From effd37402a9b0ff7669f9c918ca203b48756b8f5 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Thu, 21 Apr 2022 11:50:24 +0400 Subject: [PATCH 01/95] SMP AUTH error processing and descriptions (#544) * SMP AUTH error descriptions * change text * android error description * Update apps/android/app/src/main/res/values/strings.xml Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> * Update apps/android/app/src/main/res/values-ru/strings.xml Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> * fix translations * Apply suggestions from code review Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../main/java/chat/simplex/app/model/SimpleXAPI.kt | 9 +++++++++ apps/android/app/src/main/res/values-ru/strings.xml | 2 ++ apps/android/app/src/main/res/values/strings.xml | 2 ++ apps/ios/Shared/Model/SimpleXAPI.swift | 6 ++++++ .../en.xcloc/Localized Contents/en.xliff | 12 ++++++++++++ .../ru.xcloc/Localized Contents/ru.xliff | 12 ++++++++++++ apps/ios/ru.lproj/Localizable.strings | 6 ++++++ src/Simplex/Chat/View.hs | 5 ++++- tests/ChatTests.hs | 2 +- 9 files changed, 54 insertions(+), 2 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 1e7afd0875..14ed6cd63e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -223,6 +223,15 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt ) return false } + r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent + && r.chatError.agentError is AgentErrorType.SMP + && r.chatError.agentError.smpErr is SMPErrorType.AUTH -> { + AlertManager.shared.showAlertMsg( + generalGetString(R.string.connection_error_auth), + generalGetString(R.string.connection_error_auth_desc) + ) + return false + } else -> { apiErrorAlert("apiConnect", "Connection error", r) return false diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index 00e966e7ce..ddabcdea50 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -33,6 +33,8 @@ Вы уже соединены с %1$s! через эту ссылку. Ошибка в ссылке контакта Пожалуйста, проверьте, что вы использовали правильную ссылку, или попросите ваш контакт отправить вам новую. + Ошибка соединения (AUTH) + Возможно, ваш контакт удалил ссылку, или она уже была использована. Если это не так, то это может быть ошибкой - пожалуйста, сообщите нам об этом.\nЧтобы установить соединение, попросите ваш контакт создать еще одну ссылку и проверьте ваше соединение с сетью. Невозможно удалить контакт! Контакт %1$s! не может быть удален, так как является членом групп(ы) %2$s. Мгновенные уведомления diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 29b95f2921..30ed858e2c 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -33,6 +33,8 @@ You are already connected to %1$s! via this link. Invalid connection link Please check that you used the correct link or ask your contact to send you another one. + Connection error (AUTH) + Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection. Can\'t delete contact! Contact %1$s! cannot be deleted, they are a member of the group(s) %2$s. Instant notifications diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index e173f865cd..14a52c7e02 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -492,6 +492,12 @@ func apiConnect(connReq: String) async throws -> Bool { message: "Please check your network connection and try again." ) return false + case .chatCmdError(.errorAgent(.SMP(.AUTH))): + am.showAlertMsg( + title: "Connection error (AUTH)", + message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." + ) + return false default: throw r } } diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index d4c03f1dfa..3c41816622 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -195,6 +195,11 @@ Connection error No comment provided by engineer. + + Connection error (AUTH) + Connection error (AUTH) + No comment provided by engineer. + Connection request Connection request @@ -587,6 +592,13 @@ to scan from the app Unexpected error: %@ No comment provided by engineer. + + Unless your contact deleted the connection or this link was already used, it might be a bug - please report it. +To connect, please ask your contact to create another connection link and check that you have a stable network connection. + Unless your contact deleted the connection or this link was already used, it might be a bug - please report it. +To connect, please ask your contact to create another connection link and check that you have a stable network connection. + No comment provided by engineer. + Use SimpleX Chat servers? Use SimpleX Chat servers? diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 53bebfcc1d..377d0b05ec 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -195,6 +195,11 @@ Ошибка соединения No comment provided by engineer. + + Connection error (AUTH) + Ошибка соединения (AUTH) + No comment provided by engineer. + Connection request Запрос на соединение @@ -586,6 +591,13 @@ to scan from the app Неожиданная ошибка: %@ No comment provided by engineer. + + Unless your contact deleted the connection or this link was already used, it might be a bug - please report it. +To connect, please ask your contact to create another connection link and check that you have a stable network connection. + Возможно, ваш контакт удалил ссылку, или она уже была использована. Если это не так, то это может быть ошибкой - пожалуйста, сообщите нам об этом. +Чтобы установить соединение, попросите ваш контакт создать еще одну ссылку и проверьте ваше соединение с сетью. + No comment provided by engineer. + Use SimpleX Chat servers? Использовать серверы предосталенные SimpleX Chat? diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 443dc446bc..e5ec985260 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -142,6 +142,9 @@ /* No comment provided by engineer. */ "Connection error" = "Ошибка соединения"; +/* No comment provided by engineer. */ +"Connection error (AUTH)" = "Ошибка соединения (AUTH)"; + /* No comment provided by engineer. */ "Connection request" = "Запрос на соединение"; @@ -388,6 +391,9 @@ /* No comment provided by engineer. */ "Unexpected error: %@" = "Неожиданная ошибка: %@"; +/* No comment provided by engineer. */ +"Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." = "Возможно, ваш контакт удалил ссылку, или она уже была использована. Если это не так, то это может быть ошибкой - пожалуйста, сообщите нам об этом.\nЧтобы установить соединение, попросите ваш контакт создать еще одну ссылку и проверьте ваше соединение с сетью."; + /* No comment provided by engineer. */ "Use SimpleX Chat servers?" = "Использовать серверы предосталенные SimpleX Chat?"; diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 2d1132f4d1..e8736640a6 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -654,7 +654,10 @@ viewChatError = \case SEQuotedChatItemNotFound -> ["message not found - reply is not sent"] e -> ["chat db error: " <> sShow e] ChatErrorAgent err -> case err of - SMP SMP.AUTH -> ["error: this connection is deleted"] + SMP SMP.AUTH -> + [ "error: connection authorization failed - this could happen if connection was deleted,\ + \ secured with different credentials, or due to a bug - please re-create the connection" + ] e -> ["smp agent error: " <> sShow e] where fileNotFound fileId = ["file " <> sShow fileId <> " not found"] diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index dc49a12808..6bdd2e86de 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -1694,7 +1694,7 @@ testRejectContactAndDeleteUserContact = testChat3 aliceProfile bobProfile cathPr alice <## "To create a new chat address use /ad" cath ##> ("/c " <> cLink) - cath <## "error: this connection is deleted" + cath <## "error: connection authorization failed - this could happen if connection was deleted, secured with different credentials, or due to a bug - please re-create the connection" testDeleteConnectionRequests :: IO () testDeleteConnectionRequests = testChat3 aliceProfile bobProfile cathProfile $ From 03b8cdea8d7115bd28057bf503023219f6d78bf3 Mon Sep 17 00:00:00 2001 From: IanRDavies Date: Thu, 21 Apr 2022 14:57:36 +0100 Subject: [PATCH 02/95] android: support app links via simplex.chat (#545) * update manifest * autoverify domain * add path prefix to intent filter * support contact links * add android app readme * only allow a single simplex task at a time to avoid opening simplex 'on top' of app with link --- apps/android/app/src/main/AndroidManifest.xml | 57 ++++++++++++------- .../java/chat/simplex/app/MainActivity.kt | 6 +- apps/android/readme.md | 25 ++++++++ 3 files changed, 66 insertions(+), 22 deletions(-) create mode 100644 apps/android/readme.md diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index 17c4303b20..b7e2e930af 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -1,33 +1,35 @@ + package="chat.simplex.app"> + - - - - + + + + + android:name="SimplexApp" + android:allowBackup="true" + android:icon="@mipmap/icon" + android:label="@string/app_name" + android:supportsRtl="true" + android:theme="@style/Theme.SimpleX"> + android:name=".MainActivity" + android:launchMode="singleTask" + android:exported="true" + android:label="@string/app_name" + android:windowSoftInputMode="adjustResize" + android:theme="@style/Theme.SimpleX"> @@ -36,10 +38,24 @@ + + + + + + + + + + + + + + + android:resource="@xml/file_paths" /> @@ -57,8 +73,7 @@ android:name=".SimplexService" android:enabled="true" android:exported="false" - android:stopWithTask="false"> - + android:stopWithTask="false"> - + @@ -74,6 +89,6 @@ + android:exported="false" /> diff --git a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt index 1b08da4697..09160bd550 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt @@ -39,7 +39,6 @@ class MainActivity: ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // testJson() - processIntent(intent, vm.chatModel) setContent { SimpleXTheme { Surface( @@ -54,6 +53,11 @@ class MainActivity: ComponentActivity() { schedulePeriodicServiceRestartWorker() } + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + processIntent(intent, vm.chatModel) + } + private fun schedulePeriodicServiceRestartWorker() { val workerVersion = chatController.getAutoRestartWorkerVersion() val workPolicy = if (workerVersion == SimplexService.SERVICE_START_WORKER_VERSION) { diff --git a/apps/android/readme.md b/apps/android/readme.md new file mode 100644 index 0000000000..7822847e56 --- /dev/null +++ b/apps/android/readme.md @@ -0,0 +1,25 @@ +# Android App Development + +This readme is currently a stub and as such is in development. + +Ultimately, this readme will act as a guide to contributing to the develop of the SimpleX android app. + + +## Gotchas + +#### SHA Signature for verification for app links/deep links + +In order for the SimpleX app to be automatically adopted for opening links from https://simplex.chat the SHA certificate fingerprint for the App installed on the phone must be in the hosted [assetlinks.json](https://simplex.chat/.well-known/assetlinks.json) file on simplex.chat. + +The accepted fingerprints are in the `sha256_cert_fingerprints` list. + +To find your SHA certificate fingerprint perform the following steps. + +1. Build and install your development version of the app as usual +2. From the terminal in Android studio run `adb shell pm get-app-links chat.simplex.app` +3. Copy the signature listed in `signatures` in the result +4. Add your signature to [assetlinks.json](https://github.com/simplex-chat/website/blob/master/.well-known/assetlinks.json) in the [website repo](https://github.com/simplex-chat/website) and make a PR. On approval, wait a few minutes for the changes to propagate to the public website and then you should be able to verify SimpleX. + +More information is available [here](https://developer.android.com/training/app-links/verify-site-associations#manual-verification). If there is no response when running the `pm get-app-links` command, the intents in `AndroidManifest.xml` are likely misspecified. A verification attempt can be triggered using `adb shell pm verify-app-links --re-verify chat.simplex.app`. + +Note that this is not an issue for the app store build of the app as this is signed with our app store credentials and thus there is a stable signature over users. Developers do not have general access to these credentials for development and testing. From f5947745797025c6b1516662942621cf31a0bb59 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 21 Apr 2022 20:04:22 +0100 Subject: [PATCH 03/95] ios: push notifications (#482) * ios: get device token for push notifications * ios: receive messages when background notification is received * add notifications API, update simplexmq * chat API to register and verify notification token * update AppDelegate to recognize different notification types, update simplexmq * core: api to enable periodic background notifications * update simplexmq * chat API to delete device notification token * use base64url encoding in verification code * update simplexmq for notifications --- apps/ios/.gitignore | 2 + apps/ios/Shared/AppDelegate.swift | 59 ++++++++++++++++++ apps/ios/Shared/Model/BGManager.swift | 32 ++++++---- apps/ios/Shared/Model/ChatModel.swift | 1 + apps/ios/Shared/Model/SimpleXAPI.swift | 18 ++++++ apps/ios/Shared/SimpleXApp.swift | 1 + apps/ios/SimpleX (iOS).entitlements | 2 + apps/ios/SimpleX--iOS--Info.plist | 1 + apps/ios/SimpleX.xcodeproj/project.pbxproj | 6 ++ cabal.project | 2 +- package.yaml | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 10 ++-- src/Simplex/Chat.hs | 21 +++++-- src/Simplex/Chat/Controller.hs | 7 +++ src/Simplex/Chat/Messages.hs | 3 +- src/Simplex/Chat/Options.hs | 2 +- src/Simplex/Chat/Protocol.hs | 2 +- src/Simplex/Chat/Store.hs | 6 +- src/Simplex/Chat/Types.hs | 69 ++++++++++------------ stack.yaml | 2 +- tests/ChatClient.hs | 6 +- tests/ProtocolTests.hs | 4 +- 23 files changed, 191 insertions(+), 69 deletions(-) create mode 100644 apps/ios/Shared/AppDelegate.swift diff --git a/apps/ios/.gitignore b/apps/ios/.gitignore index 99ef9f7530..3d152a0610 100644 --- a/apps/ios/.gitignore +++ b/apps/ios/.gitignore @@ -67,3 +67,5 @@ iOSInjectionProject/ Libraries/ Shared/MyPlayground.playground/* + +testpush.sh diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift new file mode 100644 index 0000000000..09782c406c --- /dev/null +++ b/apps/ios/Shared/AppDelegate.swift @@ -0,0 +1,59 @@ +// +// AppDelegate.swift +// SimpleX +// +// Created by Evgeny on 30/03/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import UIKit + +class AppDelegate: NSObject, UIApplicationDelegate { + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { + logger.debug("AppDelegate: didFinishLaunchingWithOptions") + application.registerForRemoteNotifications() + return true + } + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + let token = deviceToken.map { String(format: "%02hhx", $0) }.joined() + ChatModel.shared.deviceToken = token + logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)") + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + logger.error("AppDelegate: didFailToRegisterForRemoteNotificationsWithError \(error.localizedDescription)") + } + + func application(_ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable : Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + logger.debug("AppDelegate: didReceiveRemoteNotification") + print(userInfo) + if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any] { + if let verification = ntfData["verification"] as? String { + logger.debug("AppDelegate: didReceiveRemoteNotification: verification, confirming \(verification)") + // TODO send to chat + completionHandler(.newData) + } else if let checkMessages = ntfData["checkMessages"] as? Bool, checkMessages { + // TODO check if app in background + logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages") + receiveMessages(completionHandler) + } else if let smpQueue = ntfData["checkMessage"] as? String { + // TODO check if app in background + logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessage \(smpQueue)") + receiveMessages(completionHandler) + } + } + } + + private func receiveMessages(_ completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + let complete = BGManager.shared.completionHandler { + logger.debug("AppDelegate: completed BGManager.receiveMessages") + completionHandler(.newData) + } + + BGManager.shared.receiveMessages(complete) + } +} diff --git a/apps/ios/Shared/Model/BGManager.swift b/apps/ios/Shared/Model/BGManager.swift index 8b66fb6e67..b76097bc79 100644 --- a/apps/ios/Shared/Model/BGManager.swift +++ b/apps/ios/Shared/Model/BGManager.swift @@ -20,7 +20,7 @@ class BGManager { static let shared = BGManager() var chatReceiver: ChatReceiver? var bgTimer: Timer? - var completed = false + var completed = true func register() { logger.debug("BGManager.register") @@ -43,36 +43,48 @@ class BGManager { private func handleRefresh(_ task: BGAppRefreshTask) { logger.debug("BGManager.handleRefresh") schedule() - self.completed = false + let completeRefresh = completionHandler { + task.setTaskCompleted(success: true) + } + task.expirationHandler = { completeRefresh("expirationHandler") } + receiveMessages(completeRefresh) + } - let completeTask: (String) -> Void = { reason in - logger.debug("BGManager.handleRefresh completeTask: \(reason)") + func completionHandler(_ complete: @escaping () -> Void) -> ((String) -> Void) { + { reason in + logger.debug("BGManager.completionHandler: \(reason)") if !self.completed { self.completed = true self.chatReceiver?.stop() self.chatReceiver = nil self.bgTimer?.invalidate() self.bgTimer = nil - task.setTaskCompleted(success: true) + complete() } } + } - task.expirationHandler = { completeTask("expirationHandler") } + func receiveMessages(_ completeReceiving: @escaping (String) -> Void) { + if (!self.completed) { + logger.debug("BGManager.receiveMessages: in progress, exiting") + return + } + self.completed = false DispatchQueue.main.async { initializeChat() if ChatModel.shared.currentUser == nil { - completeTask("no current user") + completeReceiving("no current user") return } - logger.debug("BGManager.handleRefresh: starting chat") + logger.debug("BGManager.receiveMessages: starting chat") let cr = ChatReceiver() self.chatReceiver = cr cr.start() RunLoop.current.add(Timer(timeInterval: 2, repeats: true) { timer in - logger.debug("BGManager.handleRefresh: timer") + logger.debug("BGManager.receiveMessages: timer") self.bgTimer = timer if cr.lastMsgTime.distance(to: Date.now) >= waitForMessages { - completeTask("timer (no messages after \(waitForMessages) seconds)") + completeReceiving("timer (no messages after \(waitForMessages) seconds)") } }, forMode: .default) } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 4d63b485a6..776692b913 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -23,6 +23,7 @@ final class ChatModel: ObservableObject { @Published var userAddress: String? @Published var userSMPServers: [String]? @Published var appOpenUrl: URL? + @Published var deviceToken: String? var messageDelivery: Dictionary Void> = [:] diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 14a52c7e02..3cace7634d 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -25,6 +25,8 @@ enum ChatCommand { case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent) case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) + case apiRegisterToken(token: String) + case apiVerifyToken(token: String, code: String) case getUserSMPServers case setUserSMPServers(smpServers: [String]) case addContact @@ -59,6 +61,8 @@ enum ChatCommand { } case let .apiUpdateChatItem(type, id, itemId, mc): return "/_update item \(ref(type, id)) \(itemId) \(mc.cmdString)" case let .apiDeleteChatItem(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)" + case let .apiRegisterToken(token): return "/_ntf register apn \(token)" + case let .apiVerifyToken(token, code): return "/_ntf verify apn \(token) \(code)" case .getUserSMPServers: return "/smp_servers" case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))" case .addContact: return "/connect" @@ -90,6 +94,8 @@ enum ChatCommand { case .apiSendMessage: return "apiSendMessage" case .apiUpdateChatItem: return "apiUpdateChatItem" case .apiDeleteChatItem: return "apiDeleteChatItem" + case .apiRegisterToken: return "apiRegisterToken" + case .apiVerifyToken: return "apiRegisterToken" case .getUserSMPServers: return "getUserSMPServers" case .setUserSMPServers: return "setUserSMPServers" case .addContact: return "addContact" @@ -444,6 +450,18 @@ func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteM throw r } +func apiRegisterToken(token: String) async throws { + let r = await chatSendCmd(.apiRegisterToken(token: token)) + if case .cmdOk = r { return } + throw r +} + +func apiVerifyToken(token: String, code: String) async throws { + let r = await chatSendCmd(.apiVerifyToken(token: token, code: code)) + if case .cmdOk = r { return } + throw r +} + func getUserSMPServers() throws -> [String] { let r = chatSendCmdSync(.getUserSMPServers) if case let .userSMPServers(smpServers) = r { return smpServers } diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 392857d7be..d99a0d197c 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -12,6 +12,7 @@ let logger = Logger() @main struct SimpleXApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var chatModel = ChatModel.shared @Environment(\.scenePhase) var scenePhase diff --git a/apps/ios/SimpleX (iOS).entitlements b/apps/ios/SimpleX (iOS).entitlements index 3a6f1244b0..7e024ae355 100644 --- a/apps/ios/SimpleX (iOS).entitlements +++ b/apps/ios/SimpleX (iOS).entitlements @@ -2,6 +2,8 @@ + aps-environment + development com.apple.developer.associated-domains applinks:simplex.chat diff --git a/apps/ios/SimpleX--iOS--Info.plist b/apps/ios/SimpleX--iOS--Info.plist index f996e93668..1287772e17 100644 --- a/apps/ios/SimpleX--iOS--Info.plist +++ b/apps/ios/SimpleX--iOS--Info.plist @@ -24,6 +24,7 @@ UIBackgroundModes fetch + remote-notification diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 073cfe88aa..bdd35577d8 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -18,6 +18,9 @@ 5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E261127A30FEA00F70299 /* TerminalView.swift */; }; 5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFC727B2782E00FB6C6D /* BGManager.swift */; }; 5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */; }; + 5C35CFCC27B2E91D00FB6C6D /* NtfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */; }; + 5C36027327F47AD5009F19D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C36027227F47AD5009F19D9 /* AppDelegate.swift */; }; + 5C36027427F47AD5009F19D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C36027227F47AD5009F19D9 /* AppDelegate.swift */; }; 5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */; }; 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */; }; 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; @@ -94,6 +97,7 @@ 5C2E261127A30FEA00F70299 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = ""; }; 5C35CFC727B2782E00FB6C6D /* BGManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGManager.swift; sourceTree = ""; }; 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NtfManager.swift; sourceTree = ""; }; + 5C36027227F47AD5009F19D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineWidth.swift; sourceTree = ""; }; 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FramedItemView.swift; sourceTree = ""; }; 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; }; @@ -274,6 +278,7 @@ isa = PBXGroup; children = ( 5CA059C3279559F40002BEB4 /* SimpleXApp.swift */, + 5C36027227F47AD5009F19D9 /* AppDelegate.swift */, 5CA059C4279559F40002BEB4 /* ContentView.swift */, 64DAE1502809D9F5000DA960 /* FileUtils.swift */, 5C764E87279CBC8E000C6508 /* Model */, @@ -475,6 +480,7 @@ 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */, 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */, 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */, + 5C36027327F47AD5009F19D9 /* AppDelegate.swift in Sources */, 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */, 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */, 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */, diff --git a/cabal.project b/cabal.project index a7af3451fd..448de5ce6c 100644 --- a/cabal.project +++ b/cabal.project @@ -3,7 +3,7 @@ packages: . source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: d38303d5f1d55a90dd95d951ba8d798e17699269 + tag: 7774fc327173ac86f5b94bc16742296f1d1fb85f source-repository-package type: git diff --git a/package.yaml b/package.yaml index 835bf19a9f..72ef61505d 100644 --- a/package.yaml +++ b/package.yaml @@ -30,7 +30,7 @@ dependencies: - optparse-applicative >= 0.15 && < 0.17 - process == 1.6.* - simple-logger == 0.1.* - - simplexmq >= 1.0 && < 1.1 + - simplexmq >= 1.1 && < 3.0 - sqlite-simple == 0.4.* - stm == 2.5.* - terminal == 0.2.* diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 63e6c1a0bb..94ae61487b 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."d38303d5f1d55a90dd95d951ba8d798e17699269" = "0gx9wlk0v8ml6aid9hzm1nqy357plk8sbnhfcnchna12dgm8v0sd"; + "https://github.com/simplex-chat/simplexmq.git"."7774fc327173ac86f5b94bc16742296f1d1fb85f" = "0cj4hywxmsiljgfv20syyn8cbhp56pxr5s0hfmik37l4ii8pj8rs"; "https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp"; "https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; "https://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 65c7ea92e2..92baa1eb24 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -72,7 +72,7 @@ library , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , simple-logger ==0.1.* - , simplexmq ==1.0.* + , simplexmq >=1.1 && <3.0 , sqlite-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* @@ -109,7 +109,7 @@ executable simplex-bot , process ==1.6.* , simple-logger ==0.1.* , simplex-chat - , simplexmq ==1.0.* + , simplexmq >=1.1 && <3.0 , sqlite-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* @@ -146,7 +146,7 @@ executable simplex-bot-advanced , process ==1.6.* , simple-logger ==0.1.* , simplex-chat - , simplexmq ==1.0.* + , simplexmq >=1.1 && <3.0 , sqlite-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* @@ -183,7 +183,7 @@ executable simplex-chat , process ==1.6.* , simple-logger ==0.1.* , simplex-chat - , simplexmq ==1.0.* + , simplexmq >=1.1 && <3.0 , sqlite-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* @@ -229,7 +229,7 @@ test-suite simplex-chat-test , process ==1.6.* , simple-logger ==0.1.* , simplex-chat - , simplexmq ==1.0.* + , simplexmq >=1.1 && <3.0 , sqlite-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 49c915aeb6..06f99671da 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -49,11 +49,12 @@ import Simplex.Chat.Store import Simplex.Chat.Types import Simplex.Chat.Util (ifM, safeDecodeUtf8, unlessM, whenM) import Simplex.Messaging.Agent -import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), defaultAgentConfig) +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), defaultAgentConfig) import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), PushProvider (..)) import Simplex.Messaging.Parsers (base64P, parseAll) import Simplex.Messaging.Protocol (ErrorType (..), MsgBody) import qualified Simplex.Messaging.Protocol as SMP @@ -75,8 +76,7 @@ defaultChatConfig = { agentConfig = defaultAgentConfig { tcpPort = undefined, -- agent does not listen to TCP - initialSMPServers = undefined, -- filled in newChatController - dbFile = undefined, -- filled in newChatController + dbFile = "simplex_v1", dbPoolSize = 1, yesToMigrations = False }, @@ -109,7 +109,8 @@ newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize} Ch firstTime <- not <$> doesFileExist f currentUser <- newTVarIO user initialSMPServers <- resolveServers - smpAgent <- getSMPAgentClient aCfg {dbFile = dbFilePrefix <> "_agent.db", initialSMPServers} + let servers = InitialAgentServers {smp = initialSMPServers, ntf = ["smp://smAc80rtvJKA02nysCCmiDzMUmcGnYA3gujwKU1NT30=@127.0.0.1:443"]} + smpAgent <- getSMPAgentClient aCfg {dbFile = dbFilePrefix <> "_agent.db"} servers agentAsync <- newTVarIO Nothing idsDrg <- newTVarIO =<< drgNew inputQ <- newTBQueueIO tbqSize @@ -369,6 +370,10 @@ processChatCommand = \case pure $ CRContactRequestRejected cReq APIUpdateProfile profile -> withUser (`updateProfile` profile) APIParseMarkdown text -> pure . CRApiParsedMarkdown $ parseMaybeMarkdownList text + APIRegisterToken token -> withUser $ \_ -> withAgent (`registerNtfToken` token) $> CRCmdOk + APIVerifyToken token code nonce -> withUser $ \_ -> withAgent (\a -> verifyNtfToken a token code nonce) $> CRCmdOk + APIIntervalNofication token interval -> withUser $ \_ -> withAgent (\a -> enableNtfCron a token interval) $> CRCmdOk + APIDeleteToken token -> withUser $ \_ -> withAgent (`deleteNtfToken` token) $> CRCmdOk GetUserSMPServers -> CRUserSMPServers <$> withUser (\user -> withStore (`getSMPServers` user)) SetUserSMPServers smpServers -> withUser $ \user -> withChatLock $ do withStore $ \st -> overwriteSMPServers st user smpServers @@ -1893,6 +1898,10 @@ chatCommandP = <|> "/_reject " *> (APIRejectContact <$> A.decimal) <|> "/_profile " *> (APIUpdateProfile <$> jsonP) <|> "/_parse " *> (APIParseMarkdown . safeDecodeUtf8 <$> A.takeByteString) + <|> "/_ntf register " *> (APIRegisterToken <$> tokenP) + <|> "/_ntf verify " *> (APIVerifyToken <$> tokenP <* A.space <*> strP <* A.space <*> strP) + <|> "/_ntf interval " *> (APIIntervalNofication <$> tokenP <* A.space <*> A.decimal) + <|> "/_ntf delete " *> (APIDeleteToken <$> tokenP) <|> "/smp_servers default" $> SetUserSMPServers [] <|> "/smp_servers " *> (SetUserSMPServers <$> smpServersP) <|> "/smp_servers" $> GetUserSMPServers @@ -1958,6 +1967,10 @@ chatCommandP = "text " *> (MCText . safeDecodeUtf8 <$> A.takeByteString) <|> "json " *> jsonP ciDeleteMode = "broadcast" $> CIDMBroadcast <|> "internal" $> CIDMInternal + tokenP = "apns " *> (DeviceToken PPApns <$> hexStringP) + hexStringP = + A.takeWhile (\c -> (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) >>= \s -> + if even (B.length s) then pure s else fail "odd number of hex characters" displayName = safeDecodeUtf8 <$> (B.cons <$> A.satisfy refChar <*> A.takeTill (== ' ')) sendMsgQuote msgDir = SendMessageQuote <$> displayName <* A.space <*> pure msgDir <*> quotedMsg <*> A.takeByteString quotedMsg = A.char '(' *> A.takeTill (== ')') <* A.char ')' <* optional A.space diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index dcd334c610..96c5941f58 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -22,6 +22,7 @@ import Data.Map.Strict (Map) import Data.Text (Text) import Data.Time (ZonedTime) import Data.Version (showVersion) +import Data.Word (Word16) import GHC.Generics (Generic) import Numeric.Natural import qualified Paths_simplex_chat as SC @@ -34,6 +35,8 @@ import Simplex.Messaging.Agent (AgentClient) import Simplex.Messaging.Agent.Env.SQLite (AgentConfig) import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Notifications.Protocol (DeviceToken (..)) import Simplex.Messaging.Parsers (dropPrefix, enumJSON, sumTypeJSON) import Simplex.Messaging.Protocol (CorrId) import System.IO (Handle) @@ -106,6 +109,10 @@ data ChatCommand | APIRejectContact Int64 | APIUpdateProfile Profile | APIParseMarkdown Text + | APIRegisterToken DeviceToken + | APIVerifyToken DeviceToken ByteString C.CbNonce + | APIIntervalNofication DeviceToken Word16 + | APIDeleteToken DeviceToken | GetUserSMPServers | SetUserSMPServers [SMPServer] | ChatHelp HelpSection diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index b2bc8d2218..a735566aa0 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -33,9 +33,8 @@ import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Util (eitherToMaybe, safeDecodeUtf8) import Simplex.Messaging.Agent.Protocol (AgentErrorType, AgentMsgId, MsgMeta (..)) -import Simplex.Messaging.Agent.Store.SQLite (fromTextField_) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, enumJSON, singleFieldJSON, sumTypeJSON) +import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, singleFieldJSON, sumTypeJSON) import Simplex.Messaging.Protocol (MsgBody) import Simplex.Messaging.Util ((<$?>)) diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index 09cc1e9f85..9c55d1b614 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -13,7 +13,7 @@ import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B import Options.Applicative import Simplex.Chat.Controller (updateStr, versionStr) -import Simplex.Messaging.Agent.Protocol (SMPServer (..)) +import Simplex.Messaging.Agent.Protocol (SMPServer) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (parseAll) import System.FilePath (combine) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index b52786fe86..d7cd674041 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -31,8 +31,8 @@ import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) import Simplex.Chat.Types import Simplex.Chat.Util (eitherToMaybe, safeDecodeUtf8) -import Simplex.Messaging.Agent.Store.SQLite (fromTextField_) import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (fromTextField_) import Simplex.Messaging.Util ((<$?>)) data ConnectionEntity diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index bb10843521..569368ed98 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -8,6 +8,7 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE ScopedTypeVariables #-} @@ -195,12 +196,13 @@ import Simplex.Chat.Migrations.M20220404_files_status_fields import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Util (eitherToMaybe) -import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, InvitationId, MsgMeta (..), SMPServer (..)) +import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, InvitationId, MsgMeta (..)) import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), createSQLiteStore, firstRow, withTransaction) import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String (StrEncoding (strEncode)) import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) +import Simplex.Messaging.Protocol (ProtocolServer (..), SMPServer, pattern SMPServer) import Simplex.Messaging.Util (liftIOEither, (<$$>)) import System.FilePath (takeFileName) import UnliftIO.STM @@ -3552,7 +3554,7 @@ overwriteSMPServers st User {userId} smpServers = do liftIOEither . checkConstraint SEUniqueID . withTransaction st $ \db -> do currentTs <- getCurrentTime DB.execute db "DELETE FROM smp_servers WHERE user_id = ?" (Only userId) - forM_ smpServers $ \SMPServer {host, port, keyHash} -> + forM_ smpServers $ \ProtocolServer {host, port, keyHash} -> DB.execute db [sql| diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 03eda5f6f5..edcd3940e3 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -32,9 +32,8 @@ import Database.SQLite.Simple.Ok (Ok (Ok)) import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) import Simplex.Messaging.Agent.Protocol (ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId) -import Simplex.Messaging.Agent.Store.SQLite (fromTextField_) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) +import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, sumTypeJSON) import Simplex.Messaging.Util ((<$?>)) class IsContact a where @@ -398,25 +397,25 @@ data GroupMemberCategory | GCPostMember -- member who joined after the user to whom the user was introduced (user receives x.grp.mem.new announcing these members and then x.grp.mem.fwd with invitation from these members) deriving (Eq, Show) -instance FromField GroupMemberCategory where fromField = fromTextField_ decodeText +instance FromField GroupMemberCategory where fromField = fromTextField_ textDecode -instance ToField GroupMemberCategory where toField = toField . encodeText +instance ToField GroupMemberCategory where toField = toField . textEncode instance FromJSON GroupMemberCategory where parseJSON = textParseJSON "GroupMemberCategory" instance ToJSON GroupMemberCategory where - toJSON = J.String . encodeText - toEncoding = JE.text . encodeText + toJSON = J.String . textEncode + toEncoding = JE.text . textEncode instance TextEncoding GroupMemberCategory where - decodeText = \case + textDecode = \case "user" -> Just GCUserMember "invitee" -> Just GCInviteeMember "host" -> Just GCHostMember "pre" -> Just GCPreMember "post" -> Just GCPostMember _ -> Nothing - encodeText = \case + textEncode = \case GCUserMember -> "user" GCInviteeMember -> "invitee" GCHostMember -> "host" @@ -437,15 +436,15 @@ data GroupMemberStatus | GSMemCreator -- user member that created the group (only GCUserMember) deriving (Eq, Show, Ord) -instance FromField GroupMemberStatus where fromField = fromTextField_ decodeText +instance FromField GroupMemberStatus where fromField = fromTextField_ textDecode -instance ToField GroupMemberStatus where toField = toField . encodeText +instance ToField GroupMemberStatus where toField = toField . textEncode instance FromJSON GroupMemberStatus where parseJSON = textParseJSON "GroupMemberStatus" instance ToJSON GroupMemberStatus where - toJSON = J.String . encodeText - toEncoding = JE.text . encodeText + toJSON = J.String . textEncode + toEncoding = JE.text . textEncode memberActive :: GroupMember -> Bool memberActive m = case memberStatus m of @@ -476,7 +475,7 @@ memberCurrent m = case memberStatus m of GSMemCreator -> True instance TextEncoding GroupMemberStatus where - decodeText = \case + textDecode = \case "removed" -> Just GSMemRemoved "left" -> Just GSMemLeft "deleted" -> Just GSMemGroupDeleted @@ -489,7 +488,7 @@ instance TextEncoding GroupMemberStatus where "complete" -> Just GSMemComplete "creator" -> Just GSMemCreator _ -> Nothing - encodeText = \case + textEncode = \case GSMemRemoved -> "removed" GSMemLeft -> "left" GSMemGroupDeleted -> "deleted" @@ -633,25 +632,25 @@ fileTransferCancelled (FTRcv RcvFileTransfer {cancelled}) = cancelled data FileStatus = FSNew | FSAccepted | FSConnected | FSComplete | FSCancelled deriving (Eq, Ord, Show) -instance FromField FileStatus where fromField = fromTextField_ decodeText +instance FromField FileStatus where fromField = fromTextField_ textDecode -instance ToField FileStatus where toField = toField . encodeText +instance ToField FileStatus where toField = toField . textEncode instance FromJSON FileStatus where parseJSON = textParseJSON "FileStatus" instance ToJSON FileStatus where - toJSON = J.String . encodeText - toEncoding = JE.text . encodeText + toJSON = J.String . textEncode + toEncoding = JE.text . textEncode instance TextEncoding FileStatus where - decodeText = \case + textDecode = \case "new" -> Just FSNew "accepted" -> Just FSAccepted "connected" -> Just FSConnected "complete" -> Just FSComplete "cancelled" -> Just FSCancelled _ -> Nothing - encodeText = \case + textEncode = \case FSNew -> "new" FSAccepted -> "accepted" FSConnected -> "connected" @@ -701,18 +700,18 @@ data ConnStatus ConnDeleted deriving (Eq, Show) -instance FromField ConnStatus where fromField = fromTextField_ decodeText +instance FromField ConnStatus where fromField = fromTextField_ textDecode -instance ToField ConnStatus where toField = toField . encodeText +instance ToField ConnStatus where toField = toField . textEncode instance FromJSON ConnStatus where parseJSON = textParseJSON "ConnStatus" instance ToJSON ConnStatus where - toJSON = J.String . encodeText - toEncoding = JE.text . encodeText + toJSON = J.String . textEncode + toEncoding = JE.text . textEncode instance TextEncoding ConnStatus where - decodeText = \case + textDecode = \case "new" -> Just ConnNew "joined" -> Just ConnJoined "requested" -> Just ConnRequested @@ -721,7 +720,7 @@ instance TextEncoding ConnStatus where "ready" -> Just ConnReady "deleted" -> Just ConnDeleted _ -> Nothing - encodeText = \case + textEncode = \case ConnNew -> "new" ConnJoined -> "joined" ConnRequested -> "requested" @@ -733,25 +732,25 @@ instance TextEncoding ConnStatus where data ConnType = ConnContact | ConnMember | ConnSndFile | ConnRcvFile | ConnUserContact deriving (Eq, Show) -instance FromField ConnType where fromField = fromTextField_ decodeText +instance FromField ConnType where fromField = fromTextField_ textDecode -instance ToField ConnType where toField = toField . encodeText +instance ToField ConnType where toField = toField . textEncode instance FromJSON ConnType where parseJSON = textParseJSON "ConnType" instance ToJSON ConnType where - toJSON = J.String . encodeText - toEncoding = JE.text . encodeText + toJSON = J.String . textEncode + toEncoding = JE.text . textEncode instance TextEncoding ConnType where - decodeText = \case + textDecode = \case "contact" -> Just ConnContact "member" -> Just ConnMember "snd_file" -> Just ConnSndFile "rcv_file" -> Just ConnRcvFile "user_contact" -> Just ConnUserContact _ -> Nothing - encodeText = \case + textEncode = \case ConnContact -> "contact" ConnMember -> "member" ConnSndFile -> "snd_file" @@ -812,9 +811,5 @@ data Notification = Notification {title :: Text, text :: Text} type JSONString = String -class TextEncoding a where - encodeText :: a -> Text - decodeText :: Text -> Maybe a - textParseJSON :: TextEncoding a => String -> J.Value -> JT.Parser a -textParseJSON name = J.withText name $ maybe (fail $ "bad " <> name) pure . decodeText +textParseJSON name = J.withText name $ maybe (fail $ "bad " <> name) pure . textDecode diff --git a/stack.yaml b/stack.yaml index bd9c1fda85..18a2440b9c 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: d38303d5f1d55a90dd95d951ba8d798e17699269 + commit: 7774fc327173ac86f5b94bc16742296f1d1fb85f # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 - github: simplex-chat/aeson commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7 diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 57143e7d8f..cc4ab3f7ab 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -1,6 +1,7 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TypeApplications #-} @@ -182,7 +183,10 @@ serverCfg = msgQueueQuota = 4, queueIdBytes = 12, msgIdBytes = 6, - storeLog = Nothing, + storeLogFile = Nothing, + allowNewQueues = True, + messageTTL = Just $ 7 * 86400, -- 7 days + expireMessagesInterval = Just 21600_000000, -- microseconds, 6 hours caCertificateFile = "tests/fixtures/tls/ca.crt", privateKeyFile = "tests/fixtures/tls/server.key", certificateFile = "tests/fixtures/tls/server.crt" diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 9b37be14a9..d90a416acc 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -15,7 +15,7 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (parseAll) -import Simplex.Messaging.Protocol (smpClientVRange) +import Simplex.Messaging.Protocol (ProtocolServer (..), smpClientVRange) import Test.Hspec protocolTests :: Spec @@ -23,7 +23,7 @@ protocolTests = decodeChatMessageTest srv :: SMPServer srv = - SMPServer + ProtocolServer { host = "smp.simplex.im", port = "5223", keyHash = C.KeyHash "\215m\248\251" From 7c2edff81f75bcbb5272126cf1c4ad1d2325498f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 22 Apr 2022 13:46:05 +0100 Subject: [PATCH 04/95] ios: periodic notifications UI (#548) * ios: periodic notifications UI * add missing import Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> --- apps/ios/Shared/AppDelegate.swift | 40 ++++++++-- apps/ios/Shared/Model/SimpleXAPI.swift | 52 +++++++++---- .../Views/UserSettings/SettingsView.swift | 77 +++++++++++++++++++ apps/ios/SimpleX.xcodeproj/project.pbxproj | 42 +++++----- src/Simplex/Chat.hs | 6 +- 5 files changed, 171 insertions(+), 46 deletions(-) diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index 09782c406c..3a64ce6042 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -18,8 +18,18 @@ class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { let token = deviceToken.map { String(format: "%02hhx", $0) }.joined() - ChatModel.shared.deviceToken = token logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)") + ChatModel.shared.deviceToken = token + let useNotifications = UserDefaults.standard.bool(forKey: "useNotifications") + if useNotifications { + Task { + do { + try await apiRegisterToken(token: token) + } catch { + logger.error("apiRegisterToken error: \(responseError(error))") + } + } + } } func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { @@ -30,12 +40,24 @@ class AppDelegate: NSObject, UIApplicationDelegate { didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { logger.debug("AppDelegate: didReceiveRemoteNotification") - print(userInfo) - if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any] { - if let verification = ntfData["verification"] as? String { - logger.debug("AppDelegate: didReceiveRemoteNotification: verification, confirming \(verification)") - // TODO send to chat - completionHandler(.newData) + if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any], + UserDefaults.standard.bool(forKey: "useNotifications") { + if let verification = ntfData["verification"] as? String, + let nonce = ntfData["nonce"] as? String { + if let token = ChatModel.shared.deviceToken { + logger.debug("AppDelegate: didReceiveRemoteNotification: verification, confirming \(verification)") + Task { + do { + try await apiVerifyToken(token: token, code: verification, nonce: nonce) + try await apiIntervalNofication(token: token, interval: 20) + } catch { + logger.error("AppDelegate: didReceiveRemoteNotification: apiVerifyToken or apiIntervalNofication error: \(responseError(error))") + } + completionHandler(.newData) + } + } else { + completionHandler(.noData) + } } else if let checkMessages = ntfData["checkMessages"] as? Bool, checkMessages { // TODO check if app in background logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages") @@ -44,7 +66,11 @@ class AppDelegate: NSObject, UIApplicationDelegate { // TODO check if app in background logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessage \(smpQueue)") receiveMessages(completionHandler) + } else { + completionHandler(.noData) } + } else { + completionHandler(.noData) } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 3cace7634d..4bf008600a 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -26,7 +26,9 @@ enum ChatCommand { case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) case apiRegisterToken(token: String) - case apiVerifyToken(token: String, code: String) + case apiVerifyToken(token: String, code: String, nonce: String) + case apiIntervalNofication(token: String, interval: Int) + case apiDeleteToken(token: String) case getUserSMPServers case setUserSMPServers(smpServers: [String]) case addContact @@ -61,8 +63,10 @@ enum ChatCommand { } case let .apiUpdateChatItem(type, id, itemId, mc): return "/_update item \(ref(type, id)) \(itemId) \(mc.cmdString)" case let .apiDeleteChatItem(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)" - case let .apiRegisterToken(token): return "/_ntf register apn \(token)" - case let .apiVerifyToken(token, code): return "/_ntf verify apn \(token) \(code)" + case let .apiRegisterToken(token): return "/_ntf register apns \(token)" + case let .apiVerifyToken(token, code, nonce): return "/_ntf verify apns \(token) \(code) \(nonce)" + case let .apiIntervalNofication(token, interval): return "/_ntf interval apns \(token) \(interval)" + case let .apiDeleteToken(token): return "/_ntf delete apns \(token)" case .getUserSMPServers: return "/smp_servers" case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))" case .addContact: return "/connect" @@ -96,6 +100,8 @@ enum ChatCommand { case .apiDeleteChatItem: return "apiDeleteChatItem" case .apiRegisterToken: return "apiRegisterToken" case .apiVerifyToken: return "apiRegisterToken" + case .apiIntervalNofication: return "apiIntervalNofication" + case .apiDeleteToken: return "apiDeleteToken" case .getUserSMPServers: return "getUserSMPServers" case .setUserSMPServers: return "setUserSMPServers" case .addContact: return "addContact" @@ -451,15 +457,19 @@ func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteM } func apiRegisterToken(token: String) async throws { - let r = await chatSendCmd(.apiRegisterToken(token: token)) - if case .cmdOk = r { return } - throw r + try await sendCommandOkResp(.apiRegisterToken(token: token)) } -func apiVerifyToken(token: String, code: String) async throws { - let r = await chatSendCmd(.apiVerifyToken(token: token, code: code)) - if case .cmdOk = r { return } - throw r +func apiVerifyToken(token: String, code: String, nonce: String) async throws { + try await sendCommandOkResp(.apiVerifyToken(token: token, code: code, nonce: nonce)) +} + +func apiIntervalNofication(token: String, interval: Int) async throws { + try await sendCommandOkResp(.apiIntervalNofication(token: token, interval: interval)) +} + +func apiDeleteToken(token: String) async throws { + try await sendCommandOkResp(.apiDeleteToken(token: token)) } func getUserSMPServers() throws -> [String] { @@ -469,9 +479,7 @@ func getUserSMPServers() throws -> [String] { } func setUserSMPServers(smpServers: [String]) async throws { - let r = await chatSendCmd(.setUserSMPServers(smpServers: smpServers)) - if case .cmdOk = r { return } - throw r + try await sendCommandOkResp(.setUserSMPServers(smpServers: smpServers)) } func apiAddContact() throws -> String { @@ -577,9 +585,7 @@ func apiRejectContactRequest(contactReqId: Int64) async throws { } func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) async throws { - let r = await chatSendCmd(.apiChatRead(type: type, id: id, itemRange: itemRange)) - if case .cmdOk = r { return } - throw r + try await sendCommandOkResp(.apiChatRead(type: type, id: id, itemRange: itemRange)) } func receiveFile(fileId: Int64) async throws { @@ -628,6 +634,20 @@ func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async { } } +private func sendCommandOkResp(_ cmd: ChatCommand) async throws { + let r = await chatSendCmd(cmd) + if case .cmdOk = r { return } + throw r +} + +func responseError(_ err: Error) -> String { + if let r = err as? ChatResponse { + return String(describing: r) + } else { + return err.localizedDescription + } +} + func initializeChat() { do { ChatModel.shared.currentUser = try apiGetActiveUser() diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 933f5f95bd..aa00ca8375 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -18,6 +18,9 @@ struct SettingsView: View { @Environment(\.colorScheme) var colorScheme @EnvironmentObject var chatModel: ChatModel @Binding var showSettings: Bool + @AppStorage("useNotifications") private var useNotifications: Bool = false + @State var showNotificationsAlert: Bool = false + @State var whichNotificationsAlert = NotificationAlert.enable var body: some View { let user: User = chatModel.currentUser! @@ -128,12 +131,86 @@ struct SettingsView: View { .padding(.trailing, 8) Text("Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)") } + if let token = chatModel.deviceToken { + HStack { + Image(systemName: "bolt.fill") + .padding(.trailing, 4) + NotificationsToggle(token) + } + } Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))") } } .navigationTitle("Your settings") } } + + enum NotificationAlert { + case enable + case error(LocalizedStringKey, String) + } + + private func NotificationsToggle(_ token: String) -> some View { + Toggle("Check messages", isOn: $useNotifications) + .onChange(of: useNotifications) { enable in + if enable { + showNotificationsAlert = true + whichNotificationsAlert = .enable + } else { + Task { + do { + try await apiDeleteToken(token: token) + } + catch { + DispatchQueue.main.async { + if let cr = error as? ChatResponse { + let err = String(describing: cr) + logger.error("apiDeleteToken error: \(err)") + showNotificationsAlert = true + whichNotificationsAlert = .error("Error deleting token", err) + } else { + logger.error("apiDeleteToken unknown error: \(error.localizedDescription)") + } + } + } + } + } + } + .alert(isPresented: $showNotificationsAlert) { + switch (whichNotificationsAlert) { + case .enable: return enableNotificationsAlert(token) + case let .error(title, err): return Alert(title: Text(title), message: Text(err)) + } + } + } + + private func enableNotificationsAlert(_ token: String) -> Alert { + Alert( + title: Text("Enable notifications? (BETA)"), + message: Text("The app can receive background notifications every 20 minutes to check the new messages.\n*Please note*: if you confirm, your device token will be sent to SimpleX Chat notifications server."), + primaryButton: .destructive(Text("Confirm")) { + Task { + do { + try await apiRegisterToken(token: token) + } catch { + DispatchQueue.main.async { + useNotifications = false + if let cr = error as? ChatResponse { + let err = String(describing: cr) + logger.error("apiRegisterToken error: \(err)") + showNotificationsAlert = true + whichNotificationsAlert = .error("Error registering token", err) + } else { + logger.error("apiRegisterToken unknown error: \(error.localizedDescription)") + } + } + } + } + }, secondaryButton: .cancel() { + withAnimation() { useNotifications = false } + } + ) + } } struct SettingsView_Previews: PreviewProvider { diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index bdd35577d8..d8ee08ef96 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -18,17 +18,15 @@ 5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E261127A30FEA00F70299 /* TerminalView.swift */; }; 5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFC727B2782E00FB6C6D /* BGManager.swift */; }; 5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */; }; - 5C35CFCC27B2E91D00FB6C6D /* NtfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */; }; 5C36027327F47AD5009F19D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C36027227F47AD5009F19D9 /* AppDelegate.swift */; }; - 5C36027427F47AD5009F19D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C36027227F47AD5009F19D9 /* AppDelegate.swift */; }; 5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */; }; 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */; }; 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; - 5C545C81280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C7C280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q.a */; }; - 5C545C82280D7A7E007A6B96 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C7D280D7A7E007A6B96 /* libffi.a */; }; - 5C545C83280D7A7E007A6B96 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C7E280D7A7E007A6B96 /* libgmp.a */; }; - 5C545C84280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C7F280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q-ghc8.10.7.a */; }; - 5C545C85280D7A7E007A6B96 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C80280D7A7E007A6B96 /* libgmpxx.a */; }; + 5C545C8C2812A834007A6B96 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C872812A833007A6B96 /* libgmpxx.a */; }; + 5C545C8D2812A834007A6B96 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C882812A833007A6B96 /* libgmp.a */; }; + 5C545C8E2812A834007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C892812A833007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK.a */; }; + 5C545C8F2812A834007A6B96 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C8A2812A833007A6B96 /* libffi.a */; }; + 5C545C902812A834007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C8B2812A833007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK-ghc8.10.7.a */; }; 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; }; 5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */; }; 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */; }; @@ -102,11 +100,11 @@ 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FramedItemView.swift; sourceTree = ""; }; 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; }; 5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = ""; }; - 5C545C7C280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q.a"; sourceTree = ""; }; - 5C545C7D280D7A7E007A6B96 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C545C7E280D7A7E007A6B96 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C545C7F280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q-ghc8.10.7.a"; sourceTree = ""; }; - 5C545C80280D7A7E007A6B96 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5C545C872812A833007A6B96 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5C545C882812A833007A6B96 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5C545C892812A833007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK.a"; sourceTree = ""; }; + 5C545C8A2812A833007A6B96 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C545C8B2812A833007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK-ghc8.10.7.a"; sourceTree = ""; }; 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = ""; }; 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = ""; }; 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileImage.swift; sourceTree = ""; }; @@ -161,14 +159,14 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5C545C8C2812A834007A6B96 /* libgmpxx.a in Frameworks */, + 5C545C8D2812A834007A6B96 /* libgmp.a in Frameworks */, 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */, 5C764E83279C748B000C6508 /* libz.tbd in Frameworks */, - 5C545C81280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q.a in Frameworks */, - 5C545C82280D7A7E007A6B96 /* libffi.a in Frameworks */, - 5C545C85280D7A7E007A6B96 /* libgmpxx.a in Frameworks */, - 5C545C84280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q-ghc8.10.7.a in Frameworks */, - 5C545C83280D7A7E007A6B96 /* libgmp.a in Frameworks */, + 5C545C8F2812A834007A6B96 /* libffi.a in Frameworks */, + 5C545C8E2812A834007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK.a in Frameworks */, 5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */, + 5C545C902812A834007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK-ghc8.10.7.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -213,11 +211,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C545C7D280D7A7E007A6B96 /* libffi.a */, - 5C545C7E280D7A7E007A6B96 /* libgmp.a */, - 5C545C80280D7A7E007A6B96 /* libgmpxx.a */, - 5C545C7F280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q-ghc8.10.7.a */, - 5C545C7C280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q.a */, + 5C545C8A2812A833007A6B96 /* libffi.a */, + 5C545C882812A833007A6B96 /* libgmp.a */, + 5C545C872812A833007A6B96 /* libgmpxx.a */, + 5C545C8B2812A833007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK-ghc8.10.7.a */, + 5C545C892812A833007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK.a */, ); path = Libraries; sourceTree = ""; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 06f99671da..a5bae03eee 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -54,6 +54,7 @@ import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Notifications.Client (NtfServer) import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), PushProvider (..)) import Simplex.Messaging.Parsers (base64P, parseAll) import Simplex.Messaging.Protocol (ErrorType (..), MsgBody) @@ -97,6 +98,9 @@ defaultSMPServers = "smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im" ] +defaultNtfServers :: [NtfServer] +defaultNtfServers = ["smp://ZH1Dkt2_EQRbxUUyjLlcUjg1KAhBrqfvE0xfn7Ki0Zg=@ntf1.simplex.im"] + logCfg :: LogConfig logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} @@ -109,7 +113,7 @@ newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize} Ch firstTime <- not <$> doesFileExist f currentUser <- newTVarIO user initialSMPServers <- resolveServers - let servers = InitialAgentServers {smp = initialSMPServers, ntf = ["smp://smAc80rtvJKA02nysCCmiDzMUmcGnYA3gujwKU1NT30=@127.0.0.1:443"]} + let servers = InitialAgentServers {smp = initialSMPServers, ntf = defaultNtfServers} smpAgent <- getSMPAgentClient aCfg {dbFile = dbFilePrefix <> "_agent.db"} servers agentAsync <- newTVarIO Nothing idsDrg <- newTVarIO =<< drgNew From 193287377635fd3ffd2066337d790126174fb8d4 Mon Sep 17 00:00:00 2001 From: IanRDavies Date: Fri, 22 Apr 2022 17:26:17 +0100 Subject: [PATCH 05/95] Show pending contacts (#547) * capture contact connecting event * disable navigation to not ready chat * update "pending contact" alert, Russian translations Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../java/chat/simplex/app/model/ChatModel.kt | 2 +- .../java/chat/simplex/app/model/SimpleXAPI.kt | 7 ++- .../app/views/chatlist/ChatListNavLinkView.kt | 24 ++++++++- .../app/views/chatlist/ChatPreviewView.kt | 28 ++++++----- .../app/src/main/res/values-ru/strings.xml | 5 ++ .../app/src/main/res/values/strings.xml | 7 ++- apps/ios/Shared/Model/ChatModel.swift | 2 +- apps/ios/Shared/Model/SimpleXAPI.swift | 5 ++ .../Views/ChatList/ChatListNavLink.swift | 46 ++++++++++++++++- .../en.xcloc/Localized Contents/en.xliff | 49 +++++++++++++++++++ .../ru.xcloc/Localized Contents/ru.xliff | 49 +++++++++++++++++++ apps/ios/ru.lproj/Localizable.strings | 27 ++++++++++ 12 files changed, 233 insertions(+), 18 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index eec3f1d9ff..6f3376cd29 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -357,7 +357,7 @@ class Contact( override val chatType get() = ChatType.Direct override val id get() = "@$contactId" override val apiId get() = contactId - override val ready get() = activeConn.connStatus == "ready" || activeConn.connStatus == "snd-ready" + override val ready get() = activeConn.connStatus == "ready" override val displayName get() = profile.displayName override val fullName get() = profile.fullName override val image get() = profile.image diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 14ed6cd63e..bc126724b1 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -11,7 +11,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Bolt import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier -import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import chat.simplex.app.* @@ -339,6 +338,9 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Connected()) // NtfManager.shared.notifyContactConnected(contact) } + is CR.ContactConnecting -> { + chatModel.updateContact(r.contact) + } is CR.ReceivedContactRequest -> { val contactRequest = r.contactRequest val cInfo = ChatInfo.ContactRequest(contactRequest) @@ -642,6 +644,7 @@ sealed class CR { @Serializable @SerialName("userContactLinkCreated") class UserContactLinkCreated(val connReqContact: String): CR() @Serializable @SerialName("userContactLinkDeleted") class UserContactLinkDeleted: CR() @Serializable @SerialName("contactConnected") class ContactConnected(val contact: Contact): CR() + @Serializable @SerialName("contactConnecting") class ContactConnecting(val contact: Contact): CR() @Serializable @SerialName("receivedContactRequest") class ReceivedContactRequest(val contactRequest: UserContactRequest): CR() @Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val contact: Contact): CR() @Serializable @SerialName("contactRequestRejected") class ContactRequestRejected: CR() @@ -685,6 +688,7 @@ sealed class CR { is UserContactLinkCreated -> "userContactLinkCreated" is UserContactLinkDeleted -> "userContactLinkDeleted" is ContactConnected -> "contactConnected" + is ContactConnecting -> "contactConnecting" is ReceivedContactRequest -> "receivedContactRequest" is AcceptingContactRequest -> "acceptingContactRequest" is ContactRequestRejected -> "contactRequestRejected" @@ -729,6 +733,7 @@ sealed class CR { is UserContactLinkCreated -> connReqContact is UserContactLinkDeleted -> noDetails() is ContactConnected -> json.encodeToString(contact) + is ContactConnecting -> json.encodeToString(contact) is ReceivedContactRequest -> json.encodeToString(contactRequest) is AcceptingContactRequest -> json.encodeToString(contact) is ContactRequestRejected -> noDetails() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt index f707e62c8b..b764c8d5a4 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt @@ -25,7 +25,11 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { if (chat.chatInfo is ChatInfo.ContactRequest) { contactRequestAlertDialog(chat.chatInfo, chatModel) } else { - withApi { openChat(chatModel, chat.chatInfo) } + if (chat.chatInfo.ready) { + withApi { openChat(chatModel, chat.chatInfo) } + } else { + pendingConnectionAlertDialog(chat.chatInfo, chatModel) + } } } ) @@ -63,6 +67,24 @@ fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel ) } +fun pendingConnectionAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) { + AlertManager.shared.showAlertDialog( + title = generalGetString(R.string.alert_title_contact_connection_pending), + text = generalGetString(R.string.alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry), + confirmText = generalGetString(R.string.button_delete_contact), + onConfirm = { + withApi { + val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId) + if (r) { + chatModel.removeChat(chatInfo.id) + chatModel.chatId.value = null + } + } + }, + dismissText = generalGetString(R.string.cancel_verb), + ) +} + @Composable fun ChatListNavLinkLayout(chat: Chat, click: () -> Unit) { Surface( diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt index 3c9a4158b2..f71a0ff00b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt @@ -9,6 +9,7 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -17,8 +18,7 @@ import androidx.compose.ui.unit.sp import chat.simplex.app.R import chat.simplex.app.model.Chat import chat.simplex.app.model.getTimestampText -import chat.simplex.app.ui.theme.HighOrLowlight -import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.ui.theme.* import chat.simplex.app.views.chat.item.MarkdownText import chat.simplex.app.views.helpers.* @@ -36,17 +36,21 @@ fun ChatPreviewView(chat: Chat) { maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.h3, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, + color = if (chat.chatInfo.ready) Color.Unspecified else HighOrLowlight ) - - val ci = chat.chatItems.lastOrNull() - if (ci != null) { - MarkdownText( - ci.text, ci.formattedText, ci.memberDisplayName, - metaText = ci.timestampText, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) + if (chat.chatInfo.ready) { + val ci = chat.chatItems.lastOrNull() + if (ci != null) { + MarkdownText( + ci.text, ci.formattedText, ci.memberDisplayName, + metaText = ci.timestampText, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } else { + Text(generalGetString(R.string.contact_connection_pending), color = HighOrLowlight) } } val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.createdAt) diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index ddabcdea50..f4a36949b7 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -71,6 +71,7 @@ Здравствуйте! Этот текст можно найти в Настройках Ваши чаты + соединяется… Прикрепить @@ -129,6 +130,10 @@ Принять Отклонить + + Соединение еще не установлено! + Ваш контакт должен быть в сети чтобы установить соединение.\nВы можете отменить соединение и удалить контакт (и попробовать позже с другой ссылкой). + хочет соединиться с вами! diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 30ed858e2c..cbc0bd7183 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -71,6 +71,7 @@ Welcome! This text is available in settings Your chats + connecting… Attach @@ -123,12 +124,16 @@ 💻 desktop: scan displayed QR code from the app, via Scan QR code. 📱 mobile: tap Open in mobile app, then tap Connect in the app. - + Accept connection request? If you choose to reject sender will NOT be notified. Accept Reject + + Contact is not connected yet! + Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link). + wants to connect to you! diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 776692b913..a2846d825a 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -455,7 +455,7 @@ struct Contact: Identifiable, Decodable, NamedChat { var id: ChatId { get { "@\(contactId)" } } var apiId: Int64 { get { contactId } } - var ready: Bool { get { activeConn.connStatus == "ready" || activeConn.connStatus == "snd-ready" } } + var ready: Bool { get { activeConn.connStatus == "ready" } } var displayName: String { get { profile.displayName } } var fullName: String { get { profile.fullName } } var image: String? { get { profile.image } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 4bf008600a..e62b7e6d21 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -154,6 +154,7 @@ enum ChatResponse: Decodable, Error { case userContactLinkCreated(connReqContact: String) case userContactLinkDeleted case contactConnected(contact: Contact) + case contactConnecting(contact: Contact) case receivedContactRequest(contactRequest: UserContactRequest) case acceptingContactRequest(contact: Contact) case contactRequestRejected @@ -198,6 +199,7 @@ enum ChatResponse: Decodable, Error { case .userContactLinkCreated: return "userContactLinkCreated" case .userContactLinkDeleted: return "userContactLinkDeleted" case .contactConnected: return "contactConnected" + case .contactConnecting: return "contactConnecting" case .receivedContactRequest: return "receivedContactRequest" case .acceptingContactRequest: return "acceptingContactRequest" case .contactRequestRejected: return "contactRequestRejected" @@ -245,6 +247,7 @@ enum ChatResponse: Decodable, Error { case let .userContactLinkCreated(connReq): return connReq case .userContactLinkDeleted: return noDetails case let .contactConnected(contact): return String(describing: contact) + case let .contactConnecting(contact): return String(describing: contact) case let .receivedContactRequest(contactRequest): return String(describing: contactRequest) case let .acceptingContactRequest(contact): return String(describing: contact) case .contactRequestRejected: return noDetails @@ -702,6 +705,8 @@ func processReceivedMsg(_ res: ChatResponse) { chatModel.updateContact(contact) chatModel.updateNetworkStatus(contact, .connected) NtfManager.shared.notifyContactConnected(contact) + case let .contactConnecting(contact): + chatModel.updateContact(contact) case let .receivedContactRequest(contactRequest): chatModel.addChat(Chat( chatInfo: ChatInfo.contactRequest(contactRequest: contactRequest), diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index d25ffafe53..6689d8f5db 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -53,11 +53,20 @@ struct ChatListNavLink: View { } .swipeActions(edge: .trailing) { Button(role: .destructive) { - AlertManager.shared.showAlert(deleteContactAlert(contact)) + AlertManager.shared.showAlert( + contact.ready + ? deleteContactAlert(contact) + : deletePendingContactAlert(chat, contact) + ) } label: { Label("Delete", systemImage: "trash") } } + .onTapGesture { + if !contact.ready { + AlertManager.shared.showAlert(pendingContactAlert(chat, contact)) + } + } .frame(height: 80) } @@ -150,6 +159,41 @@ struct ChatListNavLink: View { secondaryButton: .cancel() ) } + + private func pendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert { + Alert( + title: Text("Contact is not connected yet!"), + message: Text("Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)."), + primaryButton: .cancel(), + secondaryButton: .destructive(Text("Delete Contact")) { + removePendingContact(chat, contact) + } + ) + } + + private func deletePendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert { + Alert( + title: Text("Delete pending connection"), + message: Text("Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)."), + primaryButton: .destructive(Text("Delete")) { + removePendingContact(chat, contact) + }, + secondaryButton: .cancel() + ) + } + + private func removePendingContact(_ chat: Chat, _ contact: Contact) { + Task { + do { + try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId) + DispatchQueue.main.async { + chatModel.removeChat(contact.id) + } + } catch let error { + logger.error("ChatListNavLink.removePendingContact apiDeleteChat error: \(responseError(error))") + } + } + } } struct ChatListNavLink_Previews: PreviewProvider { diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 3c41816622..a76bc6a379 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -145,6 +145,11 @@ Chats back button to return to chats list + + Check messages + Check messages + No comment provided by engineer. + Choose from library Choose from library @@ -230,6 +235,11 @@ Contact is connected notification + + Contact is not connected yet! + Contact is not connected yet! + No comment provided by engineer. + Copy Copy @@ -260,6 +270,11 @@ Delete No comment provided by engineer. + + Delete Contact + Delete Contact + No comment provided by engineer. + Delete address Delete address @@ -300,6 +315,11 @@ Delete message? No comment provided by engineer. + + Delete pending connection + Delete pending connection + No comment provided by engineer. + Develop Develop @@ -315,11 +335,26 @@ Edit No comment provided by engineer. + + Enable notifications? (BETA) + Enable notifications? (BETA) + No comment provided by engineer. + Enter one SMP server per line: Enter one SMP server per line: No comment provided by engineer. + + Error deleting token + Error deleting token + No comment provided by engineer. + + + Error registering token + Error registering token + No comment provided by engineer. + Error saving SMP servers Error saving SMP servers @@ -552,6 +587,13 @@ to scan from the app The app can notify you when you receive messages or contact requests - please open settings to enable. No comment provided by engineer. + + The app can receive background notifications every 20 minutes to check the new messages. +*Please note*: if you confirm, your device token will be sent to SimpleX Chat notifications server. + The app can receive background notifications every 20 minutes to check the new messages. +*Please note*: if you confirm, your device token will be sent to SimpleX Chat notifications server. + No comment provided by engineer. + The messaging and application platform 100% private by design! The messaging and application platform 100% private by design! @@ -689,6 +731,13 @@ To connect, please ask your contact to create another connection link and check Your chats No comment provided by engineer. + + Your contact needs to be online for the connection to complete. +You can cancel this connection and remove the contact (and try later with a new link). + Your contact needs to be online for the connection to complete. +You can cancel this connection and remove the contact (and try later with a new link). + No comment provided by engineer. + Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 377d0b05ec..60d98f3542 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -145,6 +145,11 @@ Назад back button to return to chats list + + Check messages + Проверять сообщения + No comment provided by engineer. + Choose from library Выбрать из библиотеки @@ -230,6 +235,11 @@ Соединение с контактом установлено notification + + Contact is not connected yet! + Соединение еще не установлено! + No comment provided by engineer. + Copy Скопировать @@ -260,6 +270,11 @@ Удалить No comment provided by engineer. + + Delete Contact + Удалить контакт + No comment provided by engineer. + Delete address Удалить адрес @@ -300,6 +315,11 @@ Удалить сообщение? No comment provided by engineer. + + Delete pending connection + Удалить соединение + No comment provided by engineer. + Develop Для разработчиков @@ -315,11 +335,26 @@ Редактировать No comment provided by engineer. + + Enable notifications? (BETA) + Включить уведомления? (БЕТА) + No comment provided by engineer. + Enter one SMP server per line: Введите SMP серверы, каждый на отдельной строке: No comment provided by engineer. + + Error deleting token + Ошибка удаления токена + No comment provided by engineer. + + + Error registering token + Ошибка регистрации токена + No comment provided by engineer. + Error saving SMP servers Ошибка при сохранении SMP серверов @@ -551,6 +586,13 @@ to scan from the app Приложение может посылать вам уведомления о сообщениях и запросах на соединение - уведомления можно включить в Настройках. No comment provided by engineer. + + The app can receive background notifications every 20 minutes to check the new messages. +*Please note*: if you confirm, your device token will be sent to SimpleX Chat notifications server. + Приложение может получать скрытые уведомления каждые 20 минут чтобы проверить новые сообщения. +*Обратите внимание*: если вы подтвердите, токен вашего устройства будет послан на сервер SimpleX Chat. + No comment provided by engineer. + The messaging and application platform 100% private by design! Платформа для сообщений и приложений, которая защищает вашу личную информацию и безопасность. @@ -688,6 +730,13 @@ To connect, please ask your contact to create another connection link and check Ваши чаты No comment provided by engineer. + + Your contact needs to be online for the connection to complete. +You can cancel this connection and remove the contact (and try later with a new link). + Ваш контакт должен быть в сети чтобы установить соединение. +Вы можете отменить соединение и удалить контакт (и попробовать позже с другой ссылкой). + No comment provided by engineer. + Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index e5ec985260..30fdf466bd 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -106,6 +106,9 @@ /* back button to return to chats list */ "Chats" = "Назад"; +/* No comment provided by engineer. */ +"Check messages" = "Проверять сообщения"; + /* No comment provided by engineer. */ "Choose from library" = "Выбрать из библиотеки"; @@ -163,6 +166,9 @@ /* notification */ "Contact is connected" = "Соединение с контактом установлено"; +/* No comment provided by engineer. */ +"Contact is not connected yet!" = "Соединение еще не установлено!"; + /* No comment provided by engineer. */ "Copy" = "Скопировать"; @@ -190,6 +196,9 @@ /* No comment provided by engineer. */ "Delete contact" = "Удалить контакт"; +/* No comment provided by engineer. */ +"Delete Contact" = "Удалить контакт"; + /* No comment provided by engineer. */ "Delete contact?" = "Удалить контакт?"; @@ -205,6 +214,9 @@ /* No comment provided by engineer. */ "Delete message?" = "Удалить сообщение?"; +/* No comment provided by engineer. */ +"Delete pending connection" = "Удалить соединение"; + /* deleted chat item */ "deleted" = "удалено"; @@ -217,9 +229,18 @@ /* No comment provided by engineer. */ "Edit" = "Редактировать"; +/* No comment provided by engineer. */ +"Enable notifications? (BETA)" = "Включить уведомления? (БЕТА)"; + /* No comment provided by engineer. */ "Enter one SMP server per line:" = "Введите SMP серверы, каждый на отдельной строке:"; +/* No comment provided by engineer. */ +"Error deleting token" = "Ошибка удаления токена"; + +/* No comment provided by engineer. */ +"Error registering token" = "Ошибка регистрации токена"; + /* No comment provided by engineer. */ "Error saving SMP servers" = "Ошибка при сохранении SMP серверов"; @@ -367,6 +388,9 @@ /* No comment provided by engineer. */ "The app can notify you when you receive messages or contact requests - please open settings to enable." = "Приложение может посылать вам уведомления о сообщениях и запросах на соединение - уведомления можно включить в Настройках."; +/* No comment provided by engineer. */ +"The app can receive background notifications every 20 minutes to check the new messages.\n*Please note*: if you confirm, your device token will be sent to SimpleX Chat notifications server." = "Приложение может получать скрытые уведомления каждые 20 минут чтобы проверить новые сообщения.\n*Обратите внимание*: если вы подтвердите, токен вашего устройства будет послан на сервер SimpleX Chat."; + /* No comment provided by engineer. */ "The messaging and application platform 100% private by design!" = "Платформа для сообщений и приложений, которая защищает вашу личную информацию и безопасность."; @@ -448,6 +472,9 @@ /* No comment provided by engineer. */ "Your chats" = "Ваши чаты"; +/* No comment provided by engineer. */ +"Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)." = "Ваш контакт должен быть в сети чтобы установить соединение.\nВы можете отменить соединение и удалить контакт (и попробовать позже с другой ссылкой)."; + /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "Ваш профиль хранится на вашем устройстве и отправляется только вашим контактам.\nSimpleX серверы не могут получить доступ к вашему профилю."; From dcffdf83b9fb6dee4594e15d1ed1309e21452711 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 22 Apr 2022 17:53:09 +0100 Subject: [PATCH 06/95] ios: show local notification when checking messages having received background notification (#549) * ios: show local notification when checking messages having received background notification * Update apps/ios/Shared/Model/NtfManager.swift Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> * Update apps/ios/Shared/Model/NtfManager.swift Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> --- apps/ios/Shared/AppDelegate.swift | 2 ++ apps/ios/Shared/Model/NtfManager.swift | 20 +++++++++++++++++++- apps/ios/Shared/Model/SimpleXAPI.swift | 2 +- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index 3a64ce6042..aeaf6decf8 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -61,6 +61,8 @@ class AppDelegate: NSObject, UIApplicationDelegate { } else if let checkMessages = ntfData["checkMessages"] as? Bool, checkMessages { // TODO check if app in background logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages") + // TODO remove + NtfManager.shared.notifyCheckingMessages() receiveMessages(completionHandler) } else if let smpQueue = ntfData["checkMessage"] as? String { // TODO check if app in background diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index 8861968b2c..1040a80201 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -15,6 +15,8 @@ let ntfActionAccept = "NTF_ACT_ACCEPT" let ntfCategoryContactRequest = "NTF_CAT_CONTACT_REQUEST" let ntfCategoryContactConnected = "NTF_CAT_CONTACT_CONNECTED" let ntfCategoryMessageReceived = "NTF_CAT_MESSAGE_RECEIVED" +// TODO remove +let ntfCategoryCheckingMessages = "NTF_CAT_CHECKING_MESSAGES" let appNotificationId = "chat.simplex.app.notification" @@ -108,6 +110,13 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { actions: [], intentIdentifiers: [], hiddenPreviewsBodyPlaceholder: NSLocalizedString("New message", comment: "notifications") + ), + // TODO remove + UNNotificationCategory( + identifier: ntfCategoryCheckingMessages, + actions: [], + intentIdentifiers: [], + hiddenPreviewsBodyPlaceholder: NSLocalizedString("Checking new messages...", comment: "notifications") ) ]) } @@ -167,7 +176,16 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { // userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id] ) } - + + // TODO remove + func notifyCheckingMessages() { + logger.debug("NtfManager.notifyCheckingMessages") + addNotification( + categoryIdentifier: ntfCategoryCheckingMessages, + title: NSLocalizedString("Checking new messages...", comment: "notification") + ) + } + func hideSecrets(_ cItem: ChatItem) -> String { if let md = cItem.formattedText { var res = "" diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index e62b7e6d21..5067cd4b0f 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -99,7 +99,7 @@ enum ChatCommand { case .apiUpdateChatItem: return "apiUpdateChatItem" case .apiDeleteChatItem: return "apiDeleteChatItem" case .apiRegisterToken: return "apiRegisterToken" - case .apiVerifyToken: return "apiRegisterToken" + case .apiVerifyToken: return "apiVerifyToken" case .apiIntervalNofication: return "apiIntervalNofication" case .apiDeleteToken: return "apiDeleteToken" case .getUserSMPServers: return "getUserSMPServers" From 8257842914e77c125c60be742006312f9705ecbe Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 22 Apr 2022 20:32:19 +0100 Subject: [PATCH 07/95] APIRegisterToken returns NtfTknStatus (#550) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 2 +- src/Simplex/Chat/Controller.hs | 3 ++- src/Simplex/Chat/View.hs | 2 ++ stack.yaml | 2 +- 6 files changed, 8 insertions(+), 5 deletions(-) diff --git a/cabal.project b/cabal.project index 448de5ce6c..5617a6ab4f 100644 --- a/cabal.project +++ b/cabal.project @@ -3,7 +3,7 @@ packages: . source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 7774fc327173ac86f5b94bc16742296f1d1fb85f + tag: 633cd675b5cd9476dc9624512d552f3851fab34c source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 94ae61487b..a9a3364278 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."7774fc327173ac86f5b94bc16742296f1d1fb85f" = "0cj4hywxmsiljgfv20syyn8cbhp56pxr5s0hfmik37l4ii8pj8rs"; + "https://github.com/simplex-chat/simplexmq.git"."633cd675b5cd9476dc9624512d552f3851fab34c" = "0kpljb1ys7ym2lzq7xm56z6hfzkkq6j6z2b7zh5z87jr8zyxfhvk"; "https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp"; "https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; "https://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index a5bae03eee..bbd4398a1b 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -374,7 +374,7 @@ processChatCommand = \case pure $ CRContactRequestRejected cReq APIUpdateProfile profile -> withUser (`updateProfile` profile) APIParseMarkdown text -> pure . CRApiParsedMarkdown $ parseMaybeMarkdownList text - APIRegisterToken token -> withUser $ \_ -> withAgent (`registerNtfToken` token) $> CRCmdOk + APIRegisterToken token -> CRNtfTokenStatus <$> withUser (\_ -> withAgent (`registerNtfToken` token)) APIVerifyToken token code nonce -> withUser $ \_ -> withAgent (\a -> verifyNtfToken a token code nonce) $> CRCmdOk APIIntervalNofication token interval -> withUser $ \_ -> withAgent (\a -> enableNtfCron a token interval) $> CRCmdOk APIDeleteToken token -> withUser $ \_ -> withAgent (`deleteNtfToken` token) $> CRCmdOk diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 96c5941f58..1cfa91b288 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -36,7 +36,7 @@ import Simplex.Messaging.Agent.Env.SQLite (AgentConfig) import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore) import qualified Simplex.Messaging.Crypto as C -import Simplex.Messaging.Notifications.Protocol (DeviceToken (..)) +import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) import Simplex.Messaging.Parsers (dropPrefix, enumJSON, sumTypeJSON) import Simplex.Messaging.Protocol (CorrId) import System.IO (Handle) @@ -245,6 +245,7 @@ data ChatResponse | CRRcvFileSubError {rcvFileTransfer :: RcvFileTransfer, chatError :: ChatError} | CRUserContactLinkSubscribed | CRUserContactLinkSubError {chatError :: ChatError} + | CRNtfTokenStatus {status :: NtfTknStatus} | CRMessageError {severity :: Text, errorMessage :: Text} | CRChatCmdError {chatError :: ChatError} | CRChatError {chatError :: ChatError} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index e8736640a6..e625574bc0 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -31,6 +31,7 @@ import Simplex.Chat.Store (StoreError (..)) import Simplex.Chat.Styled import Simplex.Chat.Types import Simplex.Messaging.Agent.Protocol +import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.Util (bshow) @@ -140,6 +141,7 @@ responseToView testView = \case ["received file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] CRUserContactLinkSubscribed -> ["Your address is active! To show: " <> highlight' "/sa"] CRUserContactLinkSubError e -> ["user address error: " <> sShow e, "to delete your address: " <> highlight' "/da"] + CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] CRMessageError prefix err -> [plain prefix <> ": " <> plain err] CRChatError e -> viewChatError e where diff --git a/stack.yaml b/stack.yaml index 18a2440b9c..f5ac78d328 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 7774fc327173ac86f5b94bc16742296f1d1fb85f + commit: 633cd675b5cd9476dc9624512d552f3851fab34c # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 - github: simplex-chat/aeson commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7 From 0091e9f162cc36592a793ad422dcde5d6efc5938 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 23 Apr 2022 06:32:16 +0100 Subject: [PATCH 08/95] ios: show notification token status in UI (#552) * ios: show notification token status in UI * show notification token status --- apps/ios/Shared/AppDelegate.swift | 11 +++++- apps/ios/Shared/Model/ChatModel.swift | 10 +++++ apps/ios/Shared/Model/SimpleXAPI.swift | 26 ++++++++----- .../Views/UserSettings/SettingsView.swift | 38 ++++++++++++++++--- 4 files changed, 68 insertions(+), 17 deletions(-) diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index aeaf6decf8..2d427d20a7 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -19,12 +19,13 @@ class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { let token = deviceToken.map { String(format: "%02hhx", $0) }.joined() logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)") - ChatModel.shared.deviceToken = token + let m = ChatModel.shared + m.deviceToken = token let useNotifications = UserDefaults.standard.bool(forKey: "useNotifications") if useNotifications { Task { do { - try await apiRegisterToken(token: token) + m.tokenStatus = try await apiRegisterToken(token: token) } catch { logger.error("apiRegisterToken error: \(responseError(error))") } @@ -47,10 +48,16 @@ class AppDelegate: NSObject, UIApplicationDelegate { if let token = ChatModel.shared.deviceToken { logger.debug("AppDelegate: didReceiveRemoteNotification: verification, confirming \(verification)") Task { + let m = ChatModel.shared do { + if case .active = m.tokenStatus {} else { m.tokenStatus = .confirmed } try await apiVerifyToken(token: token, code: verification, nonce: nonce) + m.tokenStatus = .active try await apiIntervalNofication(token: token, interval: 20) } catch { + if let cr = error as? ChatResponse, case .chatCmdError(.errorAgent(.NTF(.AUTH))) = cr { + m.tokenStatus = .expired + } logger.error("AppDelegate: didReceiveRemoteNotification: apiVerifyToken or apiIntervalNofication error: \(responseError(error))") } completionHandler(.newData) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index a2846d825a..2a747881f0 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -24,6 +24,7 @@ final class ChatModel: ObservableObject { @Published var userSMPServers: [String]? @Published var appOpenUrl: URL? @Published var deviceToken: String? + @Published var tokenStatus = NtfTknStatus.new var messageDelivery: Dictionary Void> = [:] @@ -930,3 +931,12 @@ struct LinkPreview: Codable { var description: String = "" var image: String } + +enum NtfTknStatus: String, Decodable { + case new = "NEW" + case registered = "REGISTERED" + case invalid = "INVALID" + case confirmed = "CONFIRMED" + case active = "ACTIVE" + case expired = "EXPIRED" +} diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 5067cd4b0f..85438dd285 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -173,6 +173,7 @@ enum ChatResponse: Decodable, Error { case chatItemDeleted(deletedChatItem: AChatItem, toChatItem: AChatItem) case rcvFileAccepted case rcvFileComplete(chatItem: AChatItem) + case ntfTokenStatus(status: NtfTknStatus) case cmdOk case chatCmdError(chatError: ChatError) case chatError(chatError: ChatError) @@ -218,6 +219,7 @@ enum ChatResponse: Decodable, Error { case .chatItemDeleted: return "chatItemDeleted" case .rcvFileAccepted: return "rcvFileAccepted" case .rcvFileComplete: return "rcvFileComplete" + case .ntfTokenStatus: return "ntfTokenStatus" case .cmdOk: return "cmdOk" case .chatCmdError: return "chatCmdError" case .chatError: return "chatError" @@ -266,6 +268,7 @@ enum ChatResponse: Decodable, Error { case let .chatItemDeleted(deletedChatItem, toChatItem): return "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))" case .rcvFileAccepted: return noDetails case let .rcvFileComplete(chatItem): return String(describing: chatItem) + case let .ntfTokenStatus(status): return String(describing: status) case .cmdOk: return noDetails case let .chatCmdError(chatError): return String(describing: chatError) case let .chatError(chatError): return String(describing: chatError) @@ -459,8 +462,10 @@ func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteM throw r } -func apiRegisterToken(token: String) async throws { - try await sendCommandOkResp(.apiRegisterToken(token: token)) +func apiRegisterToken(token: String) async throws -> NtfTknStatus { + let r = await chatSendCmd(.apiRegisterToken(token: token)) + if case let .ntfTokenStatus(status) = r { return status } + throw r } func apiVerifyToken(token: String, code: String, nonce: String) async throws { @@ -959,7 +964,8 @@ enum StoreError: Decodable { enum AgentErrorType: Decodable { case CMD(cmdErr: CommandErrorType) case CONN(connErr: ConnectionErrorType) - case SMP(smpErr: SMPErrorType) + case SMP(smpErr: ProtocolErrorType) + case NTF(ntfErr: ProtocolErrorType) case BROKER(brokerErr: BrokerErrorType) case AGENT(agentErr: SMPAgentError) case INTERNAL(internalErr: String) @@ -982,17 +988,17 @@ enum ConnectionErrorType: Decodable { } enum BrokerErrorType: Decodable { - case RESPONSE(smpErr: SMPErrorType) + case RESPONSE(smpErr: ProtocolErrorType) case UNEXPECTED case NETWORK - case TRANSPORT(transportErr: SMPTransportError) + case TRANSPORT(transportErr: ProtocolTransportError) case TIMEOUT } -enum SMPErrorType: Decodable { +enum ProtocolErrorType: Decodable { case BLOCK case SESSION - case CMD(cmdErr: SMPCommandError) + case CMD(cmdErr: ProtocolCommandError) case AUTH case QUOTA case NO_MSG @@ -1000,15 +1006,15 @@ enum SMPErrorType: Decodable { case INTERNAL } -enum SMPCommandError: Decodable { +enum ProtocolCommandError: Decodable { case UNKNOWN case SYNTAX case NO_AUTH case HAS_AUTH - case NO_QUEUE + case NO_ENTITY } -enum SMPTransportError: Decodable { +enum ProtocolTransportError: Decodable { case badBlock case largeMsg case badSession diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index aa00ca8375..ff969c9ca2 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -133,9 +133,8 @@ struct SettingsView: View { } if let token = chatModel.deviceToken { HStack { - Image(systemName: "bolt.fill") - .padding(.trailing, 4) - NotificationsToggle(token) + notificationsIcon() + notificationsToggle(token) } } Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))") @@ -150,7 +149,35 @@ struct SettingsView: View { case error(LocalizedStringKey, String) } - private func NotificationsToggle(_ token: String) -> some View { + private func notificationsIcon() -> some View { + let icon: String + let color: Color + switch (chatModel.tokenStatus) { + case .new: + icon = "bolt" + color = .primary + case .registered: + icon = "bolt.fill" + color = .primary + case .invalid: + icon = "bolt.slash" + color = .primary + case .confirmed: + icon = "bolt.fill" + color = .yellow + case .active: + icon = "bolt.fill" + color = .green + case .expired: + icon = "bolt.slash.fill" + color = .primary + } + return Image(systemName: icon) + .padding(.trailing, 9) + .foregroundColor(color) + } + + private func notificationsToggle(_ token: String) -> some View { Toggle("Check messages", isOn: $useNotifications) .onChange(of: useNotifications) { enable in if enable { @@ -160,6 +187,7 @@ struct SettingsView: View { Task { do { try await apiDeleteToken(token: token) + chatModel.tokenStatus = .new } catch { DispatchQueue.main.async { @@ -191,7 +219,7 @@ struct SettingsView: View { primaryButton: .destructive(Text("Confirm")) { Task { do { - try await apiRegisterToken(token: token) + chatModel.tokenStatus = try await apiRegisterToken(token: token) } catch { DispatchQueue.main.async { useNotifications = false From e6fdb40c5967ed7b3a47e35b6a254c1d64d413c2 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 23 Apr 2022 08:35:56 +0100 Subject: [PATCH 09/95] ios: fix opening direct chats (#555) --- .../Shared/Views/ChatList/ChatListNavLink.swift | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 6689d8f5db..d6937007ba 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -16,7 +16,14 @@ struct ChatListNavLink: View { var body: some View { switch chat.chatInfo { case let .direct(contact): - contactNavLink(contact) + if contact.ready { + contactNavLink(contact) + } else { + contactNavLink(contact) + .onTapGesture { + AlertManager.shared.showAlert(pendingContactAlert(chat, contact)) + } + } case let .group(groupInfo): groupNavLink(groupInfo) case let .contactRequest(cReq): @@ -62,11 +69,6 @@ struct ChatListNavLink: View { Label("Delete", systemImage: "trash") } } - .onTapGesture { - if !contact.ready { - AlertManager.shared.showAlert(pendingContactAlert(chat, contact)) - } - } .frame(height: 80) } From 0c37282cd3d21956d6337bf711c1959195122de7 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 23 Apr 2022 11:34:08 +0100 Subject: [PATCH 10/95] ios: add translation for "Checking new messages..." test notification --- apps/ios/Shared/Model/NtfManager.swift | 4 ++-- .../en.xcloc/Localized Contents/en.xliff | 7 ++++++- .../ru.xcloc/Localized Contents/ru.xliff | 7 ++++++- apps/ios/nl.lproj/Localizable.strings | 6 ++++++ apps/ios/ru.lproj/Localizable.strings | 5 ++++- 5 files changed, 24 insertions(+), 5 deletions(-) create mode 100644 apps/ios/nl.lproj/Localizable.strings diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index 1040a80201..176ed9bf18 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -109,14 +109,14 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { identifier: ntfCategoryMessageReceived, actions: [], intentIdentifiers: [], - hiddenPreviewsBodyPlaceholder: NSLocalizedString("New message", comment: "notifications") + hiddenPreviewsBodyPlaceholder: NSLocalizedString("New message", comment: "notification") ), // TODO remove UNNotificationCategory( identifier: ntfCategoryCheckingMessages, actions: [], intentIdentifiers: [], - hiddenPreviewsBodyPlaceholder: NSLocalizedString("Checking new messages...", comment: "notifications") + hiddenPreviewsBodyPlaceholder: NSLocalizedString("Checking new messages...", comment: "notification") ) ]) } diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index a76bc6a379..fc792ef930 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -150,6 +150,11 @@ Check messages No comment provided by engineer. + + Checking new messages... + Checking new messages... + notification + Choose from library Choose from library @@ -453,7 +458,7 @@ New message New message - notifications + notification Notifications are disabled! diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 60d98f3542..e90ec57cfb 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -150,6 +150,11 @@ Проверять сообщения No comment provided by engineer. + + Checking new messages... + Проверяются новые сообщения... + notification + Choose from library Выбрать из библиотеки @@ -453,7 +458,7 @@ New message Новое сообщение - notifications + notification Notifications are disabled! diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings new file mode 100644 index 0000000000..97efd9d6d7 --- /dev/null +++ b/apps/ios/nl.lproj/Localizable.strings @@ -0,0 +1,6 @@ +/* No comment provided by engineer. */ +"Connecting server…" = "Connecting to server…"; + +/* No comment provided by engineer. */ +"Connecting server… (error: %@)" = "Connecting to server… (error: %@)"; + diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 30fdf466bd..422d019a1f 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -109,6 +109,9 @@ /* No comment provided by engineer. */ "Check messages" = "Проверять сообщения"; +/* notification */ +"Checking new messages..." = "Проверяются новые сообщения..."; + /* No comment provided by engineer. */ "Choose from library" = "Выбрать из библиотеки"; @@ -301,7 +304,7 @@ /* notification */ "New contact request" = "Новый запрос на соединение"; -/* notifications */ +/* notification */ "New message" = "Новое сообщение"; /* No comment provided by engineer. */ From 4a2bfef4b3bef24a027fc41a333b4579448934ff Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 23 Apr 2022 12:52:05 +0100 Subject: [PATCH 11/95] ios: refactor views with ViewBuilder (#558) --- .../Views/Chat/ChatItem/FramedItemView.swift | 60 ++++++++++--------- .../Views/ChatList/ChatListNavLink.swift | 21 +++---- 2 files changed, 42 insertions(+), 39 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 21670879dc..80ba354785 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -27,12 +27,7 @@ struct FramedItemView: View { let v = ZStack(alignment: .bottomTrailing) { VStack(alignment: .leading, spacing: 0) { if let qi = chatItem.quotedItem { - if let imgWidth = imgWidth, imgWidth < maxWidth { - ciQuoteView(qi) - .frame(maxWidth: imgWidth, alignment: .leading) - } else { - ciQuoteView(qi) - } + ciQuoteView(qi) } if chatItem.formattedText == nil && chatItem.file == nil && isShortEmoji(chatItem.content.text) { @@ -58,12 +53,7 @@ struct FramedItemView: View { value: .white ) } else { - let v = ciMsgContentView (chatItem, showMember) - if let imgWidth = imgWidth, imgWidth < maxWidth { - v.frame(maxWidth: imgWidth, alignment: .leading) - } else { - v - } + ciMsgContentView (chatItem, showMember) } case let .link(_, preview): CILinkView(linkPreview: preview) @@ -100,8 +90,8 @@ struct FramedItemView: View { ) } - private func ciQuoteView(_ qi: CIQuote) -> some View { - ZStack(alignment: .topTrailing) { + @ViewBuilder private func ciQuoteView(_ qi: CIQuote) -> some View { + let v = ZStack(alignment: .topTrailing) { if case let .image(_, image) = qi.content, let data = Data(base64Encoded: dropImagePrefix(image)), let uiImage = UIImage(data: data) { @@ -123,6 +113,33 @@ struct FramedItemView: View { ? (colorScheme == .light ? sentQuoteColorLight : sentQuoteColorDark) : Color(uiColor: .quaternarySystemFill) ) + + if let imgWidth = imgWidth, imgWidth < maxWidth { + v.frame(maxWidth: imgWidth, alignment: .leading) + } else { + v + } + } + + @ViewBuilder private func ciMsgContentView(_ ci: ChatItem, _ showMember: Bool = false) -> some View { + let v = MsgContentView( + content: ci.content, + formattedText: ci.formattedText, + sender: showMember ? ci.memberDisplayName : nil, + metaText: ci.timestampText, + edited: ci.meta.itemEdited + ) + .padding(.vertical, 6) + .padding(.horizontal, 12) + .overlay(DetermineWidth()) + .frame(minWidth: 0, alignment: .leading) + .textSelection(.enabled) + + if let imgWidth = imgWidth, imgWidth < maxWidth { + v.frame(maxWidth: imgWidth, alignment: .leading) + } else { + v + } } } @@ -144,21 +161,6 @@ private func ciQuotedMsgView(_ qi: CIQuote) -> some View { .padding(.horizontal, 12) } -private func ciMsgContentView(_ ci: ChatItem, _ showMember: Bool = false) -> some View { - MsgContentView( - content: ci.content, - formattedText: ci.formattedText, - sender: showMember ? ci.memberDisplayName : nil, - metaText: ci.timestampText, - edited: ci.meta.itemEdited - ) - .padding(.vertical, 6) - .padding(.horizontal, 12) - .overlay(DetermineWidth()) - .frame(minWidth: 0, alignment: .leading) - .textSelection(.enabled) -} - func chatItemFrameColor(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Color { ci.chatDir.sent ? (colorScheme == .light ? sentColorLight : sentColorDark) diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index d6937007ba..bf20e1bd32 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -16,14 +16,7 @@ struct ChatListNavLink: View { var body: some View { switch chat.chatInfo { case let .direct(contact): - if contact.ready { - contactNavLink(contact) - } else { - contactNavLink(contact) - .onTapGesture { - AlertManager.shared.showAlert(pendingContactAlert(chat, contact)) - } - } + contactNavLink(contact) case let .group(groupInfo): groupNavLink(groupInfo) case let .contactRequest(cReq): @@ -45,8 +38,8 @@ struct ChatListNavLink: View { } } - private func contactNavLink(_ contact: Contact) -> some View { - NavLinkPlain( + @ViewBuilder private func contactNavLink(_ contact: Contact) -> some View { + let v = NavLinkPlain( tag: chat.chatInfo.id, selection: $chatModel.chatId, destination: { chatView() }, @@ -70,6 +63,14 @@ struct ChatListNavLink: View { } } .frame(height: 80) + + if contact.ready { + v + } else { + v.onTapGesture { + AlertManager.shared.showAlert(pendingContactAlert(chat, contact)) + } + } } private func groupNavLink(_ groupInfo: GroupInfo) -> some View { From a525f2496939e4d834c6ce81021b98204fd357a8 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 23 Apr 2022 13:34:51 +0100 Subject: [PATCH 12/95] remove nl translations (will conflict with locale-nl branch) --- apps/ios/nl.lproj/Localizable.strings | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 apps/ios/nl.lproj/Localizable.strings diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings deleted file mode 100644 index 97efd9d6d7..0000000000 --- a/apps/ios/nl.lproj/Localizable.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* No comment provided by engineer. */ -"Connecting server…" = "Connecting to server…"; - -/* No comment provided by engineer. */ -"Connecting server… (error: %@)" = "Connecting to server… (error: %@)"; - From 14514050aee99f757e9d9ecff75807aa46c8dce8 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 23 Apr 2022 17:32:40 +0100 Subject: [PATCH 13/95] core: api for pending contact connections (#553) * core: api for pending contact connections * core: pending contact connection events / api --- src/Simplex/Chat.hs | 22 +++++++--- src/Simplex/Chat/Controller.hs | 4 +- src/Simplex/Chat/Messages.hs | 8 +++- src/Simplex/Chat/Store.hs | 76 +++++++++++++++++++++++++++++----- src/Simplex/Chat/Types.hs | 47 ++++++++------------- src/Simplex/Chat/View.hs | 3 ++ 6 files changed, 112 insertions(+), 48 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index bbd4398a1b..f7abde0677 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -183,11 +183,12 @@ processChatCommand = \case ff <- asks filesFolder atomically . writeTVar ff $ Just filesFolder' pure CRCmdOk - APIGetChats -> CRApiChats <$> withUser (\user -> withStore (`getChatPreviews` user)) + APIGetChats withPCC -> CRApiChats <$> withUser (\user -> withStore $ \st -> getChatPreviews st user withPCC) APIGetChat cType cId pagination -> withUser $ \user -> case cType of CTDirect -> CRApiChat . AChat SCTDirect <$> withStore (\st -> getDirectChat st user cId pagination) CTGroup -> CRApiChat . AChat SCTGroup <$> withStore (\st -> getGroupChat st user cId pagination) CTContactRequest -> pure $ chatCmdError "not implemented" + CTContactConnection -> pure $ chatCmdError "not supported" APIGetChatItems _pagination -> pure $ chatCmdError "not implemented" APISendMessage cType chatId file_ quotedItemId_ mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of CTDirect -> do @@ -263,6 +264,7 @@ processChatCommand = \case quoteData (CIRcvMsgContent qmc) (CIGroupRcv m) _ = pure (qmc, CIQGroupRcv $ Just m, False, m) quoteData _ _ _ = throwChatError CEInvalidQuote CTContactRequest -> pure $ chatCmdError "not supported" + CTContactConnection -> pure $ chatCmdError "not supported" where quoteContent qmc = \case MCText _ -> qmc @@ -300,6 +302,7 @@ processChatCommand = \case _ -> throwChatError CEInvalidChatItemUpdate CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate CTContactRequest -> pure $ chatCmdError "not supported" + CTContactConnection -> pure $ chatCmdError "not supported" APIDeleteChatItem cType chatId itemId mode -> withUser $ \user@User {userId} -> withChatLock $ case cType of CTDirect -> do (ct@Contact {localDisplayName = c}, CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}, file}) <- withStore $ \st -> (,) <$> getContact st userId chatId <*> getDirectChatItem st userId chatId itemId @@ -332,6 +335,7 @@ processChatCommand = \case pure $ CRChatItemDeleted (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) toCi (CIDMBroadcast, _, _) -> throwChatError CEInvalidChatItemDelete CTContactRequest -> pure $ chatCmdError "not supported" + CTContactConnection -> pure $ chatCmdError "not supported" where deleteFile :: MsgDirectionI d => UserId -> Maybe (CIFile d) -> m () deleteFile userId file = @@ -343,6 +347,7 @@ processChatCommand = \case CTDirect -> withStore (\st -> updateDirectChatItemsRead st chatId fromToIds) $> CRCmdOk CTGroup -> withStore (\st -> updateGroupChatItemsRead st chatId fromToIds) $> CRCmdOk CTContactRequest -> pure $ chatCmdError "not supported" + CTContactConnection -> pure $ chatCmdError "not supported" APIDeleteChat cType chatId -> withUser $ \User {userId} -> case cType of CTDirect -> do ct@Contact {localDisplayName} <- withStore $ \st -> getContact st userId chatId @@ -360,6 +365,8 @@ processChatCommand = \case unsetActive $ ActiveC localDisplayName pure $ CRContactDeleted ct gs -> throwChatError $ CEContactGroups ct gs + CTContactConnection -> + CRContactConnectionDeleted <$> withStore (\st -> deletePendingContactConnection st userId chatId) CTGroup -> pure $ chatCmdError "not implemented" CTContactRequest -> pure $ chatCmdError "not supported" APIAcceptContact connReqId -> withUser $ \user@User {userId} -> withChatLock $ do @@ -387,11 +394,13 @@ processChatCommand = \case Welcome -> withUser $ pure . CRWelcome AddContact -> withUser $ \User {userId} -> withChatLock . procCmd $ do (connId, cReq) <- withAgent (`createConnection` SCMInvitation) - withStore $ \st -> createDirectConnection st userId connId + conn <- withStore $ \st -> createDirectConnection st userId connId ConnNew + toView $ CRNewContactConnection conn pure $ CRInvitation cReq Connect (Just (ACR SCMInvitation cReq)) -> withUser $ \User {userId, profile} -> withChatLock . procCmd $ do connId <- withAgent $ \a -> joinConnection a cReq . directMessage $ XInfo profile - withStore $ \st -> createDirectConnection st userId connId + conn <- withStore $ \st -> createDirectConnection st userId connId ConnJoined + toView $ CRNewContactConnection conn pure CRSentConfirmation Connect (Just (ACR SCMContact cReq)) -> withUser $ \User {userId, profile} -> connectViaContact userId cReq profile @@ -647,7 +656,8 @@ processChatCommand = \case let randomXContactId = XContactId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16)) xContactId <- maybe randomXContactId pure xContactId_ connId <- withAgent $ \a -> joinConnection a cReq $ directMessage (XContact profile $ Just xContactId) - withStore $ \st -> createConnReqConnection st userId connId cReqHash xContactId + conn <- withStore $ \st -> createConnReqConnection st userId connId cReqHash xContactId + toView $ CRNewContactConnection conn pure CRSentInvitation contactMember :: Contact -> [GroupMember] -> Maybe GroupMember contactMember Contact {contactId} = @@ -1889,7 +1899,7 @@ chatCommandP = <|> ("/user" <|> "/u") $> ShowActiveUser <|> "/_start" $> StartChat <|> "/_files_folder " *> (SetFilesFolder <$> filePath) - <|> "/_get chats" $> APIGetChats + <|> "/_get chats" *> (APIGetChats <$> (" connections" $> True <|> pure False)) <|> "/_get chat " *> (APIGetChat <$> chatTypeP <*> A.decimal <* A.space <*> chatPaginationP) <|> "/_get items count=" *> (APIGetChatItems <$> A.decimal) <|> "/_send " *> (APISendMessage <$> chatTypeP <*> A.decimal <*> optional filePathTagged <*> optional quotedItemIdTagged <* A.space <*> msgContentP) @@ -1962,7 +1972,7 @@ chatCommandP = where imagePrefix = (<>) <$> "data:" <*> ("image/png;base64," <|> "image/jpg;base64,") imageP = safeDecodeUtf8 <$> ((<>) <$> imagePrefix <*> (B64.encode <$> base64P)) - chatTypeP = A.char '@' $> CTDirect <|> A.char '#' $> CTGroup + chatTypeP = A.char '@' $> CTDirect <|> A.char '#' $> CTGroup <|> A.char ':' $> CTContactConnection chatPaginationP = (CPLast <$ "count=" <*> A.decimal) <|> (CPAfter <$ "after=" <*> A.decimal <* A.space <* "count=" <*> A.decimal) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 1cfa91b288..9d08c72bd3 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -96,7 +96,7 @@ data ChatCommand | CreateActiveUser Profile | StartChat | SetFilesFolder FilePath - | APIGetChats + | APIGetChats {pendingConnections :: Bool} | APIGetChat ChatType Int64 ChatPagination | APIGetChatItems Int | APISendMessage ChatType Int64 (Maybe FilePath) (Maybe ChatItemId) MsgContent @@ -246,6 +246,8 @@ data ChatResponse | CRUserContactLinkSubscribed | CRUserContactLinkSubError {chatError :: ChatError} | CRNtfTokenStatus {status :: NtfTknStatus} + | CRNewContactConnection {connection :: PendingContactConnection} + | CRContactConnectionDeleted {connection :: PendingContactConnection} | CRMessageError {severity :: Text, errorMessage :: Text} | CRChatCmdError {chatError :: ChatError} | CRChatError {chatError :: ChatError} diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index a735566aa0..33528b7cac 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -38,7 +38,7 @@ import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, singleFi import Simplex.Messaging.Protocol (MsgBody) import Simplex.Messaging.Util ((<$?>)) -data ChatType = CTDirect | CTGroup | CTContactRequest +data ChatType = CTDirect | CTGroup | CTContactRequest | CTContactConnection deriving (Show, Generic) instance ToJSON ChatType where @@ -49,6 +49,7 @@ data ChatInfo (c :: ChatType) where DirectChat :: Contact -> ChatInfo 'CTDirect GroupChat :: GroupInfo -> ChatInfo 'CTGroup ContactRequest :: UserContactRequest -> ChatInfo 'CTContactRequest + ContactConnection :: PendingContactConnection -> ChatInfo 'CTContactConnection deriving instance Show (ChatInfo c) @@ -56,6 +57,7 @@ data JSONChatInfo = JCInfoDirect {contact :: Contact} | JCInfoGroup {groupInfo :: GroupInfo} | JCInfoContactRequest {contactRequest :: UserContactRequest} + | JCInfoContactConnection {contactConnection :: PendingContactConnection} deriving (Generic) instance ToJSON JSONChatInfo where @@ -71,6 +73,7 @@ jsonChatInfo = \case DirectChat c -> JCInfoDirect c GroupChat g -> JCInfoGroup g ContactRequest g -> JCInfoContactRequest g + ContactConnection c -> JCInfoContactConnection c data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem { chatDir :: CIDirection c d, @@ -519,12 +522,15 @@ data SChatType (c :: ChatType) where SCTDirect :: SChatType 'CTDirect SCTGroup :: SChatType 'CTGroup SCTContactRequest :: SChatType 'CTContactRequest + SCTContactConnection :: SChatType 'CTContactConnection deriving instance Show (SChatType c) instance TestEquality SChatType where testEquality SCTDirect SCTDirect = Just Refl testEquality SCTGroup SCTGroup = Just Refl + testEquality SCTContactRequest SCTContactRequest = Just Refl + testEquality SCTContactConnection SCTContactConnection = Just Refl testEquality _ _ = Nothing class ChatTypeI (c :: ChatType) where diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 569368ed98..6d6e767308 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -151,6 +151,7 @@ module Simplex.Chat.Store updateGroupChatItemsRead, getSMPServers, overwriteSMPServers, + deletePendingContactConnection, ) where @@ -171,7 +172,7 @@ import Data.Function (on) import Data.Functor (($>)) import Data.Int (Int64) import Data.List (find, sortBy, sortOn) -import Data.Maybe (fromMaybe, listToMaybe) +import Data.Maybe (fromMaybe, isJust, listToMaybe) import Data.Ord (Down (..)) import Data.Text (Text) import qualified Data.Text as T @@ -296,10 +297,11 @@ setActiveUser st userId = do DB.execute_ db "UPDATE users SET active_user = 0" DB.execute db "UPDATE users SET active_user = 1 WHERE user_id = ?" (Only userId) -createConnReqConnection :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> m () +createConnReqConnection :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> m PendingContactConnection createConnReqConnection st userId acId cReqHash xContactId = do liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime + createdAt <- getCurrentTime + let pccConnStatus = ConnJoined DB.execute db [sql| @@ -308,7 +310,9 @@ createConnReqConnection st userId acId cReqHash xContactId = do created_at, updated_at, via_contact_uri_hash, xcontact_id ) VALUES (?,?,?,?,?,?,?,?) |] - (userId, acId, ConnNew, ConnContact, currentTs, currentTs, cReqHash, xContactId) + (userId, acId, pccConnStatus, ConnContact, createdAt, createdAt, cReqHash, xContactId) + pccConnId <- insertedRowId db + pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, createdAt} getConnReqContactXContactId :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnReqUriHash -> m (Maybe Contact, Maybe XContactId) getConnReqContactXContactId st userId cReqHash = do @@ -345,11 +349,19 @@ getConnReqContactXContactId st userId cReqHash = do "SELECT xcontact_id FROM connections WHERE user_id = ? AND via_contact_uri_hash = ? LIMIT 1" (userId, cReqHash) -createDirectConnection :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnId -> m () -createDirectConnection st userId agentConnId = +createDirectConnection :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnId -> ConnStatus -> m PendingContactConnection +createDirectConnection st userId acId pccConnStatus = liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - void $ createContactConnection_ db userId agentConnId Nothing 0 currentTs + createdAt <- getCurrentTime + DB.execute + db + [sql| + INSERT INTO connections + (user_id, agent_conn_id, conn_status, conn_type, created_at, updated_at) VALUES (?,?,?,?,?,?) + |] + (userId, acId, pccConnStatus, ConnContact, createdAt, createdAt) + pccConnId <- insertedRowId db + pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, createdAt, viaContactUri = False} createContactConnection_ :: DB.Connection -> UserId -> ConnId -> Maybe Int64 -> Int -> UTCTime -> IO Connection createContactConnection_ db userId = createConnection_ db userId ConnContact Nothing @@ -2500,13 +2512,14 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe ciQuoteGroup [] = ciQuote Nothing $ CIQGroupRcv Nothing ciQuoteGroup ((Only itemId :. memberRow) : _) = ciQuote itemId . CIQGroupRcv . Just $ toGroupMember userContactId memberRow -getChatPreviews :: MonadUnliftIO m => SQLiteStore -> User -> m [AChat] -getChatPreviews st user = +getChatPreviews :: MonadUnliftIO m => SQLiteStore -> User -> Bool -> m [AChat] +getChatPreviews st user withPCC = liftIO . withTransaction st $ \db -> do directChats <- getDirectChatPreviews_ db user groupChats <- getGroupChatPreviews_ db user cReqChats <- getContactRequestChatPreviews_ db user - pure $ sortOn (Down . ts) (directChats <> groupChats <> cReqChats) + connChats <- getContactConnectionChatPreviews_ db user withPCC + pure $ sortOn (Down . ts) (directChats <> groupChats <> cReqChats <> connChats) where ts :: AChat -> UTCTime ts (AChat _ Chat {chatItems = ci : _}) = chatItemTs ci @@ -2514,6 +2527,7 @@ getChatPreviews st user = DirectChat Contact {createdAt} -> createdAt GroupChat GroupInfo {createdAt} -> createdAt ContactRequest UserContactRequest {createdAt} -> createdAt + ContactConnection PendingContactConnection {createdAt} -> createdAt chatItemTs :: CChatItem d -> UTCTime chatItemTs (CChatItem _ ChatItem {meta = CIMeta {itemTs}}) = itemTs @@ -2671,6 +2685,45 @@ getContactRequestChatPreviews_ db User {userId} = stats = ChatStats {unreadCount = 0, minUnreadItemId = 0} in AChat SCTContactRequest $ Chat (ContactRequest cReq) [] stats +getContactConnectionChatPreviews_ :: DB.Connection -> User -> Bool -> IO [AChat] +getContactConnectionChatPreviews_ _ _ False = pure [] +getContactConnectionChatPreviews_ db User {userId} _ = + map toContactConnectionChatPreview + <$> DB.query + db + [sql| + SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, created_at + FROM connections + WHERE user_id = ? AND conn_type = ? AND contact_id IS NULL AND conn_level = 0 AND via_contact IS NULL + |] + (userId, ConnContact) + where + toContactConnectionChatPreview :: (Int64, ConnId, ConnStatus, Maybe ByteString, UTCTime) -> AChat + toContactConnectionChatPreview connRow = + let conn = toPendingContactConnection connRow + stats = ChatStats {unreadCount = 0, minUnreadItemId = 0} + in AChat SCTContactConnection $ Chat (ContactConnection conn) [] stats + +deletePendingContactConnection :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m PendingContactConnection +deletePendingContactConnection st userId connId = + liftIOEither . withTransaction st $ \db -> runExceptT $ do + conn <- + ExceptT . firstRow toPendingContactConnection (SEPendingConnectionNotFound connId) $ + DB.query + db + [sql| + SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, created_at + FROM connections + WHERE user_id = ? AND conn_type = ? AND contact_id IS NULL AND conn_level = 0 AND via_contact IS NULL + |] + (userId, ConnContact) + liftIO $ DB.execute db "DELETE FROM connections WHERE connection_id = ?" (Only connId) + pure conn + +toPendingContactConnection :: (Int64, ConnId, ConnStatus, Maybe ByteString, UTCTime) -> PendingContactConnection +toPendingContactConnection (pccConnId, acId, pccConnStatus, connReqHash, createdAt) = + PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = isJust connReqHash, createdAt} + getDirectChat :: StoreMonad m => SQLiteStore -> User -> Int64 -> ChatPagination -> m (Chat 'CTDirect) getDirectChat st user contactId pagination = liftIOEither . withTransaction st $ \db -> runExceptT $ do @@ -3646,6 +3699,7 @@ data StoreError | SESharedMsgIdNotFoundByFileId {fileId :: FileTransferId} | SEFileIdNotFoundBySharedMsgId {sharedMsgId :: SharedMsgId} | SEConnectionNotFound {agentConnId :: AgentConnId} + | SEPendingConnectionNotFound {connId :: Int64} | SEIntroNotFound | SEUniqueID | SEInternalError {message :: String} diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index edcd3940e3..65f2fa4565 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -72,7 +72,7 @@ data Contact = Contact viaGroup :: Maybe Int64, createdAt :: UTCTime } - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show, Generic) instance ToJSON Contact where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} @@ -101,7 +101,7 @@ data UserContactRequest = UserContactRequest createdAt :: UTCTime, xContactId :: Maybe XContactId } - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show, Generic) instance ToJSON UserContactRequest where toEncoding = J.genericToEncoding J.defaultOptions @@ -160,7 +160,7 @@ data GroupInfo = GroupInfo membership :: GroupMember, createdAt :: UTCTime } - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show, Generic) instance ToJSON GroupInfo where toEncoding = J.genericToEncoding J.defaultOptions @@ -262,7 +262,7 @@ data GroupMember = GroupMember memberContactId :: Maybe Int64, activeConn :: Maybe Connection } - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show, Generic) instance ToJSON GroupMember where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} @@ -306,9 +306,6 @@ instance ToJSON MemberId where data InvitedBy = IBContact {byContactId :: Int64} | IBUser | IBUnknown deriving (Eq, Show, Generic) -instance FromJSON InvitedBy where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "IB" - instance ToJSON InvitedBy where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "IB" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "IB" @@ -401,8 +398,6 @@ instance FromField GroupMemberCategory where fromField = fromTextField_ textDeco instance ToField GroupMemberCategory where toField = toField . textEncode -instance FromJSON GroupMemberCategory where parseJSON = textParseJSON "GroupMemberCategory" - instance ToJSON GroupMemberCategory where toJSON = J.String . textEncode toEncoding = JE.text . textEncode @@ -440,8 +435,6 @@ instance FromField GroupMemberStatus where fromField = fromTextField_ textDecode instance ToField GroupMemberStatus where toField = toField . textEncode -instance FromJSON GroupMemberStatus where parseJSON = textParseJSON "GroupMemberStatus" - instance ToJSON GroupMemberStatus where toJSON = J.String . textEncode toEncoding = JE.text . textEncode @@ -536,7 +529,7 @@ data RcvFileTransfer = RcvFileTransfer cancelled :: Bool, grpMemberId :: Maybe Int64 } - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show, Generic) instance ToJSON RcvFileTransfer where toEncoding = J.genericToEncoding J.defaultOptions @@ -548,9 +541,6 @@ data RcvFileStatus | RFSCancelled RcvFileInfo deriving (Eq, Show, Generic) -instance FromJSON RcvFileStatus where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RFS" - instance ToJSON RcvFileStatus where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RFS" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RFS" @@ -560,7 +550,7 @@ data RcvFileInfo = RcvFileInfo connId :: Int64, agentConnId :: AgentConnId } - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show, Generic) instance ToJSON RcvFileInfo where toEncoding = J.genericToEncoding J.defaultOptions @@ -572,9 +562,6 @@ instance StrEncoding AgentConnId where strDecode s = AgentConnId <$> strDecode s strP = AgentConnId <$> strP -instance FromJSON AgentConnId where - parseJSON = strParseJSON "AgentConnId" - instance ToJSON AgentConnId where toJSON = strToJSON toEncoding = strToJEncoding @@ -591,9 +578,6 @@ instance StrEncoding AgentInvId where strDecode s = AgentInvId <$> strDecode s strP = AgentInvId <$> strP -instance FromJSON AgentInvId where - parseJSON = strParseJSON "AgentInvId" - instance ToJSON AgentInvId where toJSON = strToJSON toEncoding = strToJEncoding @@ -636,8 +620,6 @@ instance FromField FileStatus where fromField = fromTextField_ textDecode instance ToField FileStatus where toField = toField . textEncode -instance FromJSON FileStatus where parseJSON = textParseJSON "FileStatus" - instance ToJSON FileStatus where toJSON = J.String . textEncode toEncoding = JE.text . textEncode @@ -674,7 +656,7 @@ data Connection = Connection entityId :: Maybe Int64, -- contact, group member, file ID or user contact ID createdAt :: UTCTime } - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show, Generic) aConnId :: Connection -> ConnId aConnId Connection {agentConnId = AgentConnId cId} = cId @@ -683,6 +665,17 @@ instance ToJSON Connection where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +data PendingContactConnection = PendingContactConnection + { pccConnId :: Int64, + pccAgentConnId :: AgentConnId, + pccConnStatus :: ConnStatus, + viaContactUri :: Bool, + createdAt :: UTCTime + } + deriving (Eq, Show, Generic) + +instance ToJSON PendingContactConnection where toEncoding = J.genericToEncoding J.defaultOptions + data ConnStatus = -- | connection is created by initiating party with agent NEW command (createConnection) ConnNew @@ -704,8 +697,6 @@ instance FromField ConnStatus where fromField = fromTextField_ textDecode instance ToField ConnStatus where toField = toField . textEncode -instance FromJSON ConnStatus where parseJSON = textParseJSON "ConnStatus" - instance ToJSON ConnStatus where toJSON = J.String . textEncode toEncoding = JE.text . textEncode @@ -736,8 +727,6 @@ instance FromField ConnType where fromField = fromTextField_ textDecode instance ToField ConnType where toField = toField . textEncode -instance FromJSON ConnType where parseJSON = textParseJSON "ConnType" - instance ToJSON ConnType where toJSON = J.String . textEncode toEncoding = JE.text . textEncode diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index e625574bc0..2c5a6463a8 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -141,6 +141,8 @@ responseToView testView = \case ["received file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] CRUserContactLinkSubscribed -> ["Your address is active! To show: " <> highlight' "/sa"] CRUserContactLinkSubError e -> ["user address error: " <> sShow e, "to delete your address: " <> highlight' "/da"] + CRNewContactConnection _ -> [] + CRContactConnectionDeleted _ -> [] CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] CRMessageError prefix err -> [plain prefix <> ": " <> plain err] CRChatError e -> viewChatError e @@ -152,6 +154,7 @@ responseToView testView = \case toChatView (AChat _ (Chat (DirectChat Contact {localDisplayName}) items _)) = ("@" <> localDisplayName, toCIPreview items) toChatView (AChat _ (Chat (GroupChat GroupInfo {localDisplayName}) items _)) = ("#" <> localDisplayName, toCIPreview items) toChatView (AChat _ (Chat (ContactRequest UserContactRequest {localDisplayName}) items _)) = ("<@" <> localDisplayName, toCIPreview items) + toChatView (AChat _ (Chat (ContactConnection PendingContactConnection {pccConnId}) items _)) = (":" <> T.pack (show pccConnId), toCIPreview items) toCIPreview :: [CChatItem c] -> Text toCIPreview ((CChatItem _ ChatItem {meta}) : _) = itemText meta toCIPreview _ = "" From 9de28c46a0fd53564de589bd6bcc04f5d6749360 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 23 Apr 2022 18:23:29 +0100 Subject: [PATCH 14/95] core: update test (#561) --- tests/ChatTests.hs | 107 ++++++++++++++++++++++----------------------- 1 file changed, 53 insertions(+), 54 deletions(-) diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 6bdd2e86de..2711fcf3f6 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -112,30 +112,30 @@ testAddContact = bob <# "alice_1> hello" bob #> "@alice_1 hi" alice <# "bob_1> hi" - alice #$$> ("/_get chats", [("@bob_1", "hi"), ("@bob", "hi")]) - bob #$$> ("/_get chats", [("@alice_1", "hi"), ("@alice", "hi")]) + alice @@@ [("@bob_1", "hi"), ("@bob", "hi")] + bob @@@ [("@alice_1", "hi"), ("@alice", "hi")] -- test deleting contact alice ##> "/d bob_1" alice <## "bob_1: contact is deleted" alice ##> "@bob_1 hey" alice <## "no contact bob_1" - alice #$$> ("/_get chats", [("@bob", "hi")]) - bob #$$> ("/_get chats", [("@alice_1", "hi"), ("@alice", "hi")]) + alice @@@ [("@bob", "hi")] + bob @@@ [("@alice_1", "hi"), ("@alice", "hi")] where chatsEmpty alice bob = do - alice #$$> ("/_get chats", [("@bob", "")]) + alice @@@ [("@bob", "")] alice #$> ("/_get chat @2 count=100", chat, []) - bob #$$> ("/_get chats", [("@alice", "")]) + bob @@@ [("@alice", "")] bob #$> ("/_get chat @2 count=100", chat, []) chatsOneMessage alice bob = do - alice #$$> ("/_get chats", [("@bob", "hello 🙂")]) + alice @@@ [("@bob", "hello 🙂")] alice #$> ("/_get chat @2 count=100", chat, [(1, "hello 🙂")]) - bob #$$> ("/_get chats", [("@alice", "hello 🙂")]) + bob @@@ [("@alice", "hello 🙂")] bob #$> ("/_get chat @2 count=100", chat, [(0, "hello 🙂")]) chatsManyMessages alice bob = do - alice #$$> ("/_get chats", [("@bob", "hi")]) + alice @@@ [("@bob", "hi")] alice #$> ("/_get chat @2 count=100", chat, [(1, "hello 🙂"), (0, "hi")]) - bob #$$> ("/_get chats", [("@alice", "hi")]) + bob @@@ [("@alice", "hi")] bob #$> ("/_get chat @2 count=100", chat, [(0, "hello 🙂"), (1, "hi")]) -- pagination alice #$> ("/_get chat @2 after=1 count=100", chat, [(0, "hi")]) @@ -244,7 +244,7 @@ testDirectMessageDelete = alice #$> ("/_delete item @2 1 internal", id, "message deleted") alice #$> ("/_delete item @2 2 internal", id, "message deleted") - alice #$$> ("/_get chats", [("@bob", "")]) + alice @@@ [("@bob", "")] alice #$> ("/_get chat @2 count=100", chat, []) alice #$> ("/_update item @2 1 text updating deleted message", id, "cannot update this item") @@ -253,7 +253,7 @@ testDirectMessageDelete = bob #$> ("/_update item @2 2 text hey alice", id, "message updated") alice <# "bob> [edited] hey alice" - alice #$$> ("/_get chats", [("@bob", "hey alice")]) + alice @@@ [("@bob", "hey alice")] alice #$> ("/_get chat @2 count=100", chat, [(0, "hey alice")]) -- msg id 3 @@ -269,9 +269,9 @@ testDirectMessageDelete = alice #$> ("/_delete item @2 2 broadcast", id, "cannot delete this item") alice #$> ("/_delete item @2 2 internal", id, "message deleted") - alice #$$> ("/_get chats", [("@bob", "this item is deleted (broadcast)")]) + alice @@@ [("@bob", "this item is deleted (broadcast)")] alice #$> ("/_get chat @2 count=100", chat, [(0, "this item is deleted (broadcast)")]) - bob #$$> ("/_get chats", [("@alice", "hey alice")]) + bob @@@ [("@alice", "hey alice")] bob #$> ("/_get chat @2 count=100", chat', [((0, "this item is deleted (broadcast)"), Nothing), ((1, "hey alice"), (Just (0, "hello 🙂")))]) testGroup :: IO () @@ -363,13 +363,13 @@ testGroup = bob <##> cath where getReadChats alice bob cath = do - alice #$$> ("/_get chats", [("#team", "hey team"), ("@cath", ""), ("@bob", "")]) + alice @@@ [("#team", "hey team"), ("@cath", ""), ("@bob", "")] alice #$> ("/_get chat #1 count=100", chat, [(1, "hello"), (0, "hi there"), (0, "hey team")]) alice #$> ("/_get chat #1 after=1 count=100", chat, [(0, "hi there"), (0, "hey team")]) alice #$> ("/_get chat #1 before=3 count=100", chat, [(1, "hello"), (0, "hi there")]) - bob #$$> ("/_get chats", [("@cath", "hey"), ("#team", "hey team"), ("@alice", "")]) + bob @@@ [("@cath", "hey"), ("#team", "hey team"), ("@alice", "")] bob #$> ("/_get chat #1 count=100", chat, [(0, "hello"), (1, "hi there"), (0, "hey team")]) - cath #$$> ("/_get chats", [("@bob", "hey"), ("#team", "hey team"), ("@alice", "")]) + cath @@@ [("@bob", "hey"), ("#team", "hey team"), ("@alice", "")] cath #$> ("/_get chat #1 count=100", chat, [(0, "hello"), (0, "hi there"), (1, "hey team")]) alice #$> ("/_read chat #1 from=1 to=100", id, "ok") bob #$> ("/_read chat #1 from=1 to=100", id, "ok") @@ -1380,9 +1380,9 @@ testSendImageWithTextAndQuote = dest <- B.readFile "./tests/tmp/test.jpg" dest `shouldBe` src alice #$> ("/_get chat @2 count=100", chat'', [((0, "hi alice"), Nothing, Nothing), ((1, "hey bob"), Just (0, "hi alice"), Just "./tests/fixtures/test.jpg")]) - alice #$$> ("/_get chats", [("@bob", "hey bob")]) + alice @@@ [("@bob", "hey bob")] bob #$> ("/_get chat @2 count=100", chat'', [((1, "hi alice"), Nothing, Nothing), ((0, "hey bob"), Just (1, "hi alice"), Just "./tests/tmp/test.jpg")]) - bob #$$> ("/_get chats", [("@alice", "hey bob")]) + bob @@@ [("@alice", "hey bob")] testGroupSendImage :: IO () testGroupSendImage = @@ -1482,11 +1482,11 @@ testGroupSendImageWithTextAndQuote = dest2 <- B.readFile "./tests/tmp/test_1.jpg" dest2 `shouldBe` src alice #$> ("/_get chat #1 count=100", chat'', [((0, "hi team"), Nothing, Nothing), ((1, "hey bob"), Just (0, "hi team"), Just "./tests/fixtures/test.jpg")]) - alice #$$> ("/_get chats", [("#team", "hey bob"), ("@bob", ""), ("@cath", "")]) + alice @@@ [("#team", "hey bob"), ("@bob", ""), ("@cath", "")] bob #$> ("/_get chat #1 count=100", chat'', [((1, "hi team"), Nothing, Nothing), ((0, "hey bob"), Just (1, "hi team"), Just "./tests/tmp/test.jpg")]) - bob #$$> ("/_get chats", [("#team", "hey bob"), ("@alice", ""), ("@cath", "")]) + bob @@@ [("#team", "hey bob"), ("@alice", ""), ("@cath", "")] cath #$> ("/_get chat #1 count=100", chat'', [((0, "hi team"), Nothing, Nothing), ((0, "hey bob"), Just (0, "hi team"), Just "./tests/tmp/test_1.jpg")]) - cath #$$> ("/_get chats", [("#team", "hey bob"), ("@alice", ""), ("@bob", "")]) + cath @@@ [("#team", "hey bob"), ("@alice", ""), ("@bob", "")] testUserContactLink :: IO () testUserContactLink = testChat3 aliceProfile bobProfile cathProfile $ @@ -1495,24 +1495,24 @@ testUserContactLink = testChat3 aliceProfile bobProfile cathProfile $ cLink <- getContactLink alice True bob ##> ("/c " <> cLink) alice <#? bob - alice #$$> ("/_get chats", [("<@bob", "")]) + alice @@@ [("<@bob", "")] alice ##> "/ac bob" alice <## "bob (Bob): accepting contact request..." concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") - alice #$$> ("/_get chats", [("@bob", "")]) + alice @@@ [("@bob", "")] alice <##> bob cath ##> ("/c " <> cLink) alice <#? cath - alice #$$> ("/_get chats", [("<@cath", ""), ("@bob", "hey")]) + alice @@@ [("<@cath", ""), ("@bob", "hey")] alice ##> "/ac cath" alice <## "cath (Catherine): accepting contact request..." concurrently_ (cath <## "alice (Alice): contact is connected") (alice <## "cath (Catherine): contact is connected") - alice #$$> ("/_get chats", [("@cath", ""), ("@bob", "hey")]) + alice @@@ [("@cath", ""), ("@bob", "hey")] alice <##> cath testUserContactLinkAutoAccept :: IO () @@ -1524,13 +1524,13 @@ testUserContactLinkAutoAccept = bob ##> ("/c " <> cLink) alice <#? bob - alice #$$> ("/_get chats", [("<@bob", "")]) + alice @@@ [("<@bob", "")] alice ##> "/ac bob" alice <## "bob (Bob): accepting contact request..." concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") - alice #$$> ("/_get chats", [("@bob", "")]) + alice @@@ [("@bob", "")] alice <##> bob alice ##> "/auto_accept on" @@ -1542,7 +1542,7 @@ testUserContactLinkAutoAccept = concurrently_ (cath <## "alice (Alice): contact is connected") (alice <## "cath (Catherine): contact is connected") - alice #$$> ("/_get chats", [("@cath", ""), ("@bob", "hey")]) + alice @@@ [("@cath", ""), ("@bob", "hey")] alice <##> cath alice ##> "/auto_accept off" @@ -1550,13 +1550,13 @@ testUserContactLinkAutoAccept = dan ##> ("/c " <> cLink) alice <#? dan - alice #$$> ("/_get chats", [("<@dan", ""), ("@cath", "hey"), ("@bob", "hey")]) + alice @@@ [("<@dan", ""), ("@cath", "hey"), ("@bob", "hey")] alice ##> "/ac dan" alice <## "dan (Daniel): accepting contact request..." concurrently_ (dan <## "alice (Alice): contact is connected") (alice <## "dan (Daniel): contact is connected") - alice #$$> ("/_get chats", [("@dan", ""), ("@cath", "hey"), ("@bob", "hey")]) + alice @@@ [("@dan", ""), ("@cath", "hey"), ("@bob", "hey")] alice <##> dan testDeduplicateContactRequests :: IO () @@ -1567,13 +1567,13 @@ testDeduplicateContactRequests = testChat3 aliceProfile bobProfile cathProfile $ bob ##> ("/c " <> cLink) alice <#? bob - alice #$$> ("/_get chats", [("<@bob", "")]) + alice @@@ [("<@bob", "")] bob ##> ("/c " <> cLink) alice <#? bob bob ##> ("/c " <> cLink) alice <#? bob - alice #$$> ("/_get chats", [("<@bob", "")]) + alice @@@ [("<@bob", "")] alice ##> "/ac bob" alice <## "bob (Bob): accepting contact request..." @@ -1583,12 +1583,12 @@ testDeduplicateContactRequests = testChat3 aliceProfile bobProfile cathProfile $ bob ##> ("/c " <> cLink) bob <## "alice (Alice): contact already exists" - alice #$$> ("/_get chats", [("@bob", "")]) - bob #$$> ("/_get chats", [("@alice", "")]) + alice @@@ [("@bob", "")] + bob @@@ [("@alice", "")] alice <##> bob - alice #$$> ("/_get chats", [("@bob", "hey")]) - bob #$$> ("/_get chats", [("@alice", "hey")]) + alice @@@ [("@bob", "hey")] + bob @@@ [("@alice", "hey")] bob ##> ("/c " <> cLink) bob <## "alice (Alice): contact already exists" @@ -1599,13 +1599,13 @@ testDeduplicateContactRequests = testChat3 aliceProfile bobProfile cathProfile $ cath ##> ("/c " <> cLink) alice <#? cath - alice #$$> ("/_get chats", [("<@cath", ""), ("@bob", "hey")]) + alice @@@ [("<@cath", ""), ("@bob", "hey")] alice ##> "/ac cath" alice <## "cath (Catherine): accepting contact request..." concurrently_ (cath <## "alice (Alice): contact is connected") (alice <## "cath (Catherine): contact is connected") - alice #$$> ("/_get chats", [("@cath", ""), ("@bob", "hey")]) + alice @@@ [("@cath", ""), ("@bob", "hey")] alice <##> cath testDeduplicateContactRequestsProfileChange :: IO () @@ -1616,7 +1616,7 @@ testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile bob ##> ("/c " <> cLink) alice <#? bob - alice #$$> ("/_get chats", [("<@bob", "")]) + alice @@@ [("<@bob", "")] bob ##> "/p bob" bob <## "user full name removed (your contacts are notified)" @@ -1625,19 +1625,19 @@ testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile alice <## "bob wants to connect to you!" alice <## "to accept: /ac bob" alice <## "to reject: /rc bob (the sender will NOT be notified)" - alice #$$> ("/_get chats", [("<@bob", "")]) + alice @@@ [("<@bob", "")] bob ##> "/p bob Bob Ross" bob <## "user full name changed to Bob Ross (your contacts are notified)" bob ##> ("/c " <> cLink) alice <#? bob - alice #$$> ("/_get chats", [("<@bob", "")]) + alice @@@ [("<@bob", "")] bob ##> "/p robert Robert" bob <## "user profile is changed to robert (Robert) (your contacts are notified)" bob ##> ("/c " <> cLink) alice <#? bob - alice #$$> ("/_get chats", [("<@robert", "")]) + alice @@@ [("<@robert", "")] alice ##> "/ac bob" alice <## "no contact request from bob" @@ -1649,12 +1649,12 @@ testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile bob ##> ("/c " <> cLink) bob <## "alice (Alice): contact already exists" - alice #$$> ("/_get chats", [("@robert", "")]) - bob #$$> ("/_get chats", [("@alice", "")]) + alice @@@ [("@robert", "")] + bob @@@ [("@alice", "")] alice <##> bob - alice #$$> ("/_get chats", [("@robert", "hey")]) - bob #$$> ("/_get chats", [("@alice", "hey")]) + alice @@@ [("@robert", "hey")] + bob @@@ [("@alice", "hey")] bob ##> ("/c " <> cLink) bob <## "alice (Alice): contact already exists" @@ -1665,13 +1665,13 @@ testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile cath ##> ("/c " <> cLink) alice <#? cath - alice #$$> ("/_get chats", [("<@cath", ""), ("@robert", "hey")]) + alice @@@ [("<@cath", ""), ("@robert", "hey")] alice ##> "/ac cath" alice <## "cath (Catherine): accepting contact request..." concurrently_ (cath <## "alice (Alice): contact is connected") (alice <## "cath (Catherine): contact is connected") - alice #$$> ("/_get chats", [("@cath", ""), ("@robert", "hey")]) + alice @@@ [("@cath", ""), ("@robert", "hey")] alice <##> cath testRejectContactAndDeleteUserContact :: IO () @@ -1871,12 +1871,11 @@ chatF = map (\(a, _, c) -> (a, c)) . chat'' chat'' :: String -> [((Int, String), Maybe (Int, String), Maybe String)] chat'' = read -(#$$>) :: TestCC -> (String, [(String, String)]) -> Expectation -cc #$$> (cmd, res) = do - cc ##> cmd +(@@@) :: TestCC -> [(String, String)] -> Expectation +cc @@@ chats = do + cc ##> "/_get chats" line <- getTermLine cc - let chats = read line - chats `shouldMatchList` res + read line `shouldMatchList` chats send :: TestCC -> String -> IO () send TestCC {chatController = cc} cmd = atomically $ writeTBQueue (inputQ cc) cmd From 48ba6472b69fef04feda9717bbbc8eff3b5a6738 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 24 Apr 2022 09:05:54 +0100 Subject: [PATCH 15/95] core: add updatedAt to UserContactRequest and to PendingContactConnection, use it to sort the list of chats, tests (#563) --- src/Simplex/Chat.hs | 2 +- src/Simplex/Chat/Store.hs | 32 ++++++++++++++++---------------- src/Simplex/Chat/Types.hs | 6 ++++-- src/Simplex/Chat/View.hs | 12 ++++++------ tests/ChatTests.hs | 30 ++++++++++++++++++++++++------ 5 files changed, 51 insertions(+), 31 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index f7abde0677..fefff17157 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1899,7 +1899,7 @@ chatCommandP = <|> ("/user" <|> "/u") $> ShowActiveUser <|> "/_start" $> StartChat <|> "/_files_folder " *> (SetFilesFolder <$> filePath) - <|> "/_get chats" *> (APIGetChats <$> (" connections" $> True <|> pure False)) + <|> "/_get chats" *> (APIGetChats <$> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False)) <|> "/_get chat " *> (APIGetChat <$> chatTypeP <*> A.decimal <* A.space <*> chatPaginationP) <|> "/_get items count=" *> (APIGetChatItems <$> A.decimal) <|> "/_send " *> (APISendMessage <$> chatTypeP <*> A.decimal <*> optional filePathTagged <*> optional quotedItemIdTagged <* A.space <*> msgContentP) diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 6d6e767308..0041312313 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -312,7 +312,7 @@ createConnReqConnection st userId acId cReqHash xContactId = do |] (userId, acId, pccConnStatus, ConnContact, createdAt, createdAt, cReqHash, xContactId) pccConnId <- insertedRowId db - pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, createdAt} + pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, createdAt, updatedAt = createdAt} getConnReqContactXContactId :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnReqUriHash -> m (Maybe Contact, Maybe XContactId) getConnReqContactXContactId st userId cReqHash = do @@ -361,7 +361,7 @@ createDirectConnection st userId acId pccConnStatus = |] (userId, acId, pccConnStatus, ConnContact, createdAt, createdAt) pccConnId <- insertedRowId db - pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, createdAt, viaContactUri = False} + pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = False, createdAt, updatedAt = createdAt} createContactConnection_ :: DB.Connection -> UserId -> ConnId -> Maybe Int64 -> Int -> UTCTime -> IO Connection createContactConnection_ db userId = createConnection_ db userId ConnContact Nothing @@ -718,7 +718,7 @@ createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {display [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, - c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, cr.created_at, cr.xcontact_id + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, cr.xcontact_id, cr.created_at, cr.updated_at FROM contact_requests cr JOIN connections c USING (user_contact_link_id) JOIN contact_profiles p USING (contact_profile_id) @@ -777,7 +777,7 @@ getContactRequest_ db userId contactRequestId = [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, - c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, cr.created_at, cr.xcontact_id + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, cr.xcontact_id, cr.created_at, cr.updated_at FROM contact_requests cr JOIN connections c USING (user_contact_link_id) JOIN contact_profiles p USING (contact_profile_id) @@ -786,12 +786,12 @@ getContactRequest_ db userId contactRequestId = |] (userId, contactRequestId) -type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, Maybe ImageData, UTCTime, Maybe XContactId) +type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, Maybe ImageData, Maybe XContactId, UTCTime, UTCTime) toContactRequest :: ContactRequestRow -> UserContactRequest -toContactRequest (contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName, image, createdAt, xContactId) = do +toContactRequest (contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName, image, xContactId, createdAt, updatedAt) = do let profile = Profile {displayName, fullName, image} - in UserContactRequest {contactRequestId, agentInvitationId, userContactLinkId, agentContactConnId, localDisplayName, profileId, profile, createdAt, xContactId} + in UserContactRequest {contactRequestId, agentInvitationId, userContactLinkId, agentContactConnId, localDisplayName, profileId, profile, xContactId, createdAt, updatedAt} getContactRequestIdByName :: StoreMonad m => SQLiteStore -> UserId -> ContactName -> m Int64 getContactRequestIdByName st userId cName = @@ -2526,8 +2526,8 @@ getChatPreviews st user withPCC = ts (AChat _ Chat {chatInfo}) = case chatInfo of DirectChat Contact {createdAt} -> createdAt GroupChat GroupInfo {createdAt} -> createdAt - ContactRequest UserContactRequest {createdAt} -> createdAt - ContactConnection PendingContactConnection {createdAt} -> createdAt + ContactRequest UserContactRequest {updatedAt} -> updatedAt + ContactConnection PendingContactConnection {updatedAt} -> updatedAt chatItemTs :: CChatItem d -> UTCTime chatItemTs (CChatItem _ ChatItem {meta = CIMeta {itemTs}}) = itemTs @@ -2671,7 +2671,7 @@ getContactRequestChatPreviews_ db User {userId} = [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, - c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, cr.created_at, cr.xcontact_id + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, cr.xcontact_id, cr.created_at, cr.updated_at FROM contact_requests cr JOIN connections c USING (user_contact_link_id) JOIN contact_profiles p USING (contact_profile_id) @@ -2692,13 +2692,13 @@ getContactConnectionChatPreviews_ db User {userId} _ = <$> DB.query db [sql| - SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, created_at + SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, created_at, updated_at FROM connections WHERE user_id = ? AND conn_type = ? AND contact_id IS NULL AND conn_level = 0 AND via_contact IS NULL |] (userId, ConnContact) where - toContactConnectionChatPreview :: (Int64, ConnId, ConnStatus, Maybe ByteString, UTCTime) -> AChat + toContactConnectionChatPreview :: (Int64, ConnId, ConnStatus, Maybe ByteString, UTCTime, UTCTime) -> AChat toContactConnectionChatPreview connRow = let conn = toPendingContactConnection connRow stats = ChatStats {unreadCount = 0, minUnreadItemId = 0} @@ -2712,7 +2712,7 @@ deletePendingContactConnection st userId connId = DB.query db [sql| - SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, created_at + SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, created_at, updated_at FROM connections WHERE user_id = ? AND conn_type = ? AND contact_id IS NULL AND conn_level = 0 AND via_contact IS NULL |] @@ -2720,9 +2720,9 @@ deletePendingContactConnection st userId connId = liftIO $ DB.execute db "DELETE FROM connections WHERE connection_id = ?" (Only connId) pure conn -toPendingContactConnection :: (Int64, ConnId, ConnStatus, Maybe ByteString, UTCTime) -> PendingContactConnection -toPendingContactConnection (pccConnId, acId, pccConnStatus, connReqHash, createdAt) = - PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = isJust connReqHash, createdAt} +toPendingContactConnection :: (Int64, ConnId, ConnStatus, Maybe ByteString, UTCTime, UTCTime) -> PendingContactConnection +toPendingContactConnection (pccConnId, acId, pccConnStatus, connReqHash, createdAt, updatedAt) = + PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = isJust connReqHash, createdAt, updatedAt} getDirectChat :: StoreMonad m => SQLiteStore -> User -> Int64 -> ChatPagination -> m (Chat 'CTDirect) getDirectChat st user contactId pagination = diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 65f2fa4565..9d8626e92d 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -99,6 +99,7 @@ data UserContactRequest = UserContactRequest profileId :: Int64, profile :: Profile, createdAt :: UTCTime, + updatedAt :: UTCTime, xContactId :: Maybe XContactId } deriving (Eq, Show, Generic) @@ -670,7 +671,8 @@ data PendingContactConnection = PendingContactConnection pccAgentConnId :: AgentConnId, pccConnStatus :: ConnStatus, viaContactUri :: Bool, - createdAt :: UTCTime + createdAt :: UTCTime, + updatedAt :: UTCTime } deriving (Eq, Show, Generic) @@ -691,7 +693,7 @@ data ConnStatus ConnReady | -- | connection deleted ConnDeleted - deriving (Eq, Show) + deriving (Eq, Show, Read) instance FromField ConnStatus where fromField = fromTextField_ textDecode diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 2c5a6463a8..7d4ed007d7 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -142,7 +142,7 @@ responseToView testView = \case CRUserContactLinkSubscribed -> ["Your address is active! To show: " <> highlight' "/sa"] CRUserContactLinkSubError e -> ["user address error: " <> sShow e, "to delete your address: " <> highlight' "/da"] CRNewContactConnection _ -> [] - CRContactConnectionDeleted _ -> [] + CRContactConnectionDeleted PendingContactConnection {pccConnId} -> ["connection :" <> sShow pccConnId <> " deleted"] CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] CRMessageError prefix err -> [plain prefix <> ": " <> plain err] CRChatError e -> viewChatError e @@ -150,11 +150,11 @@ responseToView testView = \case testViewChats :: [AChat] -> [StyledString] testViewChats chats = [sShow $ map toChatView chats] where - toChatView :: AChat -> (Text, Text) - toChatView (AChat _ (Chat (DirectChat Contact {localDisplayName}) items _)) = ("@" <> localDisplayName, toCIPreview items) - toChatView (AChat _ (Chat (GroupChat GroupInfo {localDisplayName}) items _)) = ("#" <> localDisplayName, toCIPreview items) - toChatView (AChat _ (Chat (ContactRequest UserContactRequest {localDisplayName}) items _)) = ("<@" <> localDisplayName, toCIPreview items) - toChatView (AChat _ (Chat (ContactConnection PendingContactConnection {pccConnId}) items _)) = (":" <> T.pack (show pccConnId), toCIPreview items) + toChatView :: AChat -> (Text, Text, Maybe ConnStatus) + toChatView (AChat _ (Chat (DirectChat Contact {localDisplayName, activeConn}) items _)) = ("@" <> localDisplayName, toCIPreview items, Just $ connStatus activeConn) + toChatView (AChat _ (Chat (GroupChat GroupInfo {localDisplayName}) items _)) = ("#" <> localDisplayName, toCIPreview items, Nothing) + toChatView (AChat _ (Chat (ContactRequest UserContactRequest {localDisplayName}) items _)) = ("<@" <> localDisplayName, toCIPreview items, Nothing) + toChatView (AChat _ (Chat (ContactConnection PendingContactConnection {pccConnId, pccConnStatus}) items _)) = (":" <> T.pack (show pccConnId), toCIPreview items, Just $ pccConnStatus) toCIPreview :: [CChatItem c] -> Text toCIPreview ((CChatItem _ ChatItem {meta}) : _) = itemText meta toCIPreview _ = "" diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 2711fcf3f6..42f69540d3 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -13,7 +13,7 @@ import qualified Data.ByteString as B import Data.Char (isDigit) import qualified Data.Text as T import Simplex.Chat.Controller (ChatController (..)) -import Simplex.Chat.Types (ImageData (..), Profile (..), User (..)) +import Simplex.Chat.Types (ConnStatus (..), ImageData (..), Profile (..), User (..)) import Simplex.Chat.Util (unlessM) import System.Directory (copyFile, doesFileExist) import Test.Hspec @@ -1568,12 +1568,14 @@ testDeduplicateContactRequests = testChat3 aliceProfile bobProfile cathProfile $ bob ##> ("/c " <> cLink) alice <#? bob alice @@@ [("<@bob", "")] + bob @@@! [(":1", "", Just ConnJoined)] bob ##> ("/c " <> cLink) alice <#? bob bob ##> ("/c " <> cLink) alice <#? bob alice @@@ [("<@bob", "")] + bob @@@! [(":3", "", Just ConnJoined), (":2", "", Just ConnJoined), (":1", "", Just ConnJoined)] alice ##> "/ac bob" alice <## "bob (Bob): accepting contact request..." @@ -1584,7 +1586,11 @@ testDeduplicateContactRequests = testChat3 aliceProfile bobProfile cathProfile $ bob ##> ("/c " <> cLink) bob <## "alice (Alice): contact already exists" alice @@@ [("@bob", "")] - bob @@@ [("@alice", "")] + bob @@@ [("@alice", ""), (":2", ""), (":1", "")] + bob ##> "/_delete :1" + bob <## "connection :1 deleted" + bob ##> "/_delete :2" + bob <## "connection :2 deleted" alice <##> bob alice @@@ [("@bob", "hey")] @@ -1650,7 +1656,13 @@ testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile bob ##> ("/c " <> cLink) bob <## "alice (Alice): contact already exists" alice @@@ [("@robert", "")] - bob @@@ [("@alice", "")] + bob @@@ [("@alice", ""), (":3", ""), (":2", ""), (":1", "")] + bob ##> "/_delete :1" + bob <## "connection :1 deleted" + bob ##> "/_delete :2" + bob <## "connection :2 deleted" + bob ##> "/_delete :3" + bob <## "connection :3 deleted" alice <##> bob alice @@@ [("@robert", "hey")] @@ -1872,10 +1884,16 @@ chat'' :: String -> [((Int, String), Maybe (Int, String), Maybe String)] chat'' = read (@@@) :: TestCC -> [(String, String)] -> Expectation -cc @@@ chats = do - cc ##> "/_get chats" +(@@@) = getChats . map $ \(ldn, msg, _) -> (ldn, msg) + +(@@@!) :: TestCC -> [(String, String, Maybe ConnStatus)] -> Expectation +(@@@!) = getChats id + +getChats :: (Eq a, Show a) => ([(String, String, Maybe ConnStatus)] -> [a]) -> TestCC -> [a] -> Expectation +getChats f cc res = do + cc ##> "/_get chats pcc=on" line <- getTermLine cc - read line `shouldMatchList` chats + f (read line) `shouldMatchList` res send :: TestCC -> String -> IO () send TestCC {chatController = cc} cmd = atomically $ writeTBQueue (inputQ cc) cmd From e87660974e5dc96fdcf9e410b36a9c8602216158 Mon Sep 17 00:00:00 2001 From: IanRDavies Date: Mon, 25 Apr 2022 07:54:07 +0100 Subject: [PATCH 16/95] Paste link to connect (#551) * initial implementation of textbox * paste to connect box implemented (and tested) in android * first pass at pastebox in iOS * clean up iOS implementation * put paste link page in for groups in android * initial inclusion in iOS UI * refactor naming * lint kotlin * fix typo * ios: update "connect via link" ui, refactor connecting via link to use the one function * android: update paste link UI * add russian translations * update translations Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> * update translations Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> --- .../simplex/app/views/chat/ComposeView.kt | 11 -- .../app/views/chat/item/CIImageView.kt | 2 +- .../app/views/chatlist/ChatHelpView.kt | 2 +- .../simplex/app/views/helpers/SimpleButton.kt | 22 ++- .../simplex/app/views/helpers/TextEditor.kt | 58 +++++++ .../app/views/newchat/AddContactView.kt | 2 +- .../simplex/app/views/newchat/NewChatSheet.kt | 149 ++++++++++-------- .../app/views/newchat/PasteToConnect.kt | 126 +++++++++++++++ ...ectContactView.kt => ScanToConnectView.kt} | 2 +- .../app/views/usersettings/SMPServers.kt | 63 ++------ .../app/src/main/res/values-ru/strings.xml | 15 +- .../app/src/main/res/values/strings.xml | 16 +- apps/ios/Shared/Model/SimpleXAPI.swift | 16 +- .../Shared/Views/ChatList/ChatListView.swift | 15 +- .../Shared/Views/NewChat/AddContactView.swift | 1 - .../Shared/Views/NewChat/NewChatButton.swift | 67 ++++---- .../Views/NewChat/PasteToConnectView.swift | 78 +++++++++ ...tactView.swift => ScanToConnectView.swift} | 19 +-- .../en.xcloc/Localized Contents/en.xliff | 52 ++++-- .../ru.xcloc/Localized Contents/ru.xliff | 52 ++++-- apps/ios/SimpleX.xcodeproj/project.pbxproj | 12 +- apps/ios/ru.lproj/Localizable.strings | 28 +++- 22 files changed, 561 insertions(+), 247 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/helpers/TextEditor.kt create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/newchat/PasteToConnect.kt rename apps/android/app/src/main/java/chat/simplex/app/views/newchat/{ConnectContactView.kt => ScanToConnectView.kt} (98%) create mode 100644 apps/ios/Shared/Views/NewChat/PasteToConnectView.swift rename apps/ios/Shared/Views/NewChat/{ConnectContactView.swift => ScanToConnectView.swift} (71%) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index acc57003de..6734b30891 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -1,24 +1,13 @@ package chat.simplex.app.views.chat import ComposeImageView -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.AddCircleOutline -import androidx.compose.foundation.layout.Column import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.views.helpers.ComposeLinkView -import chat.simplex.app.views.helpers.generalGetString // TODO ComposeState diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt index d0196336c7..cc6aae2ab1 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt @@ -7,9 +7,9 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import chat.simplex.app.R import chat.simplex.app.model.CIFile import chat.simplex.app.views.helpers.* -import chat.simplex.app.R @Composable fun CIImageView( diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt index f2a96f2418..aa03a7d0cf 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt @@ -1,7 +1,7 @@ package chat.simplex.app.views.chatlist import android.content.res.Configuration -import androidx.compose.foundation.* +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.material.icons.Icons diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt index c507d8ae2e..23906df768 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt @@ -3,6 +3,7 @@ package chat.simplex.app.ui.theme import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Share @@ -18,14 +19,19 @@ import androidx.compose.ui.unit.dp fun SimpleButton(text: String, icon: ImageVector, color: Color = MaterialTheme.colors.primary, click: () -> Unit) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable { click() } - ) { - Icon(icon, text, tint = color, - modifier = Modifier.padding(horizontal = 10.dp) - ) - Text(text, style = MaterialTheme.typography.caption, color = color) + Surface(shape = RoundedCornerShape(20.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { click() } + .padding(8.dp) + ) { + Icon( + icon, text, tint = color, + modifier = Modifier.padding(end = 8.dp) + ) + Text(text, style = MaterialTheme.typography.caption, color = color) + } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/TextEditor.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/TextEditor.kt new file mode 100644 index 0000000000..c02bdfb6a4 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/TextEditor.kt @@ -0,0 +1,58 @@ +package chat.simplex.app.views.helpers + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.ui.theme.HighOrLowlight + +@Composable +fun TextEditor(modifier: Modifier, text: MutableState) { + BasicTextField( + value = text.value, + onValueChange = { text.value = it }, + textStyle = TextStyle( + fontFamily = FontFamily.Monospace, fontSize = 14.sp, + color = MaterialTheme.colors.onBackground + ), + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + autoCorrect = false + ), + modifier = modifier, + cursorBrush = SolidColor(HighOrLowlight), + decorationBox = { innerTextField -> + Surface( + shape = RoundedCornerShape(10.dp), + border = BorderStroke(1.dp, MaterialTheme.colors.secondary) + ) { + Row( + Modifier.background(MaterialTheme.colors.background), + verticalAlignment = Alignment.Top + ) { + Box( + Modifier + .weight(1f) + .padding(vertical = 5.dp, horizontal = 7.dp) + ) { + innerTextField() + } + } + } + } + ) +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt index 4e668e3580..c23c6521ca 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt @@ -63,7 +63,7 @@ fun AddContactLayout(connReq: String, share: () -> Unit) { lineHeight = 22.sp, modifier = Modifier .padding(horizontal = 16.dp) - .padding(bottom = if(screenHeight > 600.dp) 16.dp else 8.dp) + .padding(bottom = if (screenHeight > 600.dp) 16.dp else 8.dp) ) SimpleButton(generalGetString(R.string.share_invitation_link), icon = Icons.Outlined.Share, click = share) Spacer(Modifier.height(10.dp)) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt index 2d3ff4924a..b89c91fcff 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt @@ -3,6 +3,7 @@ package chat.simplex.app.views.newchat import android.Manifest import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.* @@ -39,52 +40,65 @@ fun NewChatSheet(chatModel: ChatModel, newChatCtrl: ScaffoldController) { }, scanCode = { newChatCtrl.collapse() - ModalManager.shared.showCustomModal { close -> ConnectContactView(chatModel, close) } + ModalManager.shared.showCustomModal { close -> ScanToConnectView(chatModel, close) } cameraPermissionState.launchPermissionRequest() + }, + pasteLink = { + newChatCtrl.collapse() + ModalManager.shared.showCustomModal { close -> PasteToConnectView(chatModel, close) } } ) } @Composable -fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit) { - Row( - Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 48.dp), - horizontalArrangement = Arrangement.SpaceEvenly +fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit, pasteLink: () -> Unit) { + Column( + horizontalAlignment = Alignment.CenterHorizontally ) { - Box( + Text( + generalGetString(R.string.add_contact_to_start_new_chat), + modifier = Modifier.padding(horizontal = 8.dp).padding(top = 32.dp) + ) + Row( Modifier - .weight(1F) - .fillMaxWidth()) { - ActionButton( - generalGetString(R.string.add_contact), - generalGetString(R.string.create_QR_code_or_link__bracketed__multiline), - Icons.Outlined.PersonAdd, - click = addContact - ) - } - Box( - Modifier - .weight(1F) - .fillMaxWidth()) { - ActionButton( - generalGetString(R.string.scan_QR_code), - generalGetString(R.string.in_person_or_in_video_call__bracketed), - Icons.Outlined.QrCode, - click = scanCode - ) - } - Box( - Modifier - .weight(1F) - .fillMaxWidth()) { - ActionButton( - generalGetString(R.string.create_group), - generalGetString(R.string.coming_soon__bracketed), - Icons.Outlined.GroupAdd, - disabled = true - ) + .fillMaxWidth() + .padding(horizontal = 8.dp) + .padding(top = 24.dp, bottom = 40.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + Box( + Modifier + .weight(1F) + .fillMaxWidth()) { + ActionButton( + generalGetString(R.string.create_one_time_link), + generalGetString(R.string.to_share_with_your_contact), + Icons.Outlined.PersonAdd, + click = addContact + ) + } + Box( + Modifier + .weight(1F) + .fillMaxWidth()) { + ActionButton( + generalGetString(R.string.paste_received_link), + generalGetString(R.string.paste_received_link_from_clipboard), + Icons.Outlined.Link, + click = pasteLink + ) + } + Box( + Modifier + .weight(1F) + .fillMaxWidth()) { + ActionButton( + generalGetString(R.string.scan_QR_code), + generalGetString(R.string.in_person_or_in_video_call__bracketed), + Icons.Outlined.QrCode, + click = scanCode + ) + } } } } @@ -92,33 +106,35 @@ fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit) { @Composable fun ActionButton(text: String?, comment: String?, icon: ImageVector, disabled: Boolean = false, click: () -> Unit = {}) { - Column( - Modifier - .clickable(onClick = click) - .padding(horizontal = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - val tint = if (disabled) HighOrLowlight else MaterialTheme.colors.primary - Icon(icon, text, - tint = tint, - modifier = Modifier - .size(40.dp) - .padding(bottom = 8.dp)) - if (text != null) { - Text( - text, - textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, - color = tint, - modifier = Modifier.padding(bottom = 4.dp) - ) - } - if (comment != null) { - Text( - comment, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.body2 - ) + Surface(shape = RoundedCornerShape(18.dp)) { + Column( + Modifier + .clickable(onClick = click) + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val tint = if (disabled) HighOrLowlight else MaterialTheme.colors.primary + Icon(icon, text, + tint = tint, + modifier = Modifier + .size(40.dp) + .padding(bottom = 8.dp)) + if (text != null) { + Text( + text, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + color = tint, + modifier = Modifier.padding(bottom = 4.dp) + ) + } + if (comment != null) { + Text( + comment, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.body2 + ) + } } } } @@ -129,7 +145,8 @@ fun PreviewNewChatSheet() { SimpleXTheme { NewChatSheetLayout( addContact = {}, - scanCode = {} + scanCode = {}, + pasteLink = {} ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/PasteToConnect.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/PasteToConnect.kt new file mode 100644 index 0000000000..78775ef1e5 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/PasteToConnect.kt @@ -0,0 +1,126 @@ +package chat.simplex.app.views.newchat + +import android.content.ClipboardManager +import android.content.res.Configuration +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.* +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat.getSystemService +import chat.simplex.app.R +import chat.simplex.app.model.ChatModel +import chat.simplex.app.ui.theme.SimpleButton +import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.helpers.* + +@Composable +fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) { + val connectionLink = remember { mutableStateOf("")} + val context = LocalContext.current + val clipboard = getSystemService(context, ClipboardManager::class.java) + BackHandler(onBack = close) + PasteToConnectLayout( + connectionLink = connectionLink, + pasteFromClipboard = { + connectionLink.value = clipboard?.primaryClip?.getItemAt(0)?.coerceToText(context) as String + }, + connectViaLink = { connReqUri -> + try { + val uri = Uri.parse(connReqUri) + withUriAction(uri) { action -> + connectViaUri(chatModel, action, uri) + } + } catch (e: RuntimeException) { + AlertManager.shared.showAlertMsg( + title = generalGetString(R.string.invalid_connection_link), + text = generalGetString(R.string.this_string_is_not_a_connection_link) + ) + } + close() + }, + close = close + ) +} + +@Composable +fun PasteToConnectLayout( + connectionLink: MutableState, + pasteFromClipboard: () -> Unit, + connectViaLink: (String) -> Unit, + close: () -> Unit +) { + ModalView(close) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween, + ) { + Text( + generalGetString(R.string.connect_via_link), + style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), + modifier = Modifier.padding(bottom = 16.dp) + ) + Text(generalGetString(R.string.paste_connection_link_below_to_connect)) + Text(generalGetString(R.string.profile_will_be_sent_to_contact_sending_link)) + + Box(Modifier.padding(top = 16.dp, bottom = 6.dp)) { + TextEditor(Modifier.height(180.dp), text = connectionLink) + } + + Row( + Modifier.fillMaxWidth().padding(bottom = 6.dp), + horizontalArrangement = Arrangement.Start, + ) { + if (connectionLink.value == "") { + SimpleButton(text = "Paste", icon = Icons.Outlined.ContentPaste) { + pasteFromClipboard() + } + } else { + SimpleButton(text = "Clear", icon = Icons.Outlined.Clear) { + connectionLink.value = "" + } + } + Spacer(Modifier.weight(1f).fillMaxWidth()) + SimpleButton(text = "Connect", icon = Icons.Outlined.Link) { + connectViaLink(connectionLink.value) + } + } + + Text(annotatedStringResource(R.string.you_can_also_connect_by_clicking_the_link)) + } + } +} + + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + name = "Dark Mode" +) +@Composable +fun PreviewPasteToConnectTextbox() { + SimpleXTheme { + PasteToConnectLayout( + connectionLink = remember { mutableStateOf("") }, + pasteFromClipboard = {}, + connectViaLink = { link -> + try { + println(link) + // withApi { chatModel.controller.apiConnect(link) } + } catch (e: Exception) { + e.printStackTrace() + } + }, + close = {} + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ConnectContactView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ScanToConnectView.kt similarity index 98% rename from apps/android/app/src/main/java/chat/simplex/app/views/newchat/ConnectContactView.kt rename to apps/android/app/src/main/java/chat/simplex/app/views/newchat/ScanToConnectView.kt index f65b000987..11c67a409a 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ConnectContactView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ScanToConnectView.kt @@ -19,7 +19,7 @@ import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.helpers.* @Composable -fun ConnectContactView(chatModel: ChatModel, close: () -> Unit) { +fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) { BackHandler(onBack = close) ConnectContactLayout( qrCodeScanner = { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt index 6668018792..899e2191f6 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt @@ -3,8 +3,6 @@ package chat.simplex.app.views.usersettings import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.* import androidx.compose.material.icons.Icons @@ -12,11 +10,9 @@ import androidx.compose.material.icons.outlined.OpenInNew import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -32,7 +28,7 @@ fun SMPServersView(chatModel: ChatModel) { if (userSMPServers != null) { var isUserSMPServers by remember { mutableStateOf(userSMPServers.isNotEmpty()) } var editSMPServers by remember { mutableStateOf(!isUserSMPServers) } - var userSMPServersStr by remember { mutableStateOf(if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "") } + var userSMPServersStr = remember { mutableStateOf(if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "") } fun saveSMPServers(smpServers: List) { withApi { val r = chatModel.controller.setUserSMPServers(smpServers = smpServers) @@ -66,23 +62,22 @@ fun SMPServersView(chatModel: ChatModel) { onConfirm = { saveSMPServers(listOf()) isUserSMPServers = false - userSMPServersStr = "" + userSMPServersStr.value = "" } ) } else { isUserSMPServers = false - userSMPServersStr = "" + userSMPServersStr.value = "" } } } }, - editUserSMPServersStr = { userSMPServersStr = it }, cancelEdit = { val userSMPServers = chatModel.userSMPServers.value if (userSMPServers != null) { isUserSMPServers = userSMPServers.isNotEmpty() editSMPServers = !isUserSMPServers - userSMPServersStr = if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "" + userSMPServersStr.value = if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "" } }, saveSMPServers = { saveSMPServers(it) }, @@ -95,9 +90,8 @@ fun SMPServersView(chatModel: ChatModel) { fun SMPServersLayout( isUserSMPServers: Boolean, editSMPServers: Boolean, - userSMPServersStr: String, + userSMPServersStr: MutableState, isUserSMPServersOnOff: (Boolean) -> Unit, - editUserSMPServersStr: (String) -> Unit, cancelEdit: () -> Unit, saveSMPServers: (List) -> Unit, editOn: () -> Unit, @@ -131,39 +125,7 @@ fun SMPServersLayout( } else { Text(generalGetString(R.string.enter_one_SMP_server_per_line)) if (editSMPServers) { - BasicTextField( - value = userSMPServersStr, - onValueChange = editUserSMPServersStr, - textStyle = TextStyle( - fontFamily = FontFamily.Monospace, fontSize = 14.sp, - color = MaterialTheme.colors.onBackground - ), - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - autoCorrect = false - ), - modifier = Modifier.height(160.dp), - cursorBrush = SolidColor(HighOrLowlight), - decorationBox = { innerTextField -> - Surface( - shape = RoundedCornerShape(10.dp), - border = BorderStroke(1.dp, MaterialTheme.colors.secondary) - ) { - Row( - Modifier.background(MaterialTheme.colors.background), - verticalAlignment = Alignment.Top - ) { - Box( - Modifier - .weight(1f) - .padding(vertical = 5.dp, horizontal = 7.dp) - ) { - innerTextField() - } - } - } - } - ) + TextEditor(Modifier.height(160.dp), text = userSMPServersStr) Row( Modifier.fillMaxWidth(), @@ -183,7 +145,7 @@ fun SMPServersLayout( generalGetString(R.string.save_servers_button), color = MaterialTheme.colors.primary, modifier = Modifier.clickable(onClick = { - val servers = userSMPServersStr.split("\n") + val servers = userSMPServersStr.value.split("\n") saveSMPServers(servers) }) ) @@ -205,7 +167,7 @@ fun SMPServersLayout( Modifier.verticalScroll(rememberScrollState()) ) { Text( - userSMPServersStr, + userSMPServersStr.value, Modifier .padding(vertical = 5.dp, horizontal = 7.dp), style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp), @@ -256,9 +218,8 @@ fun PreviewSMPServersLayoutDefaultServers() { SMPServersLayout( isUserSMPServers = false, editSMPServers = true, - userSMPServersStr = "", + userSMPServersStr = remember { mutableStateOf("") }, isUserSMPServersOnOff = {}, - editUserSMPServersStr = {}, cancelEdit = {}, saveSMPServers = {}, editOn = {}, @@ -273,9 +234,8 @@ fun PreviewSMPServersLayoutUserServersEditOn() { SMPServersLayout( isUserSMPServers = true, editSMPServers = true, - userSMPServersStr = "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im", + userSMPServersStr = remember { mutableStateOf("smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im") }, isUserSMPServersOnOff = {}, - editUserSMPServersStr = {}, cancelEdit = {}, saveSMPServers = {}, editOn = {}, @@ -290,9 +250,8 @@ fun PreviewSMPServersLayoutUserServersEditOff() { SMPServersLayout( isUserSMPServers = true, editSMPServers = false, - userSMPServersStr = "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im", + userSMPServersStr = remember { mutableStateOf("smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im") }, isUserSMPServersOnOff = {}, - editUserSMPServersStr = {}, cancelEdit = {}, saveSMPServers = {}, editOn = {}, diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index f4a36949b7..d5031bfd54 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -98,13 +98,14 @@ нет описания Добавить контакт - Сканировать QR код - (создать QR код или ссылку) + Создать одноразовую ссылку + Вставить полученную ссылку + Сканировать QR код + (чтобы отправить вашему контакту) (при встрече или через видеозвонок) - Создать группу - (скоро!) + (вставить ссылку из буфера обмена) Разрешение не получено! @@ -166,6 +167,8 @@ Ваш профиль будет отправлен\nвашему контакту Если вы не можете встретиться лично, вы можете сосканировать QR код во время видеозвонка, или ваш контакт может отправить вам ссылку. Поделиться ссылкой + Чтобы соединиться, вставьте в это поле ссылку, полученную от вашего контакта.. + Настройки @@ -227,5 +230,9 @@ a + b цвет секрет + Соединиться через ссылку + Эта строка не является ссылкой-приглашением! + Вы также можете соединиться, открыв ссылку там, где вы её получили. Если ссылка откроется в браузере, нажмите кнопку Open in mobile app. + Добавьте контакт, чтобы начать разговор: diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index cbc0bd7183..4307d757f5 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -98,13 +98,15 @@ Ok no details Add contact - Scan QR code - (create QR code\nor link) + Add contact to start a new chat: + Create link / QR code + Connect via received link + Scan QR code + (to share with your contact) (in person or in video call) - Create Group - (coming soon!) + (paste link from clipboard) Permission Denied! @@ -166,6 +168,12 @@ Your chat profile will be sent\nto your contact If you cannot meet in person, you can scan QR code in the video call, or your contact can share an invitation link. Share invitation link + Paste the link you received into the box below to connect with your contact. + + + Connect via link + This string is not a connection link! + You can also connect by clicking the link. If it opens in the browser, click Open in mobile app button. Your settings diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 85438dd285..01c9c68b05 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -496,42 +496,42 @@ func apiAddContact() throws -> String { throw r } -func apiConnect(connReq: String) async throws -> Bool { +func apiConnect(connReq: String) async throws -> ConnReqType? { let r = await chatSendCmd(.connect(connReq: connReq)) let am = AlertManager.shared switch r { - case .sentConfirmation: return true - case .sentInvitation: return true + case .sentConfirmation: return .invitation + case .sentInvitation: return .contact case let .contactAlreadyExists(contact): am.showAlertMsg( title: "Contact already exists", message: "You are already connected to \(contact.displayName) via this link." ) - return false + return nil case .chatCmdError(.error(.invalidConnReq)): am.showAlertMsg( title: "Invalid connection link", message: "Please check that you used the correct link or ask your contact to send you another one." ) - return false + return nil case .chatCmdError(.errorAgent(.BROKER(.TIMEOUT))): am.showAlertMsg( title: "Connection timeout", message: "Please check your network connection and try again." ) - return false + return nil case .chatCmdError(.errorAgent(.BROKER(.NETWORK))): am.showAlertMsg( title: "Connection error", message: "Please check your network connection and try again." ) - return false + return nil case .chatCmdError(.errorAgent(.SMP(.AUTH))): am.showAlertMsg( title: "Connection error (AUTH)", message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." ) - return false + return nil default: throw r } } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index e220882494..20b513315a 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -78,23 +78,12 @@ struct ChatListView: View { let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") let title: LocalizedStringKey if case .contact = action { title = "Connect via contact link?" } - else { title = "Connect via invitation link?" } + else { title = "Connect via one-time link?" } return Alert( title: Text(title), message: Text("Your profile will be sent to the contact that you received this link from"), primaryButton: .default(Text("Connect")) { - DispatchQueue.main.async { - Task { - do { - let ok = try await apiConnect(connReq: link) - if ok { connectionReqSentAlert(action) } - } catch { - let err = error.localizedDescription - AlertManager.shared.showAlertMsg(title: "Connection error", message: "Error: \(err)") - logger.debug("ChatListView.connectViaUrlAlert: apiConnect error: \(err)") - } - } - } + connectViaLink(link) }, secondaryButton: .cancel() ) diff --git a/apps/ios/Shared/Views/NewChat/AddContactView.swift b/apps/ios/Shared/Views/NewChat/AddContactView.swift index 9101e893f8..ecd3815fc4 100644 --- a/apps/ios/Shared/Views/NewChat/AddContactView.swift +++ b/apps/ios/Shared/Views/NewChat/AddContactView.swift @@ -11,7 +11,6 @@ import CoreImage.CIFilterBuiltins struct AddContactView: View { var connReqInvitation: String - var body: some View { VStack { Text("Add contact") diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift index 83f7fd85fe..ad0a133a56 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatButton.swift @@ -12,26 +12,27 @@ struct NewChatButton: View { @State private var showAddChat = false @State private var addContact = false @State private var connReqInvitation: String = "" - @State private var connectContact = false - @State private var createGroup = false + @State private var scanToConnect = false + @State private var pasteToConnect = false var body: some View { Button { showAddChat = true } label: { Image(systemName: "person.crop.circle.badge.plus") } - .confirmationDialog("Start new chat", isPresented: $showAddChat, titleVisibility: .visible) { - Button("Add contact") { addContactAction() } - Button("Scan QR code") { connectContact = true } - Button("Create group") { createGroup = true } - .disabled(true) + .confirmationDialog("Add contact to start a new chat", isPresented: $showAddChat, titleVisibility: .visible) { + Button("Create link / QR code") { addContactAction() } + Button("Paste received link") { pasteToConnect = true } + Button("Scan QR code") { scanToConnect = true } } .sheet(isPresented: $addContact, content: { AddContactView(connReqInvitation: connReqInvitation) }) - .sheet(isPresented: $connectContact, content: { - connectContactSheet() + .sheet(isPresented: $scanToConnect, content: { + ScanToConnectView(openedSheet: $scanToConnect) + }) + .sheet(isPresented: $pasteToConnect, content: { + PasteToConnectView(openedSheet: $pasteToConnect) }) - .sheet(isPresented: $createGroup, content: { CreateGroupView() }) } func addContactAction() { @@ -45,28 +46,6 @@ struct NewChatButton: View { logger.error("NewChatButton.addContactAction apiAddContact error: \(error.localizedDescription)") } } - - func addContactSheet() -> some View { - AddContactView(connReqInvitation: connReqInvitation) - } - - func connectContactSheet() -> some View { - ConnectContactView(completed: { err in - connectContact = false - DispatchQueue.global().async { - switch (err) { - case let .success(ok): - if ok { connectionReqSentAlert(.invitation) } - case let .failure(error): - connectionErrorAlert(error) - } - } - }) - } - - func connectionErrorAlert(_ error: Error) { - AlertManager.shared.showAlertMsg(title: "Connection error", message: "Error: \(error.localizedDescription)") - } } enum ConnReqType: Equatable { @@ -74,6 +53,30 @@ enum ConnReqType: Equatable { case invitation } +func connectViaLink(_ connectionLink: String, _ openedSheet: Binding? = nil) { + Task { + do { + let res = try await apiConnect(connReq: connectionLink) + DispatchQueue.main.async { + openedSheet?.wrappedValue = false + if let connReqType = res { + connectionReqSentAlert(connReqType) + } + } + } catch { + logger.error("connectViaLink apiConnect error: \(responseError(error))") + DispatchQueue.main.async { + openedSheet?.wrappedValue = false + connectionErrorAlert(error) + } + } + } +} + +func connectionErrorAlert(_ error: Error) { + AlertManager.shared.showAlertMsg(title: "Connection error", message: "Error: \(error.localizedDescription)") +} + func connectionReqSentAlert(_ type: ConnReqType) { AlertManager.shared.showAlertMsg( title: "Connection request sent!", diff --git a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift new file mode 100644 index 0000000000..e3f9f354ab --- /dev/null +++ b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift @@ -0,0 +1,78 @@ +// +// PasteToConnectView.swift +// SimpleX (iOS) +// +// Created by Ian Davies on 22/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct PasteToConnectView: View { + @Binding var openedSheet: Bool + @State private var connectionLink: String = "" + + var body: some View { + VStack(alignment: .leading) { + Text("Connect via link") + .font(.title) + .padding([.bottom]) + .frame(maxWidth: .infinity, alignment: .center) + Text("Paste the link you received into the box below to connect with your contact.") + .multilineTextAlignment(.leading) + Text("Your profile will be sent to the contact that you received this link from") + .multilineTextAlignment(.leading) + .padding(.bottom) + TextEditor(text: $connectionLink) + .onSubmit(connect) + .font(.body) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .allowsTightening(false) + .frame(height: 180) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true) + ) + + HStack(spacing: 20) { + if connectionLink == "" { + Button { + connectionLink = UIPasteboard.general.string ?? "" + } label: { + Label("Paste", systemImage: "doc.on.clipboard") + } + } else { + Button { + connectionLink = "" + } label: { + Label("Clear", systemImage: "multiply") + } + + } + Spacer() + Button(action: connect, label: { + Label("Connect", systemImage: "link") + }) + .disabled(connectionLink == "" || connectionLink.trimmingCharacters(in: .whitespaces).firstIndex(of: " ") != nil) + } + .frame(height: 48) + .padding(.bottom) + + Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button") + .multilineTextAlignment(.leading) + } + .padding() + } + + private func connect() { + connectViaLink(connectionLink.trimmingCharacters(in: .whitespaces), $openedSheet) + } +} + +struct PasteToConnectView_Previews: PreviewProvider { + static var previews: some View { + @State var openedSheet: Bool = true + return PasteToConnectView(openedSheet: $openedSheet) + } +} diff --git a/apps/ios/Shared/Views/NewChat/ConnectContactView.swift b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift similarity index 71% rename from apps/ios/Shared/Views/NewChat/ConnectContactView.swift rename to apps/ios/Shared/Views/NewChat/ScanToConnectView.swift index 9944dc21fb..aed53a2d5b 100644 --- a/apps/ios/Shared/Views/NewChat/ConnectContactView.swift +++ b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift @@ -9,8 +9,8 @@ import SwiftUI import CodeScanner -struct ConnectContactView: View { - var completed: ((Result) -> Void) +struct ScanToConnectView: View { + @Binding var openedSheet: Bool var body: some View { VStack { @@ -37,24 +37,17 @@ struct ConnectContactView: View { func processQRCode(_ resp: Result) { switch resp { case let .success(r): - Task { - do { - let ok = try await apiConnect(connReq: r.string) - completed(.success(ok)) - } catch { - logger.error("ConnectContactView.processQRCode apiConnect error: \(error.localizedDescription)") - completed(.failure(error)) - } - } + Task { connectViaLink(r.string, $openedSheet) } case let .failure(e): logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)") - completed(.failure(e)) + openedSheet = false } } } struct ConnectContactView_Previews: PreviewProvider { static var previews: some View { - return ConnectContactView(completed: {_ in }) + @State var openedSheet: Bool = true + return ScanToConnectView(openedSheet: $openedSheet) } } diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index fc792ef930..2744ebdc2c 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -115,6 +115,11 @@ Add contact No comment provided by engineer. + + Add contact to start a new chat + Add contact to start a new chat + No comment provided by engineer. + All your contacts will remain connected All your contacts will remain connected @@ -160,6 +165,11 @@ Choose from library No comment provided by engineer. + + Clear + Clear + No comment provided by engineer. + Configure SMP servers Configure SMP servers @@ -180,9 +190,14 @@ Connect via contact link? No comment provided by engineer. - - Connect via invitation link? - Connect via invitation link? + + Connect via link + Connect via link + No comment provided by engineer. + + + Connect via one-time link? + Connect via one-time link? No comment provided by engineer. @@ -260,9 +275,9 @@ Create address No comment provided by engineer. - - Create group - Create group + + Create link / QR code + Create link / QR code No comment provided by engineer. @@ -470,6 +485,21 @@ Open Settings No comment provided by engineer. + + Paste + Paste + No comment provided by engineer. + + + Paste received link + Paste received link + No comment provided by engineer. + + + Paste the link you received into the box below to connect with your contact. + Paste the link you received into the box below to connect with your contact. + No comment provided by engineer. + Please check that you used the correct link or ask your contact to send you another one. Please check that you used the correct link or ask your contact to send you another one. @@ -567,11 +597,6 @@ to scan from the app to scan from the app No comment provided by engineer. - - Start new chat - Start new chat - No comment provided by engineer. - Take picture Take picture @@ -676,6 +701,11 @@ To connect, please ask your contact to create another connection link and check You are connected to the server used to receive messages from this contact. No comment provided by engineer. + + You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button + You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button + No comment provided by engineer. + You can now send messages to %@ You can now send messages to %@ diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index e90ec57cfb..643507ec7d 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -115,6 +115,11 @@ Добавить контакт No comment provided by engineer. + + Add contact to start a new chat + Добавьте контакт, чтобы начать разговор + No comment provided by engineer. + All your contacts will remain connected Все контакты, которые соединились через этот адрес, сохранятся. @@ -160,6 +165,11 @@ Выбрать из библиотеки No comment provided by engineer. + + Clear + Очистить + No comment provided by engineer. + Configure SMP servers Настройка SMP серверов @@ -180,9 +190,14 @@ Соединиться через ссылку-контакт? No comment provided by engineer. - - Connect via invitation link? - Соединиться через ссылку-приглашение? + + Connect via link + Соединиться через ссылку + No comment provided by engineer. + + + Connect via one-time link? + Соединиться через одноразовую ссылку? No comment provided by engineer. @@ -260,9 +275,9 @@ Создать адрес No comment provided by engineer. - - Create group - Создать группу + + Create link / QR code + Создать ссылку / QR код No comment provided by engineer. @@ -470,6 +485,21 @@ Открыть Настройки No comment provided by engineer. + + Paste + Вставить + No comment provided by engineer. + + + Paste received link + Вставить полученную ссылку + No comment provided by engineer. + + + Paste the link you received into the box below to connect with your contact. + Чтобы соединиться, вставьте в это поле ссылку, полученную от вашего контакта. + No comment provided by engineer. + Please check that you used the correct link or ask your contact to send you another one. Пожалуйста, проверьте, что вы использовали правильную ссылку или попросите, чтобы ваш контакт отправил вам другую ссылку. @@ -566,11 +596,6 @@ to scan from the app Покажите QR код вашему контакту для сканирования в приложении No comment provided by engineer. - - Start new chat - Начать новый разговор - No comment provided by engineer. - Take picture Сделать фото @@ -675,6 +700,11 @@ To connect, please ask your contact to create another connection link and check Установлено соединение с сервером, через который вы получаете сообщения от этого контакта. No comment provided by engineer. + + You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button + Вы также можете соединиться, открыв ссылку там, где вы её получили. Если ссылка откроется в браузере, нажмите кнопку **Open in mobile app**. + No comment provided by engineer. + You can now send messages to %@ Вы теперь можете отправлять сообщения %@ diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index d8ee08ef96..953e7abd55 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */; }; 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; }; 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; }; 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; }; @@ -59,7 +60,7 @@ 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */; }; 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */; }; 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; - 5CCD403727A5F9A200368C90 /* ConnectContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ConnectContactView.swift */; }; + 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */; }; 5CCD403A27A5F9BE00368C90 /* CreateGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */; }; 5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407127ADB1D0007B033A /* Emoji.swift */; }; 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; }; @@ -84,6 +85,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteToConnectView.swift; sourceTree = ""; }; 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = ""; }; 3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = ""; }; 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreviewView.swift; sourceTree = ""; }; @@ -139,7 +141,7 @@ 5CC2C0FB2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 5CC2C0FE2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = "ru.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = ""; }; - 5CCD403627A5F9A200368C90 /* ConnectContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectContactView.swift; sourceTree = ""; }; + 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToConnectView.swift; sourceTree = ""; }; 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateGroupView.swift; sourceTree = ""; }; 5CE4407127ADB1D0007B033A /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = ""; }; 5CE4407827ADB701007B033A /* EmojiItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiItemView.swift; sourceTree = ""; }; @@ -311,7 +313,8 @@ children = ( 5C6AD81227A834E300348BD7 /* NewChatButton.swift */, 5CCD403327A5F6DF00368C90 /* AddContactView.swift */, - 5CCD403627A5F9A200368C90 /* ConnectContactView.swift */, + 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */, + 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */, 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */, 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */, ); @@ -491,6 +494,7 @@ 640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */, 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */, 5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */, + 3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */, 5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */, 5C9FD96B27A56D4D0075386C /* JSON.swift in Sources */, 5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */, @@ -511,7 +515,7 @@ 5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */, 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */, 5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */, - 5CCD403727A5F9A200368C90 /* ConnectContactView.swift in Sources */, + 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */, 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */, 5CCD403A27A5F9BE00368C90 /* CreateGroupView.swift in Sources */, 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */, diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 422d019a1f..1752f798ea 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -85,6 +85,9 @@ /* No comment provided by engineer. */ "Add contact" = "Добавить контакт"; +/* No comment provided by engineer. */ +"Add contact to start a new chat" = "Добавьте контакт, чтобы начать разговор"; + /* No comment provided by engineer. */ "All your contacts will remain connected" = "Все контакты, которые соединились через этот адрес, сохранятся."; @@ -115,6 +118,9 @@ /* No comment provided by engineer. */ "Choose from library" = "Выбрать из библиотеки"; +/* No comment provided by engineer. */ +"Clear" = "Очистить"; + /* No comment provided by engineer. */ "colored" = "цвет"; @@ -134,7 +140,10 @@ "Connect via contact link?" = "Соединиться через ссылку-контакт?"; /* No comment provided by engineer. */ -"Connect via invitation link?" = "Соединиться через ссылку-приглашение?"; +"Connect via link" = "Соединиться через ссылку"; + +/* No comment provided by engineer. */ +"Connect via one-time link?" = "Соединиться через одноразовую ссылку?"; /* No comment provided by engineer. */ "Connecting server…" = "Устанавливается соединение с сервером…"; @@ -182,7 +191,7 @@ "Create address" = "Создать адрес"; /* No comment provided by engineer. */ -"Create group" = "Создать группу"; +"Create link / QR code" = "Создать ссылку / QR код"; /* No comment provided by engineer. */ "Create profile" = "Создать профиль"; @@ -313,6 +322,15 @@ /* No comment provided by engineer. */ "Open Settings" = "Открыть Настройки"; +/* No comment provided by engineer. */ +"Paste" = "Вставить"; + +/* No comment provided by engineer. */ +"Paste received link" = "Вставить полученную ссылку"; + +/* No comment provided by engineer. */ +"Paste the link you received into the box below to connect with your contact." = "Чтобы соединиться, вставьте в это поле ссылку, полученную от вашего контакта."; + /* No comment provided by engineer. */ "Please check that you used the correct link or ask your contact to send you another one." = "Пожалуйста, проверьте, что вы использовали правильную ссылку или попросите, чтобы ваш контакт отправил вам другую ссылку."; @@ -373,9 +391,6 @@ /* No comment provided by engineer. */ "SMP servers" = "SMP серверы"; -/* No comment provided by engineer. */ -"Start new chat" = "Начать новый разговор"; - /* No comment provided by engineer. */ "strike" = "зачеркнуть"; @@ -445,6 +460,9 @@ /* No comment provided by engineer. */ "You are connected to the server used to receive messages from this contact." = "Установлено соединение с сервером, через который вы получаете сообщения от этого контакта."; +/* No comment provided by engineer. */ +"You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button" = "Вы также можете соединиться, открыв ссылку там, где вы её получили. Если ссылка откроется в браузере, нажмите кнопку **Open in mobile app**."; + /* notification body */ "You can now send messages to %@" = "Вы теперь можете отправлять сообщения %@"; From 0470f9cf3621c66a89c5c872c1662d2aecf2cfc2 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 25 Apr 2022 09:17:12 +0100 Subject: [PATCH 17/95] core: batch contact disconnections and re-subscriptions to one event per server (#564) * core: batch contact disconnections and re-subscriptions to one event per server * update simplexmq * update query to use temp table * remove old code --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 18 ++++++++++-------- src/Simplex/Chat/Controller.hs | 4 ++-- src/Simplex/Chat/Store.hs | 23 +++++++++++++++++++++++ src/Simplex/Chat/Types.hs | 8 ++++++++ src/Simplex/Chat/View.hs | 8 ++++++-- stack.yaml | 2 +- 8 files changed, 52 insertions(+), 15 deletions(-) diff --git a/cabal.project b/cabal.project index 5617a6ab4f..f8d20085ae 100644 --- a/cabal.project +++ b/cabal.project @@ -3,7 +3,7 @@ packages: . source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 633cd675b5cd9476dc9624512d552f3851fab34c + tag: c6dde772b459a2d8f392ad5455d6f8fd6f16e867 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index a9a3364278..ff3b87c194 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."633cd675b5cd9476dc9624512d552f3851fab34c" = "0kpljb1ys7ym2lzq7xm56z6hfzkkq6j6z2b7zh5z87jr8zyxfhvk"; + "https://github.com/simplex-chat/simplexmq.git"."c6dde772b459a2d8f392ad5455d6f8fd6f16e867" = "08crpg2jnpvj08cl372znbhf3n0frm8ldbl2bwva0f0jq5j364nk"; "https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp"; "https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; "https://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index fefff17157..a40773e9ff 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -836,7 +836,7 @@ subscribeUserConnections user@User {userId} = do contacts <- withStore (`getUserContacts` user) toView . CRContactSubSummary =<< pooledForConcurrentlyN n contacts (\ct -> ContactSubStatus ct <$> subscribeContact ce ct) subscribeContact ce ct = - (subscribe (contactConnId ct) >> when ce (toView $ CRContactSubscribed ct) $> Nothing) + (subscribe (contactConnId ct) $> Nothing) `catchError` (\e -> when ce (toView $ CRContactSubError ct e) $> Just e) subscribeGroups n ce = do groups <- withStore (`getUserGroups` user) @@ -898,6 +898,15 @@ subscribeUserConnections user@User {userId} = do processAgentMessage :: forall m. ChatMonad m => Maybe User -> ConnId -> ACommand 'Agent -> m () processAgentMessage Nothing _ _ = throwChatError CENoActiveUser +processAgentMessage (Just User {userId}) "" agentMessage = case agentMessage of + DOWN srv conns -> serverEvent srv conns CRContactsDisconnected "disconnected" + UP srv conns -> serverEvent srv conns CRContactsSubscribed "connected" + _ -> pure () + where + serverEvent srv@SMP.ProtocolServer {host, port} conns event str = do + cs <- withStore $ \st -> getConnectionsContacts st userId conns + toView $ event srv cs + showToast ("server " <> str) (safeDecodeUtf8 . strEncode $ SrvLoc host port) processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage = (withStore (\st -> getConnectionEntity st user agentConnId) >>= updateConnStatus) >>= \case RcvDirectMsgConnection conn contact_ -> @@ -1011,13 +1020,6 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage toView $ CRContactAnotherClient ct showToast (c <> "> ") "connected to another client" unsetActive $ ActiveC c - DOWN -> do - toView $ CRContactDisconnected ct - showToast (c <> "> ") "disconnected" - UP -> do - toView $ CRContactSubscribed ct - showToast (c <> "> ") "is active" - setActive $ ActiveC c -- TODO print errors MERR msgId err -> do chatItemId_ <- withStore $ \st -> getChatItemIdByAgentMsgId st connId msgId diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 9d08c72bd3..7adf8e2182 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -221,8 +221,8 @@ data ChatResponse | CRContactConnecting {contact :: Contact} | CRContactConnected {contact :: Contact} | CRContactAnotherClient {contact :: Contact} - | CRContactDisconnected {contact :: Contact} - | CRContactSubscribed {contact :: Contact} + | CRContactsDisconnected {server :: SMPServer, contactRefs :: [ContactRef]} + | CRContactsSubscribed {server :: SMPServer, contactRefs :: [ContactRef]} | CRContactSubError {contact :: Contact, chatError :: ChatError} | CRContactSubSummary {contactSubscriptions :: [ContactSubStatus]} | CRGroupInvitation {groupInfo :: GroupInfo} diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 0041312313..257fde2257 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -53,6 +53,7 @@ module Simplex.Chat.Store getPendingConnections, getContactConnections, getConnectionEntity, + getConnectionsContacts, getGroupAndMember, updateConnectionStatus, createNewGroup, @@ -1193,6 +1194,28 @@ getConnectionEntity st User {userId, userContactId} agentConnId = userContact_ [Only cReq] = Right UserContact {userContactLinkId, connReqContact = cReq} userContact_ _ = Left SEUserContactLinkNotFound +getConnectionsContacts :: MonadUnliftIO m => SQLiteStore -> UserId -> [ConnId] -> m [ContactRef] +getConnectionsContacts st userId agentConnIds = + liftIO . withTransaction st $ \db -> do + DB.execute_ db "DROP TABLE IF EXISTS temp.conn_ids" + DB.execute_ db "CREATE TABLE temp.conn_ids (conn_id BLOB)" + DB.executeMany db "INSERT INTO temp.conn_ids (conn_id) VALUES (?)" $ map Only agentConnIds + conns <- + map (uncurry ContactRef) + <$> DB.query + db + [sql| + SELECT ct.contact_id, ct.local_display_name + FROM contacts ct + JOIN connections c ON c.contact_id = ct.contact_id + WHERE ct.user_id = ? AND c.agent_conn_id IN (SELECT conn_id FROM temp.conn_ids) + AND c.conn_type = ? + AND (c.conn_status = ? OR c.conn_status = ?) + |] + (userId, ConnContact, ConnReady, ConnSndReady) + DB.execute_ db "DROP TABLE temp.conn_ids" + pure conns + getGroupAndMember :: StoreMonad m => SQLiteStore -> User -> Int64 -> m (GroupInfo, GroupMember) getGroupAndMember st User {userId, userContactId} groupMemberId = liftIOEither . withTransaction st $ \db -> diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 9d8626e92d..e8f064c2d5 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -84,6 +84,14 @@ contactConn = activeConn contactConnId :: Contact -> ConnId contactConnId Contact {activeConn} = aConnId activeConn +data ContactRef = ContactRef + { contactId :: Int64, + localDisplayName :: ContactName + } + deriving (Eq, Show, Generic) + +instance ToJSON ContactRef where toEncoding = J.genericToEncoding J.defaultOptions + data UserContact = UserContact { userContactLinkId :: Int64, connReqContact :: ConnReqContact diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 7d4ed007d7..703bcf6b59 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -111,8 +111,8 @@ responseToView testView = \case CRContactConnecting _ -> [] CRContactConnected ct -> [ttyFullContact ct <> ": contact is connected"] CRContactAnotherClient c -> [ttyContact' c <> ": contact is connected to another client"] - CRContactDisconnected c -> [ttyContact' c <> ": disconnected from server (messages will be queued)"] - CRContactSubscribed c -> [ttyContact' c <> ": connected to server"] + CRContactsDisconnected srv cs -> [plain $ "server disconnected " <> smpServer srv <> " (" <> contactList cs <> ")"] + CRContactsSubscribed srv cs -> [plain $ "server connected " <> smpServer srv <> " (" <> contactList cs <> ")"] CRContactSubError c e -> [ttyContact' c <> ": contact error " <> sShow e] CRContactSubSummary summary -> [sShow (length subscribed) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)" | not (null subscribed)] <> viewErrorsSummary errors " contact errors" @@ -174,6 +174,10 @@ responseToView testView = \case _ -> Nothing viewErrorsSummary :: [a] -> StyledString -> [StyledString] viewErrorsSummary summary s = [ttyError (T.pack . show $ length summary) <> s <> " (run with -c option to show each error)" | not (null summary)] + smpServer :: SMPServer -> String + smpServer SMP.ProtocolServer {host, port} = B.unpack . strEncode $ SrvLoc host port + contactList :: [ContactRef] -> String + contactList cs = T.unpack . T.intercalate ", " $ map (\ContactRef {localDisplayName = n} -> "@" <> n) cs viewChatItem :: MsgDirectionI d => ChatInfo c -> ChatItem c d -> [StyledString] viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} = case chat of diff --git a/stack.yaml b/stack.yaml index f5ac78d328..715e40f2cf 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 633cd675b5cd9476dc9624512d552f3851fab34c + commit: c6dde772b459a2d8f392ad5455d6f8fd6f16e867 # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 - github: simplex-chat/aeson commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7 From db4731f19b9e2685a1f6f75cf32cc9b22e6dcb39 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Mon, 25 Apr 2022 12:44:24 +0400 Subject: [PATCH 18/95] mobile: correctly resize images, refine image sending UI (#546) --- .../java/chat/simplex/app/model/SimpleXAPI.kt | 2 +- .../chat/simplex/app/views/chat/ChatView.kt | 34 +- .../app/views/chat/ComposeImageView.kt | 17 +- .../simplex/app/views/chat/ComposeView.kt | 62 ++- .../simplex/app/views/helpers/GetImageView.kt | 28 +- .../simplex/app/views/helpers/LinkPreviews.kt | 2 +- .../chat/simplex/app/views/helpers/Util.kt | 3 + .../app/views/usersettings/UserProfileView.kt | 2 +- .../app/src/main/res/values-ru/strings.xml | 3 +- .../app/src/main/res/values/strings.xml | 1 + apps/ios/Shared/FileUtils.swift | 3 + apps/ios/Shared/Model/SimpleXAPI.swift | 3 +- apps/ios/Shared/Views/Chat/ChatView.swift | 102 +---- .../Chat/ComposeMessage/ComposeView.swift | 379 +++++++++++++----- .../Chat/ComposeMessage/ContextItemView.swift | 39 +- .../Chat/ComposeMessage/SendMessageView.swift | 47 +-- .../Views/Helpers/ComposeImageView.swift | 35 +- .../Views/Helpers/ComposeLinkView.swift | 2 +- .../Shared/Views/Helpers/ImagePicker.swift | 25 +- apps/ios/Shared/Views/TerminalView.swift | 20 +- .../Views/UserSettings/UserProfile.swift | 2 +- 21 files changed, 485 insertions(+), 326 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index bc126724b1..6655a60a90 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -371,7 +371,7 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt val cItem = r.chatItem.chatItem chatModel.addChatItem(cInfo, cItem) val file = cItem.file - if (file != null && file.fileSize <= 236700) { // 394500 + if (file != null && file.fileSize <= MAX_IMAGE_SIZE) { withApi {receiveFile(file.fileId)} } if (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index 37716c8979..ed5b7c8f6b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -87,13 +87,16 @@ fun ChatView(chatModel: ChatModel) { val cInfo = chat.chatInfo val ei = editingItem.value if (ei != null) { - val updatedItem = chatModel.controller.apiUpdateChatItem( - type = cInfo.chatType, - id = cInfo.apiId, - itemId = ei.meta.itemId, - mc = MsgContent.MCText(msg) - ) - if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem) + val oldMsgContent = ei.content.msgContent + if (oldMsgContent != null) { + val updatedItem = chatModel.controller.apiUpdateChatItem( + type = cInfo.chatType, + id = cInfo.apiId, + itemId = ei.meta.itemId, + mc = updateMsgContent(oldMsgContent, msg) + ) + if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem) + } } else { var file: String? = null val imagePreviewData = imagePreview.value @@ -142,17 +145,26 @@ fun ChatView(chatModel: ChatModel) { } }, parseMarkdown = { text -> runBlocking { chatModel.controller.apiParseMarkdown(text) } }, - onImageChange = { bitmap -> imagePreview.value = resizeImageToDataSize(bitmap, maxDataSize = 12500) } + onImageChange = { bitmap -> imagePreview.value = resizeImageToStrSize(bitmap, maxDataSize = 14000) } ) } } +fun updateMsgContent(msgContent: MsgContent, text: String): MsgContent { + return when (msgContent) { + is MsgContent.MCText -> MsgContent.MCText(text) + is MsgContent.MCLink -> MsgContent.MCLink(text, preview = msgContent.preview) + is MsgContent.MCImage -> MsgContent.MCImage(text, image = msgContent.image) + is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = text, json = msgContent.json) + } +} + fun saveImage(context: Context, image: Bitmap): String { - val imageResized = base64ToBitmap(resizeImageToDataSize(image, 160000)) + val dataResized = resizeImageToDataSize(image, maxDataSize = MAX_IMAGE_SIZE) val fileToSave = "image_${System.currentTimeMillis()}.jpg" val file = File(getAppFilesDirectory(context) + "/" + fileToSave) val output = FileOutputStream(file) - imageResized.compress(Bitmap.CompressFormat.JPEG, 100, output) + dataResized.writeTo(output) output.flush() output.close() return fileToSave @@ -205,7 +217,7 @@ fun ChatLayout( topBar = { ChatInfoToolbar(chat, back, info) }, bottomBar = { ComposeView( - msg, quotedItem, editingItem, linkPreview, imagePreview, sendMessage, resetMessage, parseMarkdown, + msg, quotedItem, editingItem, linkPreview, chosenImage, imagePreview, sendMessage, resetMessage, parseMarkdown, showBottomSheet = { scope.launch { bottomSheetModalState.show() } } ) }, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt index 0e2b438de8..4b42cc0256 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt @@ -1,16 +1,22 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp +import chat.simplex.app.R import chat.simplex.app.views.chat.item.SentColorLight import chat.simplex.app.views.helpers.base64ToBitmap +import chat.simplex.app.views.helpers.generalGetString @Composable -fun ComposeImageView(image: String) { +fun ComposeImageView(image: String, cancelImage: () -> Unit) { Row( Modifier .fillMaxWidth() @@ -27,5 +33,14 @@ fun ComposeImageView(image: String) { .height(60.dp) .padding(end = 8.dp) ) + Spacer(Modifier.weight(1f)) + IconButton(onClick = cancelImage, modifier = Modifier.padding(0.dp)) { + Icon( + Icons.Outlined.Close, + contentDescription = generalGetString(R.string.icon_descr_cancel_image_preview), + tint = MaterialTheme.colors.primary, + modifier = Modifier.padding(10.dp) + ) + } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index 6734b30891..425837802c 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -1,22 +1,34 @@ package chat.simplex.app.views.chat import ComposeImageView +import android.graphics.Bitmap +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.icons.Icons +import androidx.compose.foundation.layout.Column +import androidx.compose.material.icons.filled.AttachFile import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.views.helpers.ComposeLinkView +import chat.simplex.app.views.helpers.generalGetString // TODO ComposeState - @Composable fun ComposeView( msg: MutableState, quotedItem: MutableState, editingItem: MutableState, linkPreview: MutableState, + chosenImage: MutableState, imagePreview: MutableState, sendMessage: (String) -> Unit, resetMessage: () -> Unit, @@ -33,10 +45,15 @@ fun ComposeView( linkPreview.value = null } + fun cancelImage() { + chosenImage.value = null + imagePreview.value = null + } + Column { val ip = imagePreview.value if (ip != null) { - ComposeImageView(ip) + ComposeImageView(ip, ::cancelImage) } else { val lp = linkPreview.value if (lp != null) ComposeLinkView(lp, ::cancelPreview) @@ -51,28 +68,29 @@ fun ComposeView( else -> {} } Row( - modifier = Modifier.padding(horizontal = 8.dp), -// // use this padding when attach button is uncommented -// modifier = Modifier.padding(start = 2.dp, end = 8.dp), - verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 4.dp, end = 8.dp), + verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(2.dp) ) { -// Icon( -// Icons.Outlined.AddCircleOutline, -// contentDescription = generalGetString(R.string.attach), -// tint = if (editingItem.value == null) MaterialTheme.colors.primary else Color.Gray, -// modifier = Modifier -// .size(40.dp) -// .padding(vertical = 4.dp) -// .clip(CircleShape) -// .clickable { -// if (editingItem.value == null) { -// showBottomSheet() -// } -// } -// ) - SendMsgView(msg, linkPreview, cancelledLinks, parseMarkdown, sendMessage, - editing = editingItem.value != null, sendEnabled = msg.value.isNotEmpty() || imagePreview.value != null) + Box(Modifier.padding(bottom = 12.dp)) { + Icon( + Icons.Filled.AttachFile, + contentDescription = generalGetString(R.string.attach), + tint = if (editingItem.value == null) MaterialTheme.colors.primary else Color.Gray, + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .clickable { + if (editingItem.value == null) { + showBottomSheet() + } + } + ) + } + SendMsgView( + msg, linkPreview, cancelledLinks, parseMarkdown, sendMessage, + editing = editingItem.value != null, sendEnabled = msg.value.isNotEmpty() || imagePreview.value != null + ) } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt index a5a0ef27e3..fa8971a071 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt @@ -49,24 +49,42 @@ fun cropToSquare(image: Bitmap): Bitmap { return Bitmap.createBitmap(image, xOffset, yOffset, side, side) } -fun resizeImageToDataSize(image: Bitmap, maxDataSize: Int): String { +fun resizeImageToStrSize(image: Bitmap, maxDataSize: Int): String { var img = image - var str = compressImage(img) + var str = compressImageStr(img) while (str.length > maxDataSize) { val ratio = sqrt(str.length.toDouble() / maxDataSize.toDouble()) val clippedRatio = min(ratio, 2.0) val width = (img.width.toDouble() / clippedRatio).toInt() val height = img.height * width / img.width img = Bitmap.createScaledBitmap(img, width, height, true) - str = compressImage(img) + str = compressImageStr(img) } return str } -private fun compressImage(bitmap: Bitmap): String { +private fun compressImageStr(bitmap: Bitmap): String { + return "data:image/jpg;base64," + Base64.encodeToString(compressImageData(bitmap).toByteArray(), Base64.NO_WRAP) +} + +fun resizeImageToDataSize(image: Bitmap, maxDataSize: Int): ByteArrayOutputStream { + var img = image + var stream = compressImageData(img) + while (stream.size() > maxDataSize) { + val ratio = sqrt(stream.size().toDouble() / maxDataSize.toDouble()) + val clippedRatio = min(ratio, 2.0) + val width = (img.width.toDouble() / clippedRatio).toInt() + val height = img.height * width / img.width + img = Bitmap.createScaledBitmap(img, width, height, true) + stream = compressImageData(img) + } + return stream +} + +private fun compressImageData(bitmap: Bitmap): ByteArrayOutputStream { val stream = ByteArrayOutputStream() bitmap.compress(Bitmap.CompressFormat.JPEG, 85, stream) - return "data:image/jpg;base64," + Base64.encodeToString(stream.toByteArray(), Base64.NO_WRAP) + return stream } fun base64ToBitmap(base64ImageString: String) : Bitmap { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt index ec13b5dd27..c078adc830 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt @@ -42,7 +42,7 @@ suspend fun getLinkPreview(url: String): LinkPreview? { if (imageUri != null) { try { val stream = java.net.URL(imageUri).openStream() - val image = resizeImageToDataSize(BitmapFactory.decodeStream(stream), maxDataSize = 14000) + val image = resizeImageToStrSize(BitmapFactory.decodeStream(stream), maxDataSize = 14000) // TODO add once supported in iOS // val description = ogTags.firstOrNull { // it.attr("property") == "og:description" diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt index be4e5d2d7c..15d6e1863b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt @@ -199,6 +199,9 @@ private fun spannableStringToAnnotatedString( } } +// maximum image file size to be auto-accepted +const val MAX_IMAGE_SIZE = 236700 + fun getFilesDirectory(context: Context): String { return context.filesDir.toString() } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt index 0a401c1460..8a663b36c9 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt @@ -79,7 +79,7 @@ fun UserProfileLayout( sheetContent = { GetImageBottomSheet( chosenImage, - onImageChange = { bitmap -> profileImage.value = resizeImageToDataSize(cropToSquare(bitmap), maxDataSize = 12500) }, + onImageChange = { bitmap -> profileImage.value = resizeImageToStrSize(cropToSquare(bitmap), maxDataSize = 12500) }, hideBottomSheet = { scope.launch { bottomSheetModalState.hide() } }) diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index d5031bfd54..313b9f39fa 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -78,6 +78,7 @@ Изображение + удалить превью изображения Удалить контакт? @@ -167,7 +168,7 @@ Ваш профиль будет отправлен\nвашему контакту Если вы не можете встретиться лично, вы можете сосканировать QR код во время видеозвонка, или ваш контакт может отправить вам ссылку. Поделиться ссылкой - Чтобы соединиться, вставьте в это поле ссылку, полученную от вашего контакта.. + Чтобы соединиться, вставьте в это поле ссылку, полученную от вашего контакта. diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 4307d757f5..1d840899fc 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -78,6 +78,7 @@ Image + cancel image preview Delete contact? diff --git a/apps/ios/Shared/FileUtils.swift b/apps/ios/Shared/FileUtils.swift index 2c36e33a2f..5739cc6d59 100644 --- a/apps/ios/Shared/FileUtils.swift +++ b/apps/ios/Shared/FileUtils.swift @@ -9,6 +9,9 @@ import Foundation import SwiftUI +// maximum image file size to be auto-accepted +let maxImageSize = 236700 + func getDocumentsDirectory() -> URL { return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 01c9c68b05..524fd047b0 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -743,8 +743,7 @@ func processReceivedMsg(_ res: ChatResponse) { let cItem = aChatItem.chatItem chatModel.addChatItem(cInfo, cItem) if let file = cItem.file, - file.fileSize <= 236700 { - // file.fileSize <= 394500 { + file.fileSize <= maxImageSize { Task { do { try await receiveFile(fileId: file.fileId) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 00068617f6..708f92512b 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -14,19 +14,12 @@ struct ChatView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.colorScheme) var colorScheme @ObservedObject var chat: Chat - @State var message: String = "" - @State var quotedItem: ChatItem? = nil - @State var editingItem: ChatItem? = nil - @State var linkPreview: LinkPreview? = nil + @State var composeState = ComposeState() @State var deletingItem: ChatItem? = nil - @State private var inProgress: Bool = false @FocusState private var keyboardVisible: Bool @State private var showChatInfo = false @State private var showDeleteMessage = false - @State private var chosenImage: UIImage? = nil - @State private var imagePreview: String? = nil - var body: some View { let cInfo = chat.chatInfo @@ -86,16 +79,9 @@ struct ChatView: View { Spacer(minLength: 0) ComposeView( - message: $message, - quotedItem: $quotedItem, - editingItem: $editingItem, - linkPreview: $linkPreview, - sendMessage: sendMessage, - resetMessage: { message = "" }, - inProgress: inProgress, - keyboardVisible: $keyboardVisible, - chosenImage: $chosenImage, - imagePreview: $imagePreview + chat: chat, + composeState: $composeState, + keyboardVisible: $keyboardVisible ) } .navigationTitle(cInfo.chatViewName) @@ -130,8 +116,7 @@ struct ChatView: View { if ci.isMsgContent() { Button { withAnimation { - editingItem = nil - quotedItem = ci + composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci)) } } label: { Label("Reply", systemImage: "arrowshape.turn.up.left") } Button { @@ -142,7 +127,9 @@ struct ChatView: View { showShareSheet(items: shareItems) } label: { Label("Share", systemImage: "square.and.arrow.up") } Button { - if case .image = ci.content.msgContent, let image = getStoredImage(ci.file) { + if case let .image(text, _) = ci.content.msgContent, + text == "", + let image = getStoredImage(ci.file) { UIPasteboard.general.image = image } else { UIPasteboard.general.string = ci.content.text @@ -151,9 +138,7 @@ struct ChatView: View { if ci.meta.editable { Button { withAnimation { - quotedItem = nil - editingItem = ci - message = ci.content.text + composeState = ComposeState(editingItem: ci) } } label: { Label("Edit", systemImage: "square.and.pencil") } } @@ -214,75 +199,6 @@ struct ChatView: View { } } } - - func sendMessage(_ text: String) { - logger.debug("ChatView sendMessage") - Task { - logger.debug("ChatView sendMessage: in Task") - do { - if let ei = editingItem { - let chatItem = try await apiUpdateChatItem( - type: chat.chatInfo.chatType, - id: chat.chatInfo.apiId, - itemId: ei.id, - msg: .text(text) - ) - DispatchQueue.main.async { - editingItem = nil - linkPreview = nil - let _ = chatModel.upsertChatItem(chat.chatInfo, chatItem) - } - } else { - let mc: MsgContent - var file: String? = nil - if let preview = imagePreview, - let uiImage = chosenImage, - let savedFile = saveImage(uiImage) { - mc = .image(text: text, image: preview) - file = savedFile - } else if let preview = linkPreview { - mc = .link(text: text, preview: preview) - } else { - mc = .text(text) - } - let chatItem = try await apiSendMessage( - type: chat.chatInfo.chatType, - id: chat.chatInfo.apiId, - file: file, - quotedItemId: quotedItem?.meta.itemId, - msg: mc - ) - DispatchQueue.main.async { - quotedItem = nil - linkPreview = nil - chosenImage = nil - imagePreview = nil - chatModel.addChatItem(chat.chatInfo, chatItem) - } - } - } catch { - logger.error("ChatView.sendMessage error: \(error.localizedDescription)") - } - } - } - - func saveImage(_ uiImage: UIImage) -> String? { - if let imageResized = resizeImageToDataSize(uiImage, maxDataSize: 160000), - let dataResized = Data(base64Encoded: dropImagePrefix(imageResized)), - let jpegData = UIImage(data: dataResized)?.jpegData(compressionQuality: 1) { - let millisecondsSince1970 = Int64((Date().timeIntervalSince1970 * 1000.0).rounded()) - let fileToSave = "image_\(millisecondsSince1970).jpg" - let filePath = getAppFilesDirectory().appendingPathComponent(fileToSave) - do { - try jpegData.write(to: filePath) - return fileToSave - } catch { - logger.error("ChatView.saveImage error: \(error.localizedDescription)") - return nil - } - } - return nil - } func deleteMessage(_ mode: CIDeleteMode) { logger.debug("ChatView deleteMessage") diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 62e9eec0dd..a73ed23140 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -8,43 +8,106 @@ import SwiftUI -// TODO -//enum ComposeState { -// case plain -// case quoted(quotedItem: ChatItem) -// case editing(editingItem: ChatItem) -//} +enum ComposePreview { + case noPreview + case linkPreview(linkPreview: LinkPreview) + case imagePreview(imagePreview: String) +} -//enum ReferencedItem { -// case none -// case quoted(quotedItem: ChatItem) -// case editing(editingItem: ChatItem) -//} -// -//enum Preview { -// case none -// case link(linkPreview: LinkPreview) -// case image(image: UIImage) -//} -// -//struct ComposeState { -// var quotedItem: ChatItem? = nil -// var editingItem: ChatItem? = nil -// var linkPreview: LinkPreview? = nil -//} +enum ComposeContextItem { + case noContextItem + case quotedItem(chatItem: ChatItem) + case editingItem(chatItem: ChatItem) +} + +struct ComposeState { + var message: String + var preview: ComposePreview + var contextItem: ComposeContextItem + var inProgress: Bool = false + + init( + message: String = "", + preview: ComposePreview = .noPreview, + contextItem: ComposeContextItem = .noContextItem + ) { + self.message = message + self.preview = preview + self.contextItem = contextItem + } + + init(editingItem: ChatItem) { + self.message = editingItem.content.text + self.preview = chatItemPreview(chatItem: editingItem) + self.contextItem = .editingItem(chatItem: editingItem) + } + + func copy( + message: String? = nil, + preview: ComposePreview? = nil, + contextItem: ComposeContextItem? = nil + ) -> ComposeState { + ComposeState( + message: message ?? self.message, + preview: preview ?? self.preview, + contextItem: contextItem ?? self.contextItem + ) + } + + func editing() -> Bool { + switch contextItem { + case .editingItem: return true + default: return false + } + } + + func sendEnabled() -> Bool { + switch preview { + case .imagePreview: + return true + default: + return !message.isEmpty + } + } + + func linkPreviewAllowed() -> Bool { + switch preview { + case .imagePreview: + return false + default: + return true + } + } + + func linkPreview() -> LinkPreview? { + switch preview { + case let .linkPreview(linkPreview): + return linkPreview + default: + return nil + } + } +} + +func chatItemPreview(chatItem: ChatItem) -> ComposePreview { + let chatItemPreview: ComposePreview + switch chatItem.content.msgContent { + case let .link(_, preview: preview): + chatItemPreview = .linkPreview(linkPreview: preview) + case let .image(_, image: image): + chatItemPreview = .imagePreview(imagePreview: image) + default: + chatItemPreview = .noPreview + } + return chatItemPreview +} struct ComposeView: View { - @Binding var message: String - @Binding var quotedItem: ChatItem? - @Binding var editingItem: ChatItem? - @Binding var linkPreview: LinkPreview? - - var sendMessage: (String) -> Void - var resetMessage: () -> Void - var inProgress: Bool = false + @EnvironmentObject var chatModel: ChatModel + let chat: Chat + @Binding var composeState: ComposeState @FocusState.Binding var keyboardVisible: Bool - @State var editing: Bool = false - @State var sendEnabled: Bool = false + @State var linkUrl: URL? = nil @State var prevLinkUrl: URL? = nil @State var pendingLinkUrl: URL? = nil @@ -53,59 +116,43 @@ struct ComposeView: View { @State private var showChooseSource = false @State private var showImagePicker = false @State private var imageSource: ImageSource = .imageLibrary - @Binding var chosenImage: UIImage? - @Binding var imagePreview: String? + @State var chosenImage: UIImage? var body: some View { VStack(spacing: 0) { - if let metadata = imagePreview { - ComposeImageView(image: metadata, cancelImage: nil) - } else if let metadata = linkPreview { - ComposeLinkView(linkPreview: metadata, cancelPreview: cancelPreview) - } - if (quotedItem != nil) { - ContextItemView(contextItem: $quotedItem, editing: $editing) - } else if (editingItem != nil) { - ContextItemView(contextItem: $editingItem, editing: $editing, resetMessage: resetMessage) - } - HStack{ -// Button { -// showChooseSource = true -// } label: { -// Image(systemName: "paperclip") -// .resizable() -// } -// .disabled(editingItem != nil) -// .frame(width: 25, height: 25) -// .padding(.vertical, 4) -// .padding(.leading, 12) + contextItemView() + previewView() + HStack (alignment: .bottom) { + Button { + showChooseSource = true + } label: { + Image(systemName: "paperclip") + .resizable() + } + .disabled(composeState.editing()) + .frame(width: 25, height: 25) + .padding(.bottom, 12) + .padding(.leading, 12) SendMessageView( + composeState: $composeState, sendMessage: { text in sendMessage(text) resetLinkPreview() }, - inProgress: inProgress, - message: $message, - keyboardVisible: $keyboardVisible, - editing: $editing, - sendEnabled: $sendEnabled + keyboardVisible: $keyboardVisible ) - .padding(.horizontal, 12) -// // use this padding when attach button is uncommented -// .padding(.trailing, 12) + .padding(.trailing, 12) .background(.background) } } - .onChange(of: message) { _ in - if message.count > 0 { - showLinkPreview(message) - } else { - resetLinkPreview() + .onChange(of: composeState.message) { _ in + if composeState.linkPreviewAllowed() { + if composeState.message.count > 0 { + showLinkPreview(composeState.message) + } else { + resetLinkPreview() + } } - sendEnabled = (imagePreview != nil || !message.isEmpty) - } - .onChange(of: editingItem == nil) { _ in - editing = (editingItem != nil) } .confirmationDialog("Attach", isPresented: $showChooseSource, titleVisibility: .visible) { Button("Take picture") { @@ -128,22 +175,133 @@ struct ComposeView: View { } } .onChange(of: chosenImage) { image in - if let image = image { - imagePreview = resizeImageToDataSize(image, maxDataSize: 12500) + if let image = image, + let imagePreview = resizeImageToStrSize(image, maxDataSize: 14000) { + composeState = composeState.copy(preview: .imagePreview(imagePreview: imagePreview)) } else { - imagePreview = nil + composeState = composeState.copy(preview: .noPreview) } } - .onChange(of: imagePreview) { _ in - sendEnabled = (imagePreview != nil || !message.isEmpty) + } + + @ViewBuilder func previewView() -> some View { + switch composeState.preview { + case .noPreview: + EmptyView() + case let .linkPreview(linkPreview: preview): + ComposeLinkView(linkPreview: preview, cancelPreview: cancelLinkPreview) + case let .imagePreview(imagePreview: img): + ComposeImageView( + image: img, + cancelImage: { composeState = composeState.copy(preview: .noPreview) }, + cancelEnabled: !composeState.editing()) } } + @ViewBuilder private func contextItemView() -> some View { + switch composeState.contextItem { + case .noContextItem: + EmptyView() + case let .quotedItem(chatItem: quotedItem): + ContextItemView(contextItem: quotedItem, cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }) + case let .editingItem(chatItem: editingItem): + ContextItemView(contextItem: editingItem, cancelContextItem: { composeState = ComposeState()}) + } + } + + private func sendMessage(_ text: String) { + logger.debug("ChatView sendMessage") + Task { + logger.debug("ChatView sendMessage: in Task") + do { + switch composeState.contextItem { + case let .editingItem(chatItem: ei): + if let oldMsgContent = ei.content.msgContent { + let chatItem = try await apiUpdateChatItem( + type: chat.chatInfo.chatType, + id: chat.chatInfo.apiId, + itemId: ei.id, + msg: updateMsgContent(oldMsgContent) + ) + DispatchQueue.main.async { + let _ = self.chatModel.upsertChatItem(self.chat.chatInfo, chatItem) + } + } + default: + var mc: MsgContent? = nil + var file: String? = nil + switch (composeState.preview) { + case .noPreview: + mc = .text(composeState.message) + case .linkPreview: + mc = checkLinkPreview() + case let .imagePreview(imagePreview: image): + if let uiImage = chosenImage, + let savedFile = saveImage(uiImage) { + mc = .image(text: composeState.message, image: image) + file = savedFile + } + } + + var quotedItemId: Int64? = nil + switch (composeState.contextItem) { + case let .quotedItem(chatItem: quotedItem): + quotedItemId = quotedItem.id + default: + quotedItemId = nil + } + if let mc = mc { + let chatItem = try await apiSendMessage( + type: chat.chatInfo.chatType, + id: chat.chatInfo.apiId, + file: file, + quotedItemId: quotedItemId, + msg: mc + ) + chatModel.addChatItem(chat.chatInfo, chatItem) + } + } + composeState = ComposeState() + } catch { + logger.error("ChatView.sendMessage error: \(error.localizedDescription)") + } + } + } + + private func updateMsgContent(_ msgContent: MsgContent) -> MsgContent { + switch msgContent { + case .text: + return checkLinkPreview() + case .link: + return checkLinkPreview() + case .image(_, let image): + return .image(text: composeState.message, image: image) + case .unknown(let type, _): + return .unknown(type: type, text: composeState.message) + } + } + + private func saveImage(_ uiImage: UIImage) -> String? { + if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: maxImageSize) { + let millisecondsSince1970 = Int64((Date().timeIntervalSince1970 * 1000.0).rounded()) + let fileToSave = "image_\(millisecondsSince1970).jpg" + let filePath = getAppFilesDirectory().appendingPathComponent(fileToSave) + do { + try imageDataResized.write(to: filePath) + return fileToSave + } catch { + logger.error("ChatView.saveImage error: \(error.localizedDescription)") + return nil + } + } + return nil + } + private func showLinkPreview(_ s: String) { prevLinkUrl = linkUrl linkUrl = parseMessage(s) if let url = linkUrl { - if url != linkPreview?.uri && url != pendingLinkUrl { + if url != composeState.linkPreview()?.uri && url != pendingLinkUrl { pendingLinkUrl = url if prevLinkUrl == url { loadLinkPreview(url) @@ -154,7 +312,7 @@ struct ComposeView: View { } } } else { - linkPreview = nil + composeState = composeState.copy(preview: .noPreview) } } @@ -162,7 +320,7 @@ struct ComposeView: View { do { let parsedMsg = try apiParseMarkdown(text: msg) let uri = parsedMsg?.first(where: { ft in - ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) + ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) }) if let uri = uri { return URL(string: uri.text) } else { return nil } @@ -176,18 +334,19 @@ struct ComposeView: View { link.starts(with: "https://simplex.chat") || link.starts(with: "http://simplex.chat") } - private func cancelPreview() { - if let uri = linkPreview?.uri.absoluteString { + private func cancelLinkPreview() { + if let uri = composeState.linkPreview()?.uri.absoluteString { cancelledLinks.insert(uri) } - linkPreview = nil + composeState = composeState.copy(preview: .noPreview) } private func loadLinkPreview(_ url: URL) { if pendingLinkUrl == url { - getLinkPreview(url: url) { lp in - if pendingLinkUrl == url { - linkPreview = lp + getLinkPreview(url: url) { linkPreview in + if let linkPreview = linkPreview, + pendingLinkUrl == url { + composeState = composeState.copy(preview: .linkPreview(linkPreview: linkPreview)) pendingLinkUrl = nil } } @@ -200,40 +359,38 @@ struct ComposeView: View { pendingLinkUrl = nil cancelledLinks = [] } + + private func checkLinkPreview() -> MsgContent { + switch (composeState.preview) { + case let .linkPreview(linkPreview: linkPreview): + if let url = parseMessage(composeState.message), + url == linkPreview.uri { + return .link(text: composeState.message, preview: linkPreview) + } else { + return .text(composeState.message) + } + default: + return .text(composeState.message) + } + } } struct ComposeView_Previews: PreviewProvider { static var previews: some View { - @State var message: String = "" + let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) + @State var composeState = ComposeState(message: "hello") @FocusState var keyboardVisible: Bool - @State var item: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello") - @State var nilItem: ChatItem? = nil - @State var linkPreview: LinkPreview? = nil - @State var chosenImage: UIImage? = nil - @State var imagePreview: String? = nil return Group { ComposeView( - message: $message, - quotedItem: $item, - editingItem: $nilItem, - linkPreview: $linkPreview, - sendMessage: { print ($0) }, - resetMessage: {}, - keyboardVisible: $keyboardVisible, - chosenImage: $chosenImage, - imagePreview: $imagePreview + chat: chat, + composeState: $composeState, + keyboardVisible: $keyboardVisible ) ComposeView( - message: $message, - quotedItem: $nilItem, - editingItem: $item, - linkPreview: $linkPreview, - sendMessage: { print ($0) }, - resetMessage: {}, - keyboardVisible: $keyboardVisible, - chosenImage: $chosenImage, - imagePreview: $imagePreview + chat: chat, + composeState: $composeState, + keyboardVisible: $keyboardVisible ) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index d853e17026..f7ebb1e53f 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -10,31 +10,25 @@ import SwiftUI struct ContextItemView: View { @Environment(\.colorScheme) var colorScheme - @Binding var contextItem: ChatItem? - @Binding var editing: Bool - var resetMessage: () -> Void = {} + let contextItem: ChatItem + let cancelContextItem: () -> Void var body: some View { - if let cxtItem = contextItem { - HStack { - contextText(cxtItem).lineLimit(3) - Spacer() - Button { - withAnimation { - contextItem = nil - if editing { resetMessage() } - } - } label: { - Image(systemName: "multiply") + HStack { + contextText(contextItem).lineLimit(3) + Spacer() + Button { + withAnimation { + cancelContextItem() } + } label: { + Image(systemName: "multiply") } - .padding(12) - .frame(maxWidth: .infinity) - .background(chatItemFrameColor(cxtItem, colorScheme)) - .padding(.top, 8) - } else { - EmptyView() } + .padding(12) + .frame(maxWidth: .infinity) + .background(chatItemFrameColor(contextItem, colorScheme)) + .padding(.top, 8) } func contextText(_ cxtItem: ChatItem) -> some View { @@ -48,8 +42,7 @@ struct ContextItemView: View { struct ContextItemView_Previews: PreviewProvider { static var previews: some View { - @State var contextItem: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello") - @State var editing: Bool = false - return ContextItemView(contextItem: $contextItem, editing: $editing) + let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello") + return ContextItemView(contextItem: contextItem, cancelContextItem: {}) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index bede2e2c03..3c4663f504 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -9,13 +9,10 @@ import SwiftUI struct SendMessageView: View { + @Binding var composeState: ComposeState var sendMessage: (String) -> Void - var inProgress: Bool = false - @Binding var message: String @Namespace var namespace @FocusState.Binding var keyboardVisible: Bool - @Binding var editing: Bool - @Binding var sendEnabled: Bool @State private var teHeight: CGFloat = 42 @State private var teFont: Font = .body var maxHeight: CGFloat = 360 @@ -25,15 +22,15 @@ struct SendMessageView: View { ZStack { HStack(alignment: .bottom) { ZStack(alignment: .leading) { - Text(message) + Text(composeState.message) + .lineLimit(10) .font(teFont) .foregroundColor(.clear) .padding(.horizontal, 10) .padding(.vertical, 8) .matchedGeometryEffect(id: "te", in: namespace) .background(GeometryReader(content: updateHeight)) - TextEditor(text: $message) - .onSubmit(submit) + TextEditor(text: $composeState.message) .focused($keyboardVisible) .font(teFont) .textInputAutocapitalization(.sentences) @@ -42,18 +39,18 @@ struct SendMessageView: View { .frame(height: teHeight) } - if (inProgress) { + if (composeState.inProgress) { ProgressView() .scaleEffect(1.4) .frame(width: 31, height: 31, alignment: .center) .padding([.bottom, .trailing], 3) } else { - Button(action: submit) { - Image(systemName: editing ? "checkmark.circle.fill" : "arrow.up.circle.fill") + Button(action: { sendMessage(composeState.message) }) { + Image(systemName: composeState.editing() ? "checkmark.circle.fill" : "arrow.up.circle.fill") .resizable() .foregroundColor(.accentColor) } - .disabled(!sendEnabled) + .disabled(!composeState.sendEnabled()) .frame(width: 29, height: 29) .padding([.bottom, .trailing], 4) } @@ -66,16 +63,11 @@ struct SendMessageView: View { .padding(.vertical, 8) } - func submit() { - sendMessage(message) - message = "" - } - func updateHeight(_ g: GeometryProxy) -> Color { DispatchQueue.main.async { teHeight = min(max(g.frame(in: .local).size.height, minHeight), maxHeight) - teFont = isShortEmoji(message) - ? message.count < 4 + teFont = isShortEmoji(composeState.message) + ? composeState.message.count < 4 ? largeEmojiFont : mediumEmojiFont : .body @@ -86,34 +78,29 @@ struct SendMessageView: View { struct SendMessageView_Previews: PreviewProvider { static var previews: some View { - @State var message: String = "" + @State var composeStateNew = ComposeState() + let ci = ChatItem.getSample(1, .directSnd, .now, "hello") + @State var composeStateEditing = ComposeState(editingItem: ci) @FocusState var keyboardVisible: Bool - @State var editingOff: Bool = false - @State var editingOn: Bool = true @State var sendEnabled: Bool = true - @State var item: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello") return Group { VStack { Text("") Spacer(minLength: 0) SendMessageView( + composeState: $composeStateNew, sendMessage: { print ($0) }, - message: $message, - keyboardVisible: $keyboardVisible, - editing: $editingOff, - sendEnabled: $sendEnabled + keyboardVisible: $keyboardVisible ) } VStack { Text("") Spacer(minLength: 0) SendMessageView( + composeState: $composeStateEditing, sendMessage: { print ($0) }, - message: $message, - keyboardVisible: $keyboardVisible, - editing: $editingOn, - sendEnabled: $sendEnabled + keyboardVisible: $keyboardVisible ) } } diff --git a/apps/ios/Shared/Views/Helpers/ComposeImageView.swift b/apps/ios/Shared/Views/Helpers/ComposeImageView.swift index 2d083c9721..c23de72229 100644 --- a/apps/ios/Shared/Views/Helpers/ComposeImageView.swift +++ b/apps/ios/Shared/Views/Helpers/ComposeImageView.swift @@ -11,7 +11,8 @@ import SwiftUI struct ComposeImageView: View { @Environment(\.colorScheme) var colorScheme let image: String - var cancelImage: (() -> Void)? = nil + let cancelImage: (() -> Void) + let cancelEnabled: Bool var body: some View { HStack(alignment: .center, spacing: 8) { @@ -20,9 +21,10 @@ struct ComposeImageView: View { Image(uiImage: uiImage) .resizable() .aspectRatio(contentMode: .fit) - .frame(maxWidth: 80, maxHeight: 60) + .frame(maxWidth: 80, minHeight: 40, maxHeight: 60) } - if let cancelImage = cancelImage { + Spacer() + if cancelEnabled { Button { cancelImage() } label: { Image(systemName: "multiply") } @@ -35,3 +37,30 @@ struct ComposeImageView: View { .padding(.top, 8) } } + +//struct ComposeImageView: View { +// @Environment(\.colorScheme) var colorScheme +// let image: String +// let cancelImage: (() -> Void) +// +// var body: some View { +// if let data = Data(base64Encoded: dropImagePrefix(image)), +// let uiImage = UIImage(data: data) { +// HStack(alignment: .center) { +// ZStack(alignment: .topTrailing) { +// Image(uiImage: uiImage) +// .resizable() +// .scaledToFit() +// .cornerRadius(20) +// .frame(maxHeight: 150) +// Button { cancelImage() } label: { +// Image(systemName: "multiply") +// .foregroundColor(.white) +// } +// .padding(8) +// } +// } +// .padding(.top, 8) +// } +// } +//} diff --git a/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift b/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift index 1a9c446499..6ab5e49980 100644 --- a/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift +++ b/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift @@ -25,7 +25,7 @@ func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) { logger.error("Couldn't load image preview from link metadata with error: \(error.localizedDescription)") } else { if let image = object as? UIImage, - let resized = resizeImageToDataSize(image, maxDataSize: 14000), + let resized = resizeImageToStrSize(image, maxDataSize: 14000), let title = metadata.title, let uri = metadata.originalURL { linkPreview = LinkPreview(uri: uri, title: title, image: resized) diff --git a/apps/ios/Shared/Views/Helpers/ImagePicker.swift b/apps/ios/Shared/Views/Helpers/ImagePicker.swift index 7fa0bc722b..06c4825c6a 100644 --- a/apps/ios/Shared/Views/Helpers/ImagePicker.swift +++ b/apps/ios/Shared/Views/Helpers/ImagePicker.swift @@ -46,28 +46,43 @@ func reduceSize(_ image: UIImage, ratio: CGFloat) -> UIImage { return resizeImage(image, newBounds: bounds, drawIn: bounds) } -func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int) -> String? { +func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int) -> String? { var img = image - var str = compressImage(img) + var str = compressImageStr(img) var dataSize = str?.count ?? 0 while dataSize != 0 && dataSize > maxDataSize { let ratio = sqrt(Double(dataSize) / Double(maxDataSize)) let clippedRatio = min(ratio, 2.0) img = reduceSize(img, ratio: clippedRatio) - str = compressImage(img) + str = compressImageStr(img) dataSize = str?.count ?? 0 } - logger.debug("resizeImageToDataSize final \(dataSize)") + logger.debug("resizeImageToStrSize final \(dataSize)") return str } -func compressImage(_ image: UIImage, _ compressionQuality: CGFloat = 0.85) -> String? { +func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85) -> String? { if let data = image.jpegData(compressionQuality: compressionQuality) { return "data:image/jpg;base64,\(data.base64EncodedString())" } return nil } +func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int) -> Data? { + var img = image + var data = img.jpegData(compressionQuality: 0.85) + var dataSize = data?.count ?? 0 + while dataSize != 0 && dataSize > maxDataSize { + let ratio = sqrt(Double(dataSize) / Double(maxDataSize)) + let clippedRatio = min(ratio, 2.0) + img = reduceSize(img, ratio: clippedRatio) + data = img.jpegData(compressionQuality: 0.85) + dataSize = data?.count ?? 0 + } + logger.debug("resizeImageToDataSize final \(dataSize)") + return data +} + enum ImageSource { case imageLibrary case camera diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index 98d7bbd39d..56cc0bb8c9 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -14,11 +14,8 @@ private let maxItemSize: Int = 50000 struct TerminalView: View { @EnvironmentObject var chatModel: ChatModel - @State var inProgress: Bool = false - @State var message: String = "" + @State var composeState: ComposeState = ComposeState() @FocusState private var keyboardVisible: Bool - @State var editing: Bool = false - @State var sendEnabled: Bool = false var body: some View { VStack { @@ -64,21 +61,15 @@ struct TerminalView: View { Spacer() SendMessageView( + composeState: $composeState, sendMessage: sendMessage, - inProgress: inProgress, - message: $message, - keyboardVisible: $keyboardVisible, - editing: $editing, - sendEnabled: $sendEnabled + keyboardVisible: $keyboardVisible ) .padding(.horizontal, 12) } } .navigationViewStyle(.stack) .navigationTitle("Chat console") - .onChange(of: message) { _ in - sendEnabled = !message.isEmpty - } } func scrollToBottom(_ proxy: ScrollViewProxy, animation: Animation = .default) { @@ -93,11 +84,12 @@ struct TerminalView: View { let cmd = ChatCommand.string(cmdStr) DispatchQueue.global().async { Task { - inProgress = true + composeState.inProgress = true _ = await chatSendCmd(cmd) - inProgress = false + composeState.inProgress = false } } + composeState = ComposeState() } } diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index 8c38681b3c..4de7695136 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -99,7 +99,7 @@ struct UserProfile: View { } .onChange(of: chosenImage) { image in if let image = image { - profile.image = resizeImageToDataSize(cropToSquare(image), maxDataSize: 12500) + profile.image = resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500) } else { profile.image = nil } From 89c36d42e29cc7b01697e81a748b7f32ec642fa5 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 25 Apr 2022 10:39:28 +0100 Subject: [PATCH 19/95] ios: pending contact connections UI, core: delete connections on the server when deleting in UI/db (#565) * ios: started pending connections UI * ios: UI for pending contact connections complete * this has to be getter, or it would break JSON parsing * ios: update "initiated" status of connection --- apps/ios/LOCALIZATION.md | 2 +- .../DebugJSON.playground/Contents.swift | 20 +++ .../contents.xcplayground | 4 + apps/ios/Shared/Model/ChatModel.swift | 118 +++++++++++++++++- apps/ios/Shared/Model/SimpleXAPI.swift | 57 +++++---- .../Views/ChatList/ChatListNavLink.swift | 52 +++++++- .../Shared/Views/ChatList/ChatListView.swift | 12 +- .../ChatList/ContactConnectionView.swift | 55 ++++++++ .../Views/ChatList/ContactRequestView.swift | 4 +- .../Views/UserSettings/SettingsView.swift | 8 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 6 + src/Simplex/Chat.hs | 7 +- src/Simplex/Chat/Store.hs | 47 ++++--- src/Simplex/Chat/Types.hs | 3 + 14 files changed, 343 insertions(+), 52 deletions(-) create mode 100644 apps/ios/Shared/DebugJSON.playground/Contents.swift create mode 100644 apps/ios/Shared/DebugJSON.playground/contents.xcplayground create mode 100644 apps/ios/Shared/Views/ChatList/ContactConnectionView.swift diff --git a/apps/ios/LOCALIZATION.md b/apps/ios/LOCALIZATION.md index f58b8918b4..b0c6c7088b 100644 --- a/apps/ios/LOCALIZATION.md +++ b/apps/ios/LOCALIZATION.md @@ -11,7 +11,7 @@ There are three ways XCode generates localization keys from strings: 3. All strings wrapped in `NSLocalizedString`. Please note that such strings do not support swift interpolation, instead formatted strings should be used: ```swift -String.localizedStringWithFormat(NSLocalizedString("You can now send messages to %@", comment: "notification body") +String.localizedStringWithFormat(NSLocalizedString("You can now send messages to %@", comment: "notification body"), value) ``` ## Adding strings to the existing localizations diff --git a/apps/ios/Shared/DebugJSON.playground/Contents.swift b/apps/ios/Shared/DebugJSON.playground/Contents.swift new file mode 100644 index 0000000000..e62ca1ab53 --- /dev/null +++ b/apps/ios/Shared/DebugJSON.playground/Contents.swift @@ -0,0 +1,20 @@ +import UIKit + +let s = """ + { + "contactConnection" : { + "contactConnection" : { + "viaContactUri" : false, + "pccConnId" : 456, + "pccAgentConnId" : "cTdjbmR4ZzVzSmhEZHdzMQ==", + "pccConnStatus" : "new", + "updatedAt" : "2022-04-24T11:59:23.703162Z", + "createdAt" : "2022-04-24T11:59:23.703162Z" + } + } + } +""" +//let s = "\"2022-04-24T11:59:23.703162Z\"" +let json = getJSONDecoder() +let d = s.data(using: .utf8)! +print (try! json.decode(ChatInfo.self, from: d)) diff --git a/apps/ios/Shared/DebugJSON.playground/contents.xcplayground b/apps/ios/Shared/DebugJSON.playground/contents.xcplayground new file mode 100644 index 0000000000..cf026f2286 --- /dev/null +++ b/apps/ios/Shared/DebugJSON.playground/contents.xcplayground @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 2a747881f0..e2960a6ea0 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -54,9 +54,16 @@ final class ChatModel: ObservableObject { } } + func updateContactConnection(_ contactConnection: PendingContactConnection) { + updateChat(.contactConnection(contactConnection: contactConnection)) + } + func updateContact(_ contact: Contact) { - let cInfo = ChatInfo.direct(contact: contact) - if hasChat(contact.id) { + updateChat(.direct(contact: contact)) + } + + private func updateChat(_ cInfo: ChatInfo) { + if hasChat(cInfo.id) { updateChatInfo(cInfo) } else { addChat(Chat(chatInfo: cInfo, chatItems: [])) @@ -248,6 +255,7 @@ enum ChatType: String { case direct = "@" case group = "#" case contactRequest = "<@" + case contactConnection = ":" } protocol NamedChat { @@ -268,6 +276,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { case direct(contact: Contact) case group(groupInfo: GroupInfo) case contactRequest(contactRequest: UserContactRequest) + case contactConnection(contactConnection: PendingContactConnection) var localDisplayName: String { get { @@ -275,6 +284,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { case let .direct(contact): return contact.localDisplayName case let .group(groupInfo): return groupInfo.localDisplayName case let .contactRequest(contactRequest): return contactRequest.localDisplayName + case let .contactConnection(contactConnection): return contactConnection.localDisplayName } } } @@ -285,6 +295,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { case let .direct(contact): return contact.displayName case let .group(groupInfo): return groupInfo.displayName case let .contactRequest(contactRequest): return contactRequest.displayName + case let .contactConnection(contactConnection): return contactConnection.displayName } } } @@ -295,6 +306,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { case let .direct(contact): return contact.fullName case let .group(groupInfo): return groupInfo.fullName case let .contactRequest(contactRequest): return contactRequest.fullName + case let .contactConnection(contactConnection): return contactConnection.fullName } } } @@ -305,6 +317,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { case let .direct(contact): return contact.image case let .group(groupInfo): return groupInfo.image case let .contactRequest(contactRequest): return contactRequest.image + case let .contactConnection(contactConnection): return contactConnection.image } } } @@ -315,6 +328,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { case let .direct(contact): return contact.id case let .group(groupInfo): return groupInfo.id case let .contactRequest(contactRequest): return contactRequest.id + case let .contactConnection(contactConnection): return contactConnection.id } } } @@ -325,6 +339,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { case .direct: return .direct case .group: return .group case .contactRequest: return .contactRequest + case .contactConnection: return .contactConnection } } } @@ -335,6 +350,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { case let .direct(contact): return contact.apiId case let .group(groupInfo): return groupInfo.apiId case let .contactRequest(contactRequest): return contactRequest.apiId + case let .contactConnection(contactConnection): return contactConnection.apiId } } } @@ -345,6 +361,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { case let .direct(contact): return contact.ready case let .group(groupInfo): return groupInfo.ready case let .contactRequest(contactRequest): return contactRequest.ready + case let .contactConnection(contactConnection): return contactConnection.ready } } } @@ -354,6 +371,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { case let .direct(contact): return contact.createdAt case let .group(groupInfo): return groupInfo.createdAt case let .contactRequest(contactRequest): return contactRequest.createdAt + case let .contactConnection(contactConnection): return contactConnection.createdAt } } @@ -456,7 +474,7 @@ struct Contact: Identifiable, Decodable, NamedChat { var id: ChatId { get { "@\(contactId)" } } var apiId: Int64 { get { contactId } } - var ready: Bool { get { activeConn.connStatus == "ready" } } + var ready: Bool { get { activeConn.connStatus == .ready } } var displayName: String { get { profile.displayName } } var fullName: String { get { profile.fullName } } var image: String? { get { profile.image } } @@ -476,9 +494,15 @@ struct ContactSubStatus: Decodable { } struct Connection: Decodable { - var connStatus: String + var connId: Int64 + var connStatus: ConnStatus - static let sampleData = Connection(connStatus: "ready") + var id: ChatId { get { ":\(connId)" } } + + static let sampleData = Connection( + connId: 1, + connStatus: .ready + ) } struct UserContactRequest: Decodable, NamedChat { @@ -486,6 +510,7 @@ struct UserContactRequest: Decodable, NamedChat { var localDisplayName: ContactName var profile: Profile var createdAt: Date + var updatedAt: Date var id: ChatId { get { "<@\(contactRequestId)" } } var apiId: Int64 { get { contactRequestId } } @@ -498,10 +523,91 @@ struct UserContactRequest: Decodable, NamedChat { contactRequestId: 1, localDisplayName: "alice", profile: Profile.sampleData, - createdAt: .now + createdAt: .now, + updatedAt: .now ) } +struct PendingContactConnection: Decodable, NamedChat { + var pccConnId: Int64 + var pccAgentConnId: String + var pccConnStatus: ConnStatus + var viaContactUri: Bool + var createdAt: Date + var updatedAt: Date + + var id: ChatId { get { ":\(pccConnId)" } } + var apiId: Int64 { get { pccConnId } } + var ready: Bool { get { false } } + var localDisplayName: String { + get { String.localizedStringWithFormat(NSLocalizedString("connection:%@", comment: "connection information"), pccConnId) } + } + var displayName: String { + get { + if let initiated = pccConnStatus.initiated { + return initiated && !viaContactUri + ? NSLocalizedString("invited to connect", comment: "chat list item title") + : NSLocalizedString("connecting…", comment: "chat list item title") + } else { + // this should not be in the list + return NSLocalizedString("connection established", comment: "chat list item title (it should not be shown") + } + } + } + var fullName: String { get { "" } } + var image: String? { get { nil } } + var initiated: Bool { get { (pccConnStatus.initiated ?? false) && !viaContactUri } } + + var description: String { + get { + if let initiated = pccConnStatus.initiated { + return initiated && !viaContactUri + ? NSLocalizedString("you shared one-time link", comment: "chat list item description") + : viaContactUri + ? NSLocalizedString("via contact address link", comment: "chat list item description") + : NSLocalizedString("via one-time link", comment: "chat list item description") + } else { + return "" + } + } + } + + static func getSampleData(_ status: ConnStatus = .new, viaContactUri: Bool = false) -> PendingContactConnection { + PendingContactConnection( + pccConnId: 1, + pccAgentConnId: "abcd", + pccConnStatus: status, + viaContactUri: viaContactUri, + createdAt: .now, + updatedAt: .now + ) + } +} + +enum ConnStatus: String, Decodable { + case new = "new" + case joined = "joined" + case requested = "requested" + case accepted = "accepted" + case sndReady = "snd-ready" + case ready = "ready" + case deleted = "deleted" + + var initiated: Bool? { + get { + switch self { + case .new: return true + case .joined: return false + case .requested: return true + case .accepted: return true + case .sndReady: return false + case .ready: return nil + case .deleted: return nil + } + } + } +} + struct GroupInfo: Identifiable, Decodable, NamedChat { var groupId: Int64 var localDisplayName: GroupName diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 524fd047b0..af6803b797 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -52,7 +52,7 @@ enum ChatCommand { case let .createActiveUser(profile): return "/u \(profile.displayName) \(profile.fullName)" case .startChat: return "/_start" case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)" - case .apiGetChats: return "/_get chats" + case .apiGetChats: return "/_get chats pcc=on" case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100" case let .apiSendMessage(type, id, file, quotedItemId, mc): switch (file, quotedItemId) { @@ -174,6 +174,8 @@ enum ChatResponse: Decodable, Error { case rcvFileAccepted case rcvFileComplete(chatItem: AChatItem) case ntfTokenStatus(status: NtfTknStatus) + case newContactConnection(connection: PendingContactConnection) + case contactConnectionDeleted(connection: PendingContactConnection) case cmdOk case chatCmdError(chatError: ChatError) case chatError(chatError: ChatError) @@ -220,6 +222,8 @@ enum ChatResponse: Decodable, Error { case .rcvFileAccepted: return "rcvFileAccepted" case .rcvFileComplete: return "rcvFileComplete" case .ntfTokenStatus: return "ntfTokenStatus" + case .newContactConnection: return "newContactConnection" + case .contactConnectionDeleted: return "contactConnectionDeleted" case .cmdOk: return "cmdOk" case .chatCmdError: return "chatCmdError" case .chatError: return "chatError" @@ -269,6 +273,8 @@ enum ChatResponse: Decodable, Error { case .rcvFileAccepted: return noDetails case let .rcvFileComplete(chatItem): return String(describing: chatItem) case let .ntfTokenStatus(status): return String(describing: status) + case let .newContactConnection(connection): return String(describing: connection) + case let .contactConnectionDeleted(connection): return String(describing: connection) case .cmdOk: return noDetails case let .chatCmdError(chatError): return String(describing: chatError) case let .chatError(chatError): return String(describing: chatError) @@ -538,7 +544,8 @@ func apiConnect(connReq: String) async throws -> ConnReqType? { func apiDeleteChat(type: ChatType, id: Int64) async throws { let r = await chatSendCmd(.apiDeleteChat(type: type, id: id), bgTask: false) - if case .contactDeleted = r { return } + if case .direct = type, case .contactDeleted = r { return } + if case .contactConnection = type, case .contactConnectionDeleted = r { return } throw r } @@ -608,7 +615,7 @@ func acceptContactRequest(_ contactRequest: UserContactRequest) async { let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: []) DispatchQueue.main.async { ChatModel.shared.replaceChat(contactRequest.id, chat) } } catch let error { - logger.error("acceptContactRequest error: \(error.localizedDescription)") + logger.error("acceptContactRequest error: \(responseError(error))") } } @@ -617,7 +624,7 @@ func rejectContactRequest(_ contactRequest: UserContactRequest) async { try await apiRejectContactRequest(contactReqId: contactRequest.apiId) DispatchQueue.main.async { ChatModel.shared.removeChat(contactRequest.id) } } catch let error { - logger.error("rejectContactRequest: \(error.localizedDescription)") + logger.error("rejectContactRequest: \(responseError(error))") } } @@ -629,7 +636,7 @@ func markChatRead(_ chat: Chat) async { try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: itemRange) DispatchQueue.main.async { ChatModel.shared.markChatItemsRead(cInfo) } } catch { - logger.error("markChatRead apiChatRead error: \(error.localizedDescription)") + logger.error("markChatRead apiChatRead error: \(responseError(error))") } } @@ -638,7 +645,7 @@ func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async { try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id)) DispatchQueue.main.async { ChatModel.shared.markChatItemRead(cInfo, cItem) } } catch { - logger.error("markChatItemRead apiChatRead error: \(error.localizedDescription)") + logger.error("markChatItemRead apiChatRead error: \(responseError(error))") } } @@ -701,33 +708,37 @@ class ChatReceiver { } func processReceivedMsg(_ res: ChatResponse) { - let chatModel = ChatModel.shared + let m = ChatModel.shared DispatchQueue.main.async { - chatModel.terminalItems.append(.resp(.now, res)) + m.terminalItems.append(.resp(.now, res)) logger.debug("processReceivedMsg: \(res.responseType)") switch res { + case let .newContactConnection(contactConnection): + m.updateContactConnection(contactConnection) case let .contactConnected(contact): - chatModel.updateContact(contact) - chatModel.updateNetworkStatus(contact, .connected) + m.updateContact(contact) + m.removeChat(contact.activeConn.id) + m.updateNetworkStatus(contact, .connected) NtfManager.shared.notifyContactConnected(contact) case let .contactConnecting(contact): - chatModel.updateContact(contact) + m.updateContact(contact) + m.removeChat(contact.activeConn.id) case let .receivedContactRequest(contactRequest): - chatModel.addChat(Chat( + m.addChat(Chat( chatInfo: ChatInfo.contactRequest(contactRequest: contactRequest), chatItems: [] )) NtfManager.shared.notifyContactRequest(contactRequest) case let .contactUpdated(toContact): let cInfo = ChatInfo.direct(contact: toContact) - if chatModel.hasChat(toContact.id) { - chatModel.updateChatInfo(cInfo) + if m.hasChat(toContact.id) { + m.updateChatInfo(cInfo) } case let .contactSubscribed(contact): processContactSubscribed(contact) case let .contactDisconnected(contact): - chatModel.updateContact(contact) - chatModel.updateNetworkStatus(contact, .disconnected) + m.updateContact(contact) + m.updateNetworkStatus(contact, .disconnected) case let .contactSubError(contact, chatError): processContactSubError(contact, chatError) case let .contactSubSummary(contactSubscriptions): @@ -741,7 +752,7 @@ func processReceivedMsg(_ res: ChatResponse) { case let .newChatItem(aChatItem): let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem - chatModel.addChatItem(cInfo, cItem) + m.addChatItem(cInfo, cItem) if let file = cItem.file, file.fileSize <= maxImageSize { Task { @@ -758,11 +769,11 @@ func processReceivedMsg(_ res: ChatResponse) { let cItem = aChatItem.chatItem var res = false if !cItem.isDeletedContent() { - res = chatModel.upsertChatItem(cInfo, cItem) + res = m.upsertChatItem(cInfo, cItem) } if res { NtfManager.shared.notifyMessageReceived(cInfo, cItem) - } else if let endTask = chatModel.messageDelivery[cItem.id] { + } else if let endTask = m.messageDelivery[cItem.id] { switch cItem.meta.itemStatus { case .sndSent: endTask() case .sndErrorAuth: endTask() @@ -773,22 +784,22 @@ func processReceivedMsg(_ res: ChatResponse) { case let .chatItemUpdated(aChatItem): let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem - if chatModel.upsertChatItem(cInfo, cItem) { + if m.upsertChatItem(cInfo, cItem) { NtfManager.shared.notifyMessageReceived(cInfo, cItem) } case let .chatItemDeleted(_, toChatItem): let cInfo = toChatItem.chatInfo let cItem = toChatItem.chatItem if cItem.meta.itemDeleted { - chatModel.removeChatItem(cInfo, cItem) + m.removeChatItem(cInfo, cItem) } else { // currently only broadcast deletion of rcv message can be received, and only this case should happen - _ = chatModel.upsertChatItem(cInfo, cItem) + _ = m.upsertChatItem(cInfo, cItem) } case let .rcvFileComplete(aChatItem): let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem - if chatModel.upsertChatItem(cInfo, cItem) { + if m.upsertChatItem(cInfo, cItem) { NtfManager.shared.notifyMessageReceived(cInfo, cItem) } default: diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index bf20e1bd32..78b6777eaa 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -21,6 +21,8 @@ struct ChatListNavLink: View { groupNavLink(groupInfo) case let .contactRequest(cReq): contactRequestNavLink(cReq) + case let .contactConnection(cConn): + contactConnectionNavLink(cConn) } } @@ -125,6 +127,31 @@ struct ChatListNavLink: View { } } + private func contactConnectionNavLink(_ contactConnection: PendingContactConnection) -> some View { + ContactConnectionView(contactConnection: contactConnection) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + AlertManager.shared.showAlert(deleteContactConnectionAlert(contactConnection)) + } label: { + Label("Delete", systemImage: "trash") + } + } + .frame(height: 80) + .onTapGesture { + AlertManager.shared.showAlertMsg( + title: + contactConnection.initiated + ? "You invited your contact" + : "You accepted connection", + // below are the same messages that are shown in alert + message: + contactConnection.viaContactUri + ? "You will be connected when your connection request is accepted, please wait or check later!" + : "You will be connected when your contact's device is online, please wait or check later!" + ) + } + } + private func deleteContactAlert(_ contact: Contact) -> Alert { Alert( title: Text("Delete contact?"), @@ -137,7 +164,7 @@ struct ChatListNavLink: View { chatModel.removeChat(contact.id) } } catch let error { - logger.error("ChatListNavLink.deleteContactAlert apiDeleteChat error: \(error.localizedDescription)") + logger.error("ChatListNavLink.deleteContactAlert apiDeleteChat error: \(responseError(error))") } } }, @@ -163,6 +190,29 @@ struct ChatListNavLink: View { ) } + private func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection) -> Alert { + Alert( + title: Text("Delete pending connection?"), + message: + contactConnection.initiated + ? Text("The contact you shared this link with will NOT be able to connect!") + : Text("The connection you accepted will be cancelled!"), + primaryButton: .destructive(Text("Delete")) { + Task { + do { + try await apiDeleteChat(type: .contactConnection, id: contactConnection.apiId) + DispatchQueue.main.async { + chatModel.removeChat(contactConnection.id) + } + } catch let error { + logger.error("ChatListNavLink.deleteContactConnectionAlert apiDeleteChat error: \(responseError(error))") + } + } + }, + secondaryButton: .cancel() + ) + } + private func pendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert { Alert( title: Text("Contact is not connected yet!"), diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 20b513315a..094f916bda 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -13,6 +13,7 @@ struct ChatListView: View { // not really used in this view @State private var showSettings = false @State private var searchText = "" + @AppStorage("pendingConnections") private var pendingConnections = true var user: User @@ -64,9 +65,16 @@ struct ChatListView: View { private func filteredChats() -> [Chat] { let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase - return s == "" + return s == "" && pendingConnections ? chatModel.chats - : chatModel.chats.filter { $0.chatInfo.chatViewName.localizedLowercase.contains(s) } + : s == "" + ? chatModel.chats.filter { + pendingConnections || $0.chatInfo.chatType != .contactConnection + } + : chatModel.chats.filter { + (pendingConnections || $0.chatInfo.chatType != .contactConnection) && + $0.chatInfo.chatViewName.localizedLowercase.contains(s) + } } private func connectViaUrlAlert(_ url: URL) -> Alert { diff --git a/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift b/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift new file mode 100644 index 0000000000..90c9b70bd8 --- /dev/null +++ b/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift @@ -0,0 +1,55 @@ +// +// ContactConnectionView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 24/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ContactConnectionView: View { + var contactConnection: PendingContactConnection + + var body: some View { + HStack(spacing: 8) { + Image(systemName: contactConnection.initiated ? "link.badge.plus" : "link") + .resizable() + .foregroundColor(Color(uiColor: .secondarySystemBackground)) + .scaledToFill() + .frame(width: 48, height: 48) + .frame(width: 63, height: 63) + .padding(.leading, 4) + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .top) { + Text(contactConnection.chatViewName) + .font(.title3) + .fontWeight(.bold) + .foregroundColor(.secondary) + .padding(.leading, 8) + .padding(.top, 4) + .frame(maxHeight: .infinity, alignment: .topLeading) + Spacer() + timestampText(contactConnection.updatedAt) + .font(.subheadline) + .padding(.trailing, 8) + .padding(.top, 4) + .frame(minWidth: 60, alignment: .trailing) + .foregroundColor(.secondary) + } + Text(contactConnection.description) + .frame(minHeight: 44, maxHeight: 44, alignment: .topLeading) + .padding([.leading, .trailing], 8) + .padding(.bottom, 4) + .padding(.top, 1) + } + } + } +} + +struct ContactConnectionView_Previews: PreviewProvider { + static var previews: some View { + ContactConnectionView(contactConnection: PendingContactConnection.getSampleData()) + .previewLayout(.fixed(width: 360, height: 80)) + } +} diff --git a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift index 2eedbdeb49..31be17edf8 100644 --- a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift +++ b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift @@ -20,7 +20,7 @@ struct ContactRequestView: View { .padding(.leading, 4) VStack(alignment: .leading, spacing: 4) { HStack(alignment: .top) { - Text(ChatInfo.contactRequest(contactRequest: contactRequest).chatViewName) + Text(contactRequest.chatViewName) .font(.title3) .fontWeight(.bold) .foregroundColor(.blue) @@ -28,7 +28,7 @@ struct ContactRequestView: View { .padding(.top, 4) .frame(maxHeight: .infinity, alignment: .topLeading) Spacer() - timestampText(contactRequest.createdAt) + timestampText(contactRequest.updatedAt) .font(.subheadline) .padding(.trailing, 8) .padding(.top, 4) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index ff969c9ca2..c2d329d102 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -18,7 +18,8 @@ struct SettingsView: View { @Environment(\.colorScheme) var colorScheme @EnvironmentObject var chatModel: ChatModel @Binding var showSettings: Bool - @AppStorage("useNotifications") private var useNotifications: Bool = false + @AppStorage("useNotifications") private var useNotifications = false + @AppStorage("pendingConnections") private var pendingConnections = true @State var showNotificationsAlert: Bool = false @State var whichNotificationsAlert = NotificationAlert.enable @@ -59,6 +60,11 @@ struct SettingsView: View { } Section("Settings") { + HStack { + Image(systemName: "link") + .padding(.trailing, 8) + Toggle("Show pending connections", isOn: $pendingConnections) + } NavigationLink { SMPServers() .navigationTitle("Your SMP servers") diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 953e7abd55..9a6c0b7d2a 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; }; 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; }; 5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; }; + 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C13730A28156D2700F43030 /* ContactConnectionView.swift */; }; 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; }; 5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */; }; 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260A27A30CFA00F70299 /* ChatListView.swift */; }; @@ -90,6 +91,8 @@ 3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = ""; }; 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreviewView.swift; sourceTree = ""; }; 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactRequestView.swift; sourceTree = ""; }; + 5C13730A28156D2700F43030 /* ContactConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactConnectionView.swift; sourceTree = ""; }; + 5C13730C2815740A00F43030 /* DebugJSON.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = DebugJSON.playground; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemView.swift; sourceTree = ""; }; 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXAPI.swift; sourceTree = ""; }; 5C2E260A27A30CFA00F70299 /* ChatListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListView.swift; sourceTree = ""; }; @@ -286,6 +289,7 @@ 5CA059C5279559F40002BEB4 /* Assets.xcassets */, 5C764E7D279C7275000C6508 /* SimpleX (iOS)-Bridging-Header.h */, 5C764E7F279C7276000C6508 /* dummy.m */, + 5C13730C2815740A00F43030 /* DebugJSON.playground */, ); path = Shared; sourceTree = ""; @@ -342,6 +346,7 @@ 5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */, 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */, 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */, + 5C13730A28156D2700F43030 /* ContactConnectionView.swift */, ); path = ChatList; sourceTree = ""; @@ -483,6 +488,7 @@ 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */, 5C36027327F47AD5009F19D9 /* AppDelegate.swift in Sources */, 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */, + 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */, 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */, 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */, 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */, diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index a40773e9ff..64321d7e6e 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -365,8 +365,11 @@ processChatCommand = \case unsetActive $ ActiveC localDisplayName pure $ CRContactDeleted ct gs -> throwChatError $ CEContactGroups ct gs - CTContactConnection -> - CRContactConnectionDeleted <$> withStore (\st -> deletePendingContactConnection st userId chatId) + CTContactConnection -> withChatLock . procCmd $ do + conn <- withStore $ \st -> getPendingContactConnection st userId chatId + withAgent $ \a -> deleteConnection a $ aConnId' conn + withStore $ \st -> deletePendingContactConnection st userId chatId + pure $ CRContactConnectionDeleted conn CTGroup -> pure $ chatCmdError "not implemented" CTContactRequest -> pure $ chatCmdError "not supported" APIAcceptContact connReqId -> withUser $ \user@User {userId} -> withChatLock $ do diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 257fde2257..9c381a7764 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -152,6 +152,7 @@ module Simplex.Chat.Store updateGroupChatItemsRead, getSMPServers, overwriteSMPServers, + getPendingContactConnection, deletePendingContactConnection, ) where @@ -2727,21 +2728,39 @@ getContactConnectionChatPreviews_ db User {userId} _ = stats = ChatStats {unreadCount = 0, minUnreadItemId = 0} in AChat SCTContactConnection $ Chat (ContactConnection conn) [] stats -deletePendingContactConnection :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m PendingContactConnection +getPendingContactConnection :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m PendingContactConnection +getPendingContactConnection st userId connId = + liftIOEither . withTransaction st $ \db -> do + firstRow toPendingContactConnection (SEPendingConnectionNotFound connId) $ + DB.query + db + [sql| + SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, created_at, updated_at + FROM connections + WHERE user_id = ? + AND connection_id = ? + AND conn_type = ? + AND contact_id IS NULL + AND conn_level = 0 + AND via_contact IS NULL + |] + (userId, connId, ConnContact) + +deletePendingContactConnection :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> m () deletePendingContactConnection st userId connId = - liftIOEither . withTransaction st $ \db -> runExceptT $ do - conn <- - ExceptT . firstRow toPendingContactConnection (SEPendingConnectionNotFound connId) $ - DB.query - db - [sql| - SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, created_at, updated_at - FROM connections - WHERE user_id = ? AND conn_type = ? AND contact_id IS NULL AND conn_level = 0 AND via_contact IS NULL - |] - (userId, ConnContact) - liftIO $ DB.execute db "DELETE FROM connections WHERE connection_id = ?" (Only connId) - pure conn + liftIO . withTransaction st $ \db -> + DB.execute + db + [sql| + DELETE FROM connections + WHERE user_id = ? + AND connection_id = ? + AND conn_type = ? + AND contact_id IS NULL + AND conn_level = 0 + AND via_contact IS NULL + |] + (userId, connId, ConnContact) toPendingContactConnection :: (Int64, ConnId, ConnStatus, Maybe ByteString, UTCTime, UTCTime) -> PendingContactConnection toPendingContactConnection (pccConnId, acId, pccConnStatus, connReqHash, createdAt, updatedAt) = diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index e8f064c2d5..fcb11f7f5f 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -684,6 +684,9 @@ data PendingContactConnection = PendingContactConnection } deriving (Eq, Show, Generic) +aConnId' :: PendingContactConnection -> ConnId +aConnId' PendingContactConnection {pccAgentConnId = AgentConnId cId} = cId + instance ToJSON PendingContactConnection where toEncoding = J.genericToEncoding J.defaultOptions data ConnStatus From b5022b4d41b871ed62f8f66b3b77781037e33e5d Mon Sep 17 00:00:00 2001 From: IanRDavies Date: Mon, 25 Apr 2022 11:05:09 +0100 Subject: [PATCH 20/95] android: use opensource boofcv to scan QR codes (#562) * open source qr code scanner * refined error localisation - todo moved * tidy up to make picking back up easier * better errors * scan images one at a time and once per second to avoid multiple requests being sent * more sophisticated avoidance of repeat contact requests * remove format specifier * refinements * update QR scanner to use boofcv * remove dependence on zxing * attribution * Update apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCodeScanner.kt Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/android/app/build.gradle | 6 +- .../chat/simplex/app/views/newchat/QRCode.kt | 20 +-- .../app/views/newchat/QRCodeScanner.kt | 128 ++++++++---------- 3 files changed, 70 insertions(+), 84 deletions(-) diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index b60f2e6840..62bf846734 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -90,9 +90,11 @@ dependencies { implementation "androidx.camera:camera-camera2:${camerax_version}" implementation "androidx.camera:camera-lifecycle:${camerax_version}" implementation "androidx.camera:camera-view:${camerax_version}" + //Barcode - implementation 'com.google.zxing:core:3.4.0' - implementation 'com.google.mlkit:barcode-scanning:17.0.2' + implementation 'org.boofcv:boofcv-android:0.40.1' + implementation 'org.boofcv:boofcv-core:0.40.1' + //Camera Permission implementation "com.google.accompanist:accompanist-permissions:0.23.0" diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCode.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCode.kt index f55489c7d7..d91e6d9200 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCode.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCode.kt @@ -1,18 +1,17 @@ package chat.simplex.app.views.newchat import android.graphics.Bitmap -import android.graphics.Color import androidx.compose.foundation.Image import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.tooling.preview.Preview +import boofcv.alg.fiducial.qrcode.QrCodeEncoder +import boofcv.alg.fiducial.qrcode.QrCodeGeneratorImage +import boofcv.android.ConvertBitmap import chat.simplex.app.R import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.helpers.generalGetString -import com.google.zxing.BarcodeFormat -import com.google.zxing.EncodeHintType -import com.google.zxing.qrcode.QRCodeWriter @Composable fun QRCode(connReq: String, modifier: Modifier = Modifier) { @@ -24,15 +23,10 @@ fun QRCode(connReq: String, modifier: Modifier = Modifier) { } fun qrCodeBitmap(content: String, size: Int): Bitmap { - val hints = hashMapOf().also { it[EncodeHintType.MARGIN] = 1 } - val bits = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints) - return Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565).also { - for (x in 0 until size) { - for (y in 0 until size) { - it.setPixel(x, y, if (bits[x, y]) Color.BLACK else Color.WHITE) - } - } - } + val qrCode = QrCodeEncoder().addAutomatic(content).fixate() + val renderer = QrCodeGeneratorImage(5) + renderer.render(qrCode) + return ConvertBitmap.grayToBitmap(renderer.gray, Bitmap.Config.RGB_565) } @Preview diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCodeScanner.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCodeScanner.kt index f6bee70490..5d594b98b3 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCodeScanner.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCodeScanner.kt @@ -1,5 +1,6 @@ package chat.simplex.app.views.newchat +import android.annotation.SuppressLint import android.util.Log import android.view.ViewGroup import androidx.camera.core.* @@ -10,21 +11,24 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat +import boofcv.abst.fiducial.QrCodeDetector +import boofcv.alg.color.ColorFormat +import boofcv.android.ConvertCameraImage +import boofcv.factory.fiducial.FactoryFiducial +import boofcv.struct.image.GrayU8 import chat.simplex.app.TAG import com.google.common.util.concurrent.ListenableFuture -import com.google.mlkit.vision.barcode.BarcodeScannerOptions -import com.google.mlkit.vision.barcode.BarcodeScanning -import com.google.mlkit.vision.barcode.common.Barcode -import com.google.mlkit.vision.common.InputImage import java.util.concurrent.* -// Bar code scanner adapted from https://github.com/MakeItEasyDev/Jetpack-Compose-BarCode-Scanner +// Adapted from learntodroid - https://gist.github.com/learntodroid/8f839be0b29d0378f843af70607bd7f5 @Composable fun QRCodeScanner(onBarcode: (String) -> Unit) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current var preview by remember { mutableStateOf(null) } + var lastAnalyzedTimeStamp = 0L + var contactLink = "" AndroidView( factory = { AndroidViewContext -> @@ -36,74 +40,60 @@ fun QRCodeScanner(onBarcode: (String) -> Unit) { ) implementationMode = PreviewView.ImplementationMode.COMPATIBLE } - }, -// modifier = Modifier.fillMaxSize(), - update = { previewView -> - val cameraSelector: CameraSelector = CameraSelector.Builder() - .requireLensFacing(CameraSelector.LENS_FACING_BACK) - .build() - val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor() - val cameraProviderFuture: ListenableFuture = - ProcessCameraProvider.getInstance(context) - - cameraProviderFuture.addListener({ - preview = Preview.Builder().build().also { - it.setSurfaceProvider(previewView.surfaceProvider) - } - val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get() - val barcodeAnalyser = BarCodeAnalyser { barcodes -> - barcodes.firstOrNull()?.rawValue?.let(onBarcode) - } - val imageAnalysis: ImageAnalysis = ImageAnalysis.Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .build() - .also { it.setAnalyzer(cameraExecutor, barcodeAnalyser) } - - try { - cameraProvider.unbindAll() - cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis) - } catch (e: Exception) { - Log.d(TAG, "CameraPreview: ${e.localizedMessage}") - } - }, ContextCompat.getMainExecutor(context)) } - ) -} + ) { previewView -> + val cameraSelector: CameraSelector = CameraSelector.Builder() + .requireLensFacing(CameraSelector.LENS_FACING_BACK) + .build() + val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor() + val cameraProviderFuture: ListenableFuture = + ProcessCameraProvider.getInstance(context) -class BarCodeAnalyser( - private val onBarcodeDetected: (barcodes: List) -> Unit, -): ImageAnalysis.Analyzer { - private var lastAnalyzedTimeStamp = 0L - - @ExperimentalGetImage - override fun analyze(image: ImageProxy) { - val currentTimestamp = System.currentTimeMillis() - if (currentTimestamp - lastAnalyzedTimeStamp >= TimeUnit.SECONDS.toMillis(1)) { - image.image?.let { imageToAnalyze -> - val options = BarcodeScannerOptions.Builder() - .setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS) - .build() - val barcodeScanner = BarcodeScanning.getClient(options) - val imageToProcess = InputImage.fromMediaImage(imageToAnalyze, image.imageInfo.rotationDegrees) - - barcodeScanner.process(imageToProcess) - .addOnSuccessListener { barcodes -> - if (barcodes.isNotEmpty()) { - onBarcodeDetected(barcodes) - } else { - Log.d(TAG, "BarcodeAnalyser: No barcode Scanned") + cameraProviderFuture.addListener({ + preview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get() + val detector: QrCodeDetector = FactoryFiducial.qrcode(null, GrayU8::class.java) + fun getQR(imageProxy: ImageProxy) { + val currentTimeStamp = System.currentTimeMillis() + if (currentTimeStamp - lastAnalyzedTimeStamp >= TimeUnit.SECONDS.toMillis(1)) { + detector.process(imageProxyToGrayU8(imageProxy)) + val found = detector.detections + val qr = found.firstOrNull() + if (qr != null) { + if (qr.message != contactLink) { + // Make sure link is new and not a repeat + contactLink = qr.message + onBarcode(contactLink) } } - .addOnFailureListener { exception -> - Log.e(TAG, "BarcodeAnalyser: Something went wrong $exception") - } - .addOnCompleteListener { - image.close() - } + } + imageProxy.close() } - lastAnalyzedTimeStamp = currentTimestamp - } else { - image.close() - } + val imageAnalyzer = ImageAnalysis.Analyzer { proxy -> getQR(proxy) } + val imageAnalysis: ImageAnalysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .setImageQueueDepth(1) + .build() + .also { it.setAnalyzer(cameraExecutor, imageAnalyzer) } + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis) + } catch (e: Exception) { + Log.d(TAG, "CameraPreview: ${e.localizedMessage}") + } + }, ContextCompat.getMainExecutor(context)) } } + +@SuppressLint("UnsafeOptInUsageError") +private fun imageProxyToGrayU8(img: ImageProxy) : GrayU8? { + val image = img.image + if (image != null) { + val outImg = GrayU8() + ConvertCameraImage.imageToBoof(image, ColorFormat.GRAY, outImg, null) + return outImg + } + return null +} From 5fc1364fd36272595da2d15eb2bb0b3b35cc659d Mon Sep 17 00:00:00 2001 From: IanRDavies Date: Mon, 25 Apr 2022 12:37:32 +0100 Subject: [PATCH 21/95] refactor to use stringResource in composables (#568) In order to retain the ability to have Previews in android studio we need to use stringResource over generalGetString in composable items. --- .../java/chat/simplex/app/model/SimpleXAPI.kt | 7 ++-- .../chat/simplex/app/views/WelcomeView.kt | 24 +++++++------ .../simplex/app/views/chat/ChatInfoView.kt | 11 +++--- .../chat/simplex/app/views/chat/ChatView.kt | 3 +- .../app/views/chat/ComposeImageView.kt | 5 ++- .../simplex/app/views/chat/ComposeView.kt | 5 ++- .../simplex/app/views/chat/ContextItemView.kt | 4 +-- .../simplex/app/views/chat/SendMsgView.kt | 6 ++-- .../app/views/chat/item/CIImageView.kt | 3 +- .../simplex/app/views/chat/item/CIMetaView.kt | 12 +++---- .../app/views/chat/item/ChatItemView.kt | 15 ++++---- .../app/views/chat/item/FramedItemView.kt | 6 ++-- .../views/chat/item/ImageFullScreenView.kt | 4 +-- .../app/views/chatlist/ChatHelpView.kt | 16 ++++----- .../app/views/chatlist/ChatListView.kt | 16 ++++----- .../app/views/chatlist/ChatPreviewView.kt | 11 +++--- .../app/views/chatlist/ContactRequestView.kt | 4 +-- .../app/views/helpers/ChatInfoImage.kt | 5 +-- .../app/views/helpers/CloseSheetBar.kt | 3 +- .../simplex/app/views/helpers/GetImageView.kt | 5 +-- .../simplex/app/views/helpers/LinkPreviews.kt | 7 ++-- .../chat/simplex/app/views/helpers/Util.kt | 2 ++ .../app/views/newchat/AddContactView.kt | 10 +++--- .../simplex/app/views/newchat/NewChatSheet.kt | 18 +++++----- .../app/views/newchat/PasteToConnect.kt | 7 ++-- .../chat/simplex/app/views/newchat/QRCode.kt | 4 +-- .../app/views/usersettings/HelpView.kt | 4 +-- .../views/usersettings/MarkdownHelpView.kt | 18 +++++----- .../app/views/usersettings/SMPServers.kt | 19 +++++----- .../app/views/usersettings/SettingsView.kt | 35 ++++++++++--------- .../app/views/usersettings/UserAddressView.kt | 13 +++---- .../app/views/usersettings/UserProfileView.kt | 19 +++++----- 32 files changed, 173 insertions(+), 148 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 6655a60a90..b38fcbe011 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -11,6 +11,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Bolt import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import chat.simplex.app.* @@ -448,9 +449,9 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt Row { Icon( Icons.Outlined.Bolt, - contentDescription = generalGetString(R.string.icon_descr_instant_notifications), + contentDescription = stringResource(R.string.icon_descr_instant_notifications), ) - Text(generalGetString(R.string.private_instant_notifications), fontWeight = FontWeight.Bold) + Text(stringResource(R.string.private_instant_notifications), fontWeight = FontWeight.Bold) } }, text = { @@ -463,7 +464,7 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt } }, confirmButton = { - Button(onClick = AlertManager.shared::hideAlert) { Text(generalGetString(R.string.ok)) } + Button(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) } } ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt index 25516fb10e..da3d6c07cf 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -17,7 +18,8 @@ import chat.simplex.app.R import chat.simplex.app.SimplexService import chat.simplex.app.model.ChatModel import chat.simplex.app.model.Profile -import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.helpers.getKeyboardState +import chat.simplex.app.views.helpers.withApi import com.google.accompanist.insets.ProvideWindowInsets import com.google.accompanist.insets.navigationBarsWithImePadding import kotlinx.coroutines.launch @@ -45,22 +47,22 @@ fun WelcomeView(chatModel: ChatModel) { ) { Image( painter = painterResource(R.drawable.logo), - contentDescription = generalGetString(R.string.image_descr_simplex_logo), + contentDescription = stringResource(R.string.image_descr_simplex_logo), modifier = Modifier.padding(vertical = 15.dp) ) Text( - generalGetString(R.string.you_control_your_chat), + stringResource(R.string.you_control_your_chat), style = MaterialTheme.typography.h4, color = MaterialTheme.colors.onBackground ) Text( - generalGetString(R.string.the_messaging_and_app_platform_protecting_your_privacy_and_security), + stringResource(R.string.the_messaging_and_app_platform_protecting_your_privacy_and_security), style = MaterialTheme.typography.body1, color = MaterialTheme.colors.onBackground ) Spacer(Modifier.height(8.dp)) Text( - generalGetString(R.string.we_do_not_store_contacts_or_messages_on_servers), + stringResource(R.string.we_do_not_store_contacts_or_messages_on_servers), style = MaterialTheme.typography.body1, color = MaterialTheme.colors.onBackground ) @@ -93,19 +95,19 @@ fun CreateProfilePanel(chatModel: ChatModel) { modifier=Modifier.fillMaxSize() ) { Text( - generalGetString(R.string.create_profile), + stringResource(R.string.create_profile), style = MaterialTheme.typography.h4, color = MaterialTheme.colors.onBackground, modifier = Modifier.padding(vertical = 5.dp) ) Text( - generalGetString(R.string.your_profile_is_stored_on_your_decide_and_shared_only_with_your_contacts), + stringResource(R.string.your_profile_is_stored_on_your_decide_and_shared_only_with_your_contacts), style = MaterialTheme.typography.body1, color = MaterialTheme.colors.onBackground ) Spacer(Modifier.height(10.dp)) Text( - generalGetString(R.string.display_name), + stringResource(R.string.display_name), style = MaterialTheme.typography.h6, color = MaterialTheme.colors.onBackground, modifier = Modifier.padding(bottom = 3.dp) @@ -127,7 +129,7 @@ fun CreateProfilePanel(chatModel: ChatModel) { ), singleLine = true ) - val errorText = if(!isValidDisplayName(displayName)) generalGetString(R.string.display_name_cannot_contain_whitespace) else "" + val errorText = if(!isValidDisplayName(displayName)) stringResource(R.string.display_name_cannot_contain_whitespace) else "" Text( errorText, @@ -137,7 +139,7 @@ fun CreateProfilePanel(chatModel: ChatModel) { Spacer(Modifier.height(3.dp)) Text( - generalGetString(R.string.full_name_optional__prompt), + stringResource(R.string.full_name_optional__prompt), style = MaterialTheme.typography.h6, color = MaterialTheme.colors.onBackground, modifier = Modifier.padding(bottom = 5.dp) @@ -171,6 +173,6 @@ fun CreateProfilePanel(chatModel: ChatModel) { } }, enabled = (displayName.isNotEmpty() && isValidDisplayName(displayName)) - ) { Text(generalGetString(R.string.create_profile_button)) } + ) { Text(stringResource(R.string.create_profile_button)) } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt index d37c752cbe..0faae49c64 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -97,7 +98,7 @@ fun ChatInfoLayout(chat: Chat, close: () -> Unit, deleteContact: () -> Unit) { Box(Modifier.padding(48.dp)) { SimpleButton( - generalGetString(R.string.button_delete_contact), + stringResource(R.string.button_delete_contact), icon = Icons.Outlined.Delete, color = Color.Red, click = deleteContact @@ -112,13 +113,13 @@ fun ServerImage(chat: Chat) { val status = chat.serverInfo.networkStatus when { status is Chat.NetworkStatus.Connected -> - Icon(Icons.Filled.Circle, generalGetString(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant) + Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant) status is Chat.NetworkStatus.Disconnected -> - Icon(Icons.Filled.Pending, generalGetString(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight) + Icon(Icons.Filled.Pending, stringResource(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight) status is Chat.NetworkStatus.Error -> - Icon(Icons.Filled.Error, generalGetString(R.string.icon_descr_server_status_error), tint = HighOrLowlight) + Icon(Icons.Filled.Error, stringResource(R.string.icon_descr_server_status_error), tint = HighOrLowlight) else -> - Icon(Icons.Outlined.Circle, generalGetString(R.string.icon_descr_server_status_pending), tint = HighOrLowlight) + Icon(Icons.Outlined.Circle, stringResource(R.string.icon_descr_server_status_pending), tint = HighOrLowlight) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index ed5b7c8f6b..6b23da0b21 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -246,7 +247,7 @@ fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) { IconButton(onClick = back) { Icon( Icons.Outlined.ArrowBackIos, - generalGetString(R.string.back), + stringResource(R.string.back), tint = MaterialTheme.colors.primary, modifier = Modifier.padding(10.dp) ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt index 4b42cc0256..01c60ecc8b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt @@ -8,12 +8,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import chat.simplex.app.R import chat.simplex.app.views.chat.item.SentColorLight import chat.simplex.app.views.helpers.base64ToBitmap -import chat.simplex.app.views.helpers.generalGetString @Composable fun ComposeImageView(image: String, cancelImage: () -> Unit) { @@ -37,7 +36,7 @@ fun ComposeImageView(image: String, cancelImage: () -> Unit) { IconButton(onClick = cancelImage, modifier = Modifier.padding(0.dp)) { Icon( Icons.Outlined.Close, - contentDescription = generalGetString(R.string.icon_descr_cancel_image_preview), + contentDescription = stringResource(R.string.icon_descr_cancel_image_preview), tint = MaterialTheme.colors.primary, modifier = Modifier.padding(10.dp) ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index 425837802c..d0dea0aad8 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -8,18 +8,17 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.icons.Icons -import androidx.compose.foundation.layout.Column import androidx.compose.material.icons.filled.AttachFile import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.views.helpers.ComposeLinkView -import chat.simplex.app.views.helpers.generalGetString // TODO ComposeState @Composable @@ -75,7 +74,7 @@ fun ComposeView( Box(Modifier.padding(bottom = 12.dp)) { Icon( Icons.Filled.AttachFile, - contentDescription = generalGetString(R.string.attach), + contentDescription = stringResource(R.string.attach), tint = if (editingItem.value == null) MaterialTheme.colors.primary else Color.Gray, modifier = Modifier .size(28.dp) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt index 8a16d670ec..67581d81bd 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt @@ -8,6 +8,7 @@ import androidx.compose.material.icons.outlined.Close import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview @@ -17,7 +18,6 @@ import chat.simplex.app.model.CIDirection import chat.simplex.app.model.ChatItem import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chat.item.* -import chat.simplex.app.views.helpers.generalGetString import kotlinx.datetime.Clock @Composable @@ -52,7 +52,7 @@ fun ContextItemView( }) { Icon( Icons.Outlined.Close, - contentDescription = generalGetString(R.string.cancel_verb), + contentDescription = stringResource(R.string.cancel_verb), tint = MaterialTheme.colors.primary, modifier = Modifier.padding(10.dp) ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index c81b99e295..32e8aa8b4e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -25,7 +26,8 @@ import chat.simplex.app.model.* import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chat.item.* -import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.helpers.getLinkPreview +import chat.simplex.app.views.helpers.withApi import kotlinx.coroutines.delay @Composable @@ -128,7 +130,7 @@ fun SendMsgView( val color = if (sendEnabled) MaterialTheme.colors.primary else Color.Gray Icon( if (editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward, - generalGetString(R.string.icon_descr_send_message), + stringResource(R.string.icon_descr_send_message), tint = Color.White, modifier = Modifier .size(36.dp) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt index cc6aae2ab1..2f24e9f249 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import chat.simplex.app.R import chat.simplex.app.model.CIFile @@ -25,7 +26,7 @@ fun CIImageView( } Image( imageBitmap.asImageBitmap(), - contentDescription = generalGetString(R.string.image_descr), + contentDescription = stringResource(R.string.image_descr), // .width(1000.dp) is a hack for image to increase IntrinsicSize of FramedItemView // if text is short and take all available width if text is long modifier = Modifier diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt index 7e67d33664..126b477e80 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -16,7 +17,6 @@ import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimplexBlue -import chat.simplex.app.views.helpers.generalGetString import kotlinx.datetime.Clock @Composable @@ -27,7 +27,7 @@ fun CIMetaView(chatItem: ChatItem, metaColor: Color = HighOrLowlight) { Icon( Icons.Filled.Edit, modifier = Modifier.height(12.dp).padding(end = 1.dp), - contentDescription = generalGetString(R.string.icon_descr_edited), + contentDescription = stringResource(R.string.icon_descr_edited), tint = metaColor, ) } @@ -47,16 +47,16 @@ fun CIMetaView(chatItem: ChatItem, metaColor: Color = HighOrLowlight) { fun CIStatusView(status: CIStatus, metaColor: Color = HighOrLowlight) { when (status) { is CIStatus.SndSent -> { - Icon(Icons.Filled.Check, generalGetString(R.string.icon_descr_sent_msg_status_sent), Modifier.height(12.dp), tint = metaColor) + Icon(Icons.Filled.Check, stringResource(R.string.icon_descr_sent_msg_status_sent), Modifier.height(12.dp), tint = metaColor) } is CIStatus.SndErrorAuth -> { - Icon(Icons.Filled.Close, generalGetString(R.string.icon_descr_sent_msg_status_unauthorized_send), Modifier.height(12.dp), tint = Color.Red) + Icon(Icons.Filled.Close, stringResource(R.string.icon_descr_sent_msg_status_unauthorized_send), Modifier.height(12.dp), tint = Color.Red) } is CIStatus.SndError -> { - Icon(Icons.Filled.WarningAmber, generalGetString(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = Color.Yellow) + Icon(Icons.Filled.WarningAmber, stringResource(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = Color.Yellow) } is CIStatus.RcvNew -> { - Icon(Icons.Filled.Circle, generalGetString(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = SimplexBlue) + Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = SimplexBlue) } else -> {} } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index c52b99e1f7..79c487c879 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import chat.simplex.app.R @@ -59,21 +60,21 @@ fun ChatItemView( onDismissRequest = { showMenu.value = false }, Modifier.width(220.dp) ) { - ItemAction(generalGetString(R.string.reply_verb), Icons.Outlined.Reply, onClick = { + ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = { editingItem.value = null quotedItem.value = cItem showMenu.value = false }) - ItemAction(generalGetString(R.string.share_verb), Icons.Outlined.Share, onClick = { + ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = { shareText(cxt, cItem.content.text) showMenu.value = false }) - ItemAction(generalGetString(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = { + ItemAction(stringResource(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = { copyText(cxt, cItem.content.text) showMenu.value = false }) if (cItem.chatDir.sent && cItem.meta.editable) { - ItemAction(generalGetString(R.string.edit_verb), Icons.Filled.Edit, onClick = { + ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = { quotedItem.value = null editingItem.value = cItem msg.value = cItem.content.text @@ -81,7 +82,7 @@ fun ChatItemView( }) } ItemAction( - generalGetString(R.string.delete_verb), + stringResource(R.string.delete_verb), Icons.Outlined.Delete, onClick = { showMenu.value = false @@ -125,13 +126,13 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteM Button(onClick = { deleteMessage(chatItem.id, CIDeleteMode.cidmInternal) AlertManager.shared.hideAlert() - }) { Text(generalGetString(R.string.for_me_only)) } + }) { Text(stringResource(R.string.for_me_only)) } if (chatItem.meta.editable) { Spacer(Modifier.padding(horizontal = 4.dp)) Button(onClick = { deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast) AlertManager.shared.hideAlert() - }) { Text(generalGetString(R.string.for_everybody)) } + }) { Text(stringResource(R.string.for_everybody)) } } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt index a75194f97d..282b879c2c 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.* import androidx.compose.ui.unit.dp @@ -22,7 +23,8 @@ import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.helpers.ChatItemLinkView +import chat.simplex.app.views.helpers.base64ToBitmap import kotlinx.datetime.Clock val SentColorLight = Color(0x1E45B8FF) @@ -67,7 +69,7 @@ fun FramedItemView( val imageBitmap = base64ToBitmap(qi.content.image).asImageBitmap() Image( imageBitmap, - contentDescription = generalGetString(R.string.image_descr), + contentDescription = stringResource(R.string.image_descr), contentScale = ContentScale.Crop, modifier = Modifier .size(60.dp) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt index bf74ab05ce..3f981e1de7 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt @@ -7,8 +7,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource import chat.simplex.app.R -import chat.simplex.app.views.helpers.generalGetString @Composable fun ImageFullScreenView(imageBitmap: Bitmap, close: () -> Unit) { @@ -21,7 +21,7 @@ fun ImageFullScreenView(imageBitmap: Bitmap, close: () -> Unit) { ) { Image( imageBitmap.asImageBitmap(), - contentDescription = generalGetString(R.string.image_descr), + contentDescription = stringResource(R.string.image_descr), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Fit, ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt index aa03a7d0cf..ff10bbbfeb 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview @@ -18,7 +19,6 @@ import androidx.compose.ui.unit.sp import chat.simplex.app.R import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.helpers.annotatedStringResource -import chat.simplex.app.views.helpers.generalGetString import chat.simplex.app.views.usersettings.simplexTeamUri val bold = SpanStyle(fontWeight = FontWeight.Bold) @@ -31,7 +31,7 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) { ) { val uriHandler = LocalUriHandler.current - Text(generalGetString(R.string.thank_you_for_installing_simplex), lineHeight = 22.sp) + Text(stringResource(R.string.thank_you_for_installing_simplex), lineHeight = 22.sp) Text( annotatedStringResource(R.string.you_can_connect_to_simplex_chat_founder), modifier = Modifier.clickable(onClick = { @@ -46,7 +46,7 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) { verticalArrangement = Arrangement.spacedBy(10.dp) ) { Text( - generalGetString(R.string.to_start_a_new_chat_help_header), + stringResource(R.string.to_start_a_new_chat_help_header), style = MaterialTheme.typography.h2, lineHeight = 22.sp ) @@ -54,13 +54,13 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Text(generalGetString(R.string.chat_help_tap_button)) + Text(stringResource(R.string.chat_help_tap_button)) Icon( Icons.Outlined.PersonAdd, - generalGetString(R.string.add_contact), + stringResource(R.string.add_contact), modifier = if (addContact != null) Modifier.clickable(onClick = addContact) else Modifier, ) - Text(generalGetString(R.string.above_then_preposition_continuation)) + Text(stringResource(R.string.above_then_preposition_continuation)) } Text(annotatedStringResource(R.string.add_new_contact_to_create_one_time_QR_code), lineHeight = 22.sp) Text(annotatedStringResource(R.string.scan_QR_code_to_connect_to_contact_who_shows_QR_code), lineHeight = 22.sp) @@ -71,8 +71,8 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) { horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(10.dp) ) { - Text(generalGetString(R.string.to_connect_via_link_title), style = MaterialTheme.typography.h2) - Text(generalGetString(R.string.if_you_received_simplex_invitation_link_you_can_open_in_browser), lineHeight = 22.sp) + Text(stringResource(R.string.to_connect_via_link_title), style = MaterialTheme.typography.h2) + Text(stringResource(R.string.if_you_received_simplex_invitation_link_you_can_open_in_browser), lineHeight = 22.sp) Text(annotatedStringResource(R.string.desktop_scan_QR_code_from_app_via_scan_QR_code), lineHeight = 22.sp) Text(annotatedStringResource(R.string.mobile_tap_open_in_mobile_app_then_tap_connect_in_app), lineHeight = 22.sp) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt index c6fffb927e..df2a1ee8ef 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import chat.simplex.app.R @@ -19,7 +20,6 @@ import chat.simplex.app.model.ChatModel import chat.simplex.app.ui.theme.ToolbarDark import chat.simplex.app.ui.theme.ToolbarLight import chat.simplex.app.views.helpers.ModalManager -import chat.simplex.app.views.helpers.generalGetString import chat.simplex.app.views.newchat.NewChatSheet import chat.simplex.app.views.usersettings.SettingsView import kotlinx.coroutines.CoroutineScope @@ -113,8 +113,8 @@ fun Help(scaffoldCtrl: ScaffoldController, displayName: String?) { .padding(16.dp) ) { val welcomeMsg = if (displayName != null) { - String.format(generalGetString(R.string.personal_welcome), displayName) - } else generalGetString(R.string.welcome) + String.format(stringResource(R.string.personal_welcome), displayName) + } else stringResource(R.string.welcome) Text( text = welcomeMsg, Modifier.padding(bottom = 24.dp), @@ -128,12 +128,12 @@ fun Help(scaffoldCtrl: ScaffoldController, displayName: String?) { horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - generalGetString(R.string.this_text_is_available_in_settings), + stringResource(R.string.this_text_is_available_in_settings), color = MaterialTheme.colors.onBackground ) Icon( Icons.Outlined.Settings, - generalGetString(R.string.icon_descr_settings), + stringResource(R.string.icon_descr_settings), tint = MaterialTheme.colors.onBackground, modifier = Modifier.clickable(onClick = { scaffoldCtrl.toggleDrawer() }) ) @@ -155,13 +155,13 @@ fun ChatListToolbar(scaffoldCtrl: ScaffoldController) { IconButton(onClick = { scaffoldCtrl.toggleDrawer() }) { Icon( Icons.Outlined.Menu, - generalGetString(R.string.icon_descr_settings), + stringResource(R.string.icon_descr_settings), tint = MaterialTheme.colors.primary, modifier = Modifier.padding(10.dp) ) } Text( - generalGetString(R.string.your_chats), + stringResource(R.string.your_chats), color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(5.dp) @@ -169,7 +169,7 @@ fun ChatListToolbar(scaffoldCtrl: ScaffoldController) { IconButton(onClick = { scaffoldCtrl.toggleSheet() }) { Icon( Icons.Outlined.PersonAdd, - generalGetString(R.string.add_contact), + stringResource(R.string.add_contact), tint = MaterialTheme.colors.primary, modifier = Modifier.padding(10.dp) ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt index f71a0ff00b..80e3713c34 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -18,9 +19,11 @@ import androidx.compose.ui.unit.sp import chat.simplex.app.R import chat.simplex.app.model.Chat import chat.simplex.app.model.getTimestampText -import chat.simplex.app.ui.theme.* +import chat.simplex.app.ui.theme.HighOrLowlight +import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chat.item.MarkdownText -import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.helpers.ChatInfoImage +import chat.simplex.app.views.helpers.badgeLayout @Composable fun ChatPreviewView(chat: Chat) { @@ -50,7 +53,7 @@ fun ChatPreviewView(chat: Chat) { ) } } else { - Text(generalGetString(R.string.contact_connection_pending), color = HighOrLowlight) + Text(stringResource(R.string.contact_connection_pending), color = HighOrLowlight) } } val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.createdAt) @@ -67,7 +70,7 @@ fun ChatPreviewView(chat: Chat) { val n = chat.chatStats.unreadCount if (n > 0) { Text( - if (n < 1000) "$n" else "${n / 1000}" + generalGetString(R.string.thousand_abbreviation), + if (n < 1000) "$n" else "${n / 1000}" + stringResource(R.string.thousand_abbreviation), color = MaterialTheme.colors.onPrimary, fontSize = 14.sp, modifier = Modifier diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt index 08a7b46795..3866fc0123 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt @@ -5,6 +5,7 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -13,7 +14,6 @@ import chat.simplex.app.model.Chat import chat.simplex.app.model.getTimestampText import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.views.helpers.ChatInfoImage -import chat.simplex.app.views.helpers.generalGetString @Composable fun ContactRequestView(chat: Chat) { @@ -33,7 +33,7 @@ fun ContactRequestView(chat: Chat) { color = MaterialTheme.colors.primary ) Text( - generalGetString(R.string.contact_wants_to_connect_with_you), + stringResource(R.string.contact_wants_to_connect_with_you), maxLines = 2, overflow = TextOverflow.Ellipsis ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt index 3b92c1bf13..8f57b8f151 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -40,7 +41,7 @@ fun ProfileImage( if (image == null) { Icon( icon, - contentDescription = generalGetString(R.string.icon_descr_profile_image_placeholder), + contentDescription = stringResource(R.string.icon_descr_profile_image_placeholder), tint = MaterialTheme.colors.secondary, modifier = Modifier.fillMaxSize() ) @@ -48,7 +49,7 @@ fun ProfileImage( val imageBitmap = base64ToBitmap(image).asImageBitmap() Image( imageBitmap, - generalGetString(R.string.image_descr_profile_image), + stringResource(R.string.image_descr_profile_image), contentScale = ContentScale.Crop, modifier = Modifier.size(size).padding(size / 12).clip(CircleShape) ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt index 2577ae9251..c0de98ad65 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt @@ -8,6 +8,7 @@ import androidx.compose.material.icons.outlined.Close import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import chat.simplex.app.R @@ -25,7 +26,7 @@ fun CloseSheetBar(close: () -> Unit) { IconButton(onClick = close) { Icon( Icons.Outlined.Close, - generalGetString(R.string.icon_descr_close_button), + stringResource(R.string.icon_descr_close_button), tint = MaterialTheme.colors.primary, modifier = Modifier.padding(10.dp) ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt index fa8971a071..301a56aac3 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.content.FileProvider @@ -189,7 +190,7 @@ fun GetImageBottomSheet( .padding(horizontal = 8.dp, vertical = 30.dp), horizontalArrangement = Arrangement.SpaceEvenly ) { - ActionButton(null, generalGetString(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) { + ActionButton(null, stringResource(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) { when (PackageManager.PERMISSION_GRANTED) { ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> { cameraLauncher.launch(null) @@ -201,7 +202,7 @@ fun GetImageBottomSheet( } } } - ActionButton(null, generalGetString(R.string.from_gallery_button), icon = Icons.Outlined.Collections) { + ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Collections) { when (PackageManager.PERMISSION_GRANTED) { ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) -> { galleryLauncher.launch("image/*") diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt index c078adc830..e4afa68202 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -73,7 +74,7 @@ fun ComposeLinkView(linkPreview: LinkPreview, cancelPreview: () -> Unit) { val imageBitmap = base64ToBitmap(linkPreview.image).asImageBitmap() Image( imageBitmap, - generalGetString(R.string.image_descr_link_preview), + stringResource(R.string.image_descr_link_preview), modifier = Modifier.width(80.dp).height(60.dp).padding(end = 8.dp) ) Column(Modifier.fillMaxWidth().weight(1F)) { @@ -86,7 +87,7 @@ fun ComposeLinkView(linkPreview: LinkPreview, cancelPreview: () -> Unit) { IconButton(onClick = cancelPreview, modifier = Modifier.padding(0.dp)) { Icon( Icons.Outlined.Close, - contentDescription = generalGetString(R.string.icon_descr_cancel_link_preview), + contentDescription = stringResource(R.string.icon_descr_cancel_link_preview), tint = MaterialTheme.colors.primary, modifier = Modifier.padding(10.dp) ) @@ -99,7 +100,7 @@ fun ChatItemLinkView(linkPreview: LinkPreview) { Column { Image( base64ToBitmap(linkPreview.image).asImageBitmap(), - generalGetString(R.string.image_descr_link_preview), + stringResource(R.string.image_descr_link_preview), modifier = Modifier.fillMaxWidth(), contentScale = ContentScale.FillWidth, ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt index 15d6e1863b..20a682488b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt @@ -57,9 +57,11 @@ fun getKeyboardState(): State { return keyboardState } + // Resource to annotated string from // https://stackoverflow.com/questions/68549248/android-jetpack-compose-how-to-show-styled-text-from-string-resources fun generalGetString(id: Int): String { + // prefer stringResource in Composable items to retain preview abilities return SimplexApp.context.getString(id) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt index c23c6521ca..0340693610 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -19,7 +20,6 @@ import chat.simplex.app.R import chat.simplex.app.model.ChatModel import chat.simplex.app.ui.theme.SimpleButton import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.views.helpers.generalGetString import chat.simplex.app.views.helpers.shareText @Composable @@ -43,11 +43,11 @@ fun AddContactLayout(connReq: String, share: () -> Unit) { verticalArrangement = Arrangement.SpaceBetween, ) { Text( - generalGetString(R.string.add_contact), + stringResource(R.string.add_contact), style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), ) Text( - generalGetString(R.string.show_QR_code_for_your_contact_to_scan_from_the_app__multiline), + stringResource(R.string.show_QR_code_for_your_contact_to_scan_from_the_app__multiline), style = MaterialTheme.typography.h3, textAlign = TextAlign.Center, ) @@ -58,14 +58,14 @@ fun AddContactLayout(connReq: String, share: () -> Unit) { .padding(vertical = 3.dp) ) Text( - generalGetString(R.string.if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel), + stringResource(R.string.if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel), textAlign = TextAlign.Center, lineHeight = 22.sp, modifier = Modifier .padding(horizontal = 16.dp) .padding(bottom = if (screenHeight > 600.dp) 16.dp else 8.dp) ) - SimpleButton(generalGetString(R.string.share_invitation_link), icon = Icons.Outlined.Share, click = share) + SimpleButton(stringResource(R.string.share_invitation_link), icon = Icons.Outlined.Share, click = share) Spacer(Modifier.height(10.dp)) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt index b89c91fcff..514658944e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -20,7 +21,8 @@ import chat.simplex.app.model.ChatModel import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chatlist.ScaffoldController -import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.helpers.ModalManager +import chat.simplex.app.views.helpers.withApi import com.google.accompanist.permissions.rememberPermissionState @Composable @@ -56,7 +58,7 @@ fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit, pasteLink: horizontalAlignment = Alignment.CenterHorizontally ) { Text( - generalGetString(R.string.add_contact_to_start_new_chat), + stringResource(R.string.add_contact_to_start_new_chat), modifier = Modifier.padding(horizontal = 8.dp).padding(top = 32.dp) ) Row( @@ -71,8 +73,8 @@ fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit, pasteLink: .weight(1F) .fillMaxWidth()) { ActionButton( - generalGetString(R.string.create_one_time_link), - generalGetString(R.string.to_share_with_your_contact), + stringResource(R.string.create_one_time_link), + stringResource(R.string.to_share_with_your_contact), Icons.Outlined.PersonAdd, click = addContact ) @@ -82,8 +84,8 @@ fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit, pasteLink: .weight(1F) .fillMaxWidth()) { ActionButton( - generalGetString(R.string.paste_received_link), - generalGetString(R.string.paste_received_link_from_clipboard), + stringResource(R.string.paste_received_link), + stringResource(R.string.paste_received_link_from_clipboard), Icons.Outlined.Link, click = pasteLink ) @@ -93,8 +95,8 @@ fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit, pasteLink: .weight(1F) .fillMaxWidth()) { ActionButton( - generalGetString(R.string.scan_QR_code), - generalGetString(R.string.in_person_or_in_video_call__bracketed), + stringResource(R.string.scan_QR_code), + stringResource(R.string.in_person_or_in_video_call__bracketed), Icons.Outlined.QrCode, click = scanCode ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/PasteToConnect.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/PasteToConnect.kt index 78775ef1e5..6e9372dcc2 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/PasteToConnect.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/PasteToConnect.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -65,12 +66,12 @@ fun PasteToConnectLayout( verticalArrangement = Arrangement.SpaceBetween, ) { Text( - generalGetString(R.string.connect_via_link), + stringResource(R.string.connect_via_link), style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), modifier = Modifier.padding(bottom = 16.dp) ) - Text(generalGetString(R.string.paste_connection_link_below_to_connect)) - Text(generalGetString(R.string.profile_will_be_sent_to_contact_sending_link)) + Text(stringResource(R.string.paste_connection_link_below_to_connect)) + Text(stringResource(R.string.profile_will_be_sent_to_contact_sending_link)) Box(Modifier.padding(top = 16.dp, bottom = 6.dp)) { TextEditor(Modifier.height(180.dp), text = connectionLink) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCode.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCode.kt index d91e6d9200..b0f5a0c718 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCode.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCode.kt @@ -5,19 +5,19 @@ import androidx.compose.foundation.Image import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import boofcv.alg.fiducial.qrcode.QrCodeEncoder import boofcv.alg.fiducial.qrcode.QrCodeGeneratorImage import boofcv.android.ConvertBitmap import chat.simplex.app.R import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.views.helpers.generalGetString @Composable fun QRCode(connReq: String, modifier: Modifier = Modifier) { Image( bitmap = qrCodeBitmap(connReq, 1024).asImageBitmap(), - contentDescription = generalGetString(R.string.image_descr_qr_code), + contentDescription = stringResource(R.string.image_descr_qr_code), modifier = modifier ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HelpView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HelpView.kt index eca076a6d4..5400aa0aa9 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HelpView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HelpView.kt @@ -10,13 +10,13 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import chat.simplex.app.R import chat.simplex.app.model.ChatModel import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chatlist.ChatHelpView -import chat.simplex.app.views.helpers.generalGetString @Composable fun HelpView(chatModel: ChatModel) { @@ -35,7 +35,7 @@ fun HelpLayout(displayName: String) { horizontalAlignment = Alignment.Start ){ Text( - String.format(generalGetString(R.string.personal_welcome), displayName), + String.format(stringResource(R.string.personal_welcome), displayName), Modifier.padding(bottom = 24.dp), style = MaterialTheme.typography.h1, ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt index b1cb3e68f2..6493b90bcc 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt @@ -7,6 +7,7 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.* import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -14,25 +15,24 @@ import chat.simplex.app.R import chat.simplex.app.model.Format import chat.simplex.app.model.FormatColor import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.views.helpers.generalGetString @Composable fun MarkdownHelpView() { Column { Text( - generalGetString(R.string.how_to_use_markdown), + stringResource(R.string.how_to_use_markdown), style = MaterialTheme.typography.h1, ) Text( - generalGetString(R.string.you_can_use_markdown_to_format_messages__prompt), + stringResource(R.string.you_can_use_markdown_to_format_messages__prompt), Modifier.padding(vertical = 16.dp) ) - val bold = generalGetString(R.string.bold) - val italic = generalGetString(R.string.italic) - val strikethrough = generalGetString(R.string.strikethrough) - val equation = generalGetString(R.string.a_plus_b) - val colored = generalGetString(R.string.colored) - val secret = generalGetString(R.string.secret) + val bold = stringResource(R.string.bold) + val italic = stringResource(R.string.italic) + val strikethrough = stringResource(R.string.strikethrough) + val equation = stringResource(R.string.a_plus_b) + val colored = stringResource(R.string.colored) + val secret = stringResource(R.string.secret) MdFormat("*$bold*", bold, Format.Bold()) MdFormat("_${italic}_", italic, Format.Italic()) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt index 899e2191f6..84b51dc91b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview @@ -102,14 +103,14 @@ fun SMPServersLayout( verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - generalGetString(R.string.your_SMP_servers), + stringResource(R.string.your_SMP_servers), Modifier.padding(bottom = 24.dp), style = MaterialTheme.typography.h1 ) Row( verticalAlignment = Alignment.CenterVertically ) { - Text(generalGetString(R.string.configure_SMP_servers), Modifier.padding(end = 24.dp)) + Text(stringResource(R.string.configure_SMP_servers), Modifier.padding(end = 24.dp)) Switch( checked = isUserSMPServers, onCheckedChange = isUserSMPServersOnOff, @@ -121,9 +122,9 @@ fun SMPServersLayout( } if (!isUserSMPServers) { - Text(generalGetString(R.string.using_simplex_chat_servers), lineHeight = 22.sp) + Text(stringResource(R.string.using_simplex_chat_servers), lineHeight = 22.sp) } else { - Text(generalGetString(R.string.enter_one_SMP_server_per_line)) + Text(stringResource(R.string.enter_one_SMP_server_per_line)) if (editSMPServers) { TextEditor(Modifier.height(160.dp), text = userSMPServersStr) @@ -135,14 +136,14 @@ fun SMPServersLayout( Column(horizontalAlignment = Alignment.Start) { Row { Text( - generalGetString(R.string.cancel_verb), + stringResource(R.string.cancel_verb), color = MaterialTheme.colors.primary, modifier = Modifier .clickable(onClick = cancelEdit) ) Spacer(Modifier.padding(horizontal = 8.dp)) Text( - generalGetString(R.string.save_servers_button), + stringResource(R.string.save_servers_button), color = MaterialTheme.colors.primary, modifier = Modifier.clickable(onClick = { val servers = userSMPServersStr.value.split("\n") @@ -181,7 +182,7 @@ fun SMPServersLayout( ) { Column(horizontalAlignment = Alignment.Start) { Text( - generalGetString(R.string.edit_verb), + stringResource(R.string.edit_verb), color = MaterialTheme.colors.primary, modifier = Modifier .clickable(onClick = editOn) @@ -203,9 +204,9 @@ fun howToButton() { verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { uriHandler.openUri("https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent") } ) { - Text(generalGetString(R.string.how_to), color = MaterialTheme.colors.primary) + Text(stringResource(R.string.how_to), color = MaterialTheme.colors.primary) Icon( - Icons.Outlined.OpenInNew, generalGetString(R.string.how_to), tint = MaterialTheme.colors.primary, + Icons.Outlined.OpenInNew, stringResource(R.string.how_to), tint = MaterialTheme.colors.primary, modifier = Modifier.padding(horizontal = 5.dp) ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt index a99a49c99b..cd3cd897f0 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -69,7 +70,7 @@ fun SettingsLayout( .padding(top = 16.dp) ) { Text( - generalGetString(R.string.your_settings), + stringResource(R.string.your_settings), style = MaterialTheme.typography.h1, modifier = Modifier.padding(start = 8.dp) ) @@ -91,39 +92,39 @@ fun SettingsLayout( SettingsSectionView(showModal { UserAddressView(it) }) { Icon( Icons.Outlined.QrCode, - contentDescription = generalGetString(R.string.icon_descr_address), + contentDescription = stringResource(R.string.icon_descr_address), ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text(generalGetString(R.string.your_simplex_contact_address)) + Text(stringResource(R.string.your_simplex_contact_address)) } Spacer(Modifier.height(24.dp)) SettingsSectionView(showModal { HelpView(it) }) { Icon( Icons.Outlined.HelpOutline, - contentDescription = generalGetString(R.string.icon_descr_help), + contentDescription = stringResource(R.string.icon_descr_help), ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text(generalGetString(R.string.how_to_use_simplex_chat)) + Text(stringResource(R.string.how_to_use_simplex_chat)) } Divider(Modifier.padding(horizontal = 8.dp)) SettingsSectionView(showModal { MarkdownHelpView() }) { Icon( Icons.Outlined.TextFormat, - contentDescription = generalGetString(R.string.markdown_help), + contentDescription = stringResource(R.string.markdown_help), ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text(generalGetString(R.string.markdown_in_messages)) + Text(stringResource(R.string.markdown_in_messages)) } Divider(Modifier.padding(horizontal = 8.dp)) SettingsSectionView({ uriHandler.openUri(simplexTeamUri) }) { Icon( Icons.Outlined.Tag, - contentDescription = generalGetString(R.string.icon_descr_simplex_team), + contentDescription = stringResource(R.string.icon_descr_simplex_team), ) Spacer(Modifier.padding(horizontal = 4.dp)) Text( - generalGetString(R.string.chat_with_the_founder), + stringResource(R.string.chat_with_the_founder), color = MaterialTheme.colors.primary ) } @@ -131,11 +132,11 @@ fun SettingsLayout( SettingsSectionView({ uriHandler.openUri("mailto:chat@simplex.chat") }) { Icon( Icons.Outlined.Email, - contentDescription = generalGetString(R.string.icon_descr_email), + contentDescription = stringResource(R.string.icon_descr_email), ) Spacer(Modifier.padding(horizontal = 4.dp)) Text( - generalGetString(R.string.send_us_an_email), + stringResource(R.string.send_us_an_email), color = MaterialTheme.colors.primary ) } @@ -144,20 +145,20 @@ fun SettingsLayout( SettingsSectionView(showModal { SMPServersView(it) }) { Icon( Icons.Outlined.Dns, - contentDescription = generalGetString(R.string.smp_servers), + contentDescription = stringResource(R.string.smp_servers), ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text(generalGetString(R.string.smp_servers)) + Text(stringResource(R.string.smp_servers)) } Divider(Modifier.padding(horizontal = 8.dp)) SettingsSectionView() { Icon( Icons.Outlined.Bolt, - contentDescription = generalGetString(R.string.private_notifications), + contentDescription = stringResource(R.string.private_notifications), ) Spacer(Modifier.padding(horizontal = 4.dp)) Text( - generalGetString(R.string.private_notifications), Modifier + stringResource(R.string.private_notifications), Modifier .padding(end = 24.dp) .fillMaxWidth() .weight(1F)) @@ -175,10 +176,10 @@ fun SettingsLayout( SettingsSectionView(showTerminal) { Icon( painter = painterResource(id = R.drawable.ic_outline_terminal), - contentDescription = generalGetString(R.string.chat_console), + contentDescription = stringResource(R.string.chat_console), ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text(generalGetString(R.string.chat_console)) + Text(stringResource(R.string.chat_console)) } Divider(Modifier.padding(horizontal = 8.dp)) SettingsSectionView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt index 734fa19a05..71e5b37033 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -60,12 +61,12 @@ fun UserAddressLayout( verticalArrangement = Arrangement.Top ) { Text( - generalGetString(R.string.your_chat_address), + stringResource(R.string.your_chat_address), Modifier.padding(bottom = 16.dp), style = MaterialTheme.typography.h1, ) Text( - generalGetString(R.string.you_can_share_your_address_anybody_will_be_able_to_connect), + stringResource(R.string.you_can_share_your_address_anybody_will_be_able_to_connect), Modifier.padding(bottom = 12.dp), lineHeight = 22.sp ) @@ -76,11 +77,11 @@ fun UserAddressLayout( ) { if (userAddress == null) { Text( - generalGetString(R.string.if_you_delete_address_you_wont_lose_contacts), + stringResource(R.string.if_you_delete_address_you_wont_lose_contacts), Modifier.padding(bottom = 12.dp), lineHeight = 22.sp ) - SimpleButton(generalGetString(R.string.create_address), icon = Icons.Outlined.QrCode, click = createAddress) + SimpleButton(stringResource(R.string.create_address), icon = Icons.Outlined.QrCode, click = createAddress) } else { QRCode(userAddress, Modifier.weight(1f, fill = false).aspectRatio(1f)) Row( @@ -89,11 +90,11 @@ fun UserAddressLayout( modifier = Modifier.padding(vertical = 10.dp) ) { SimpleButton( - generalGetString(R.string.share_link), + stringResource(R.string.share_link), icon = Icons.Outlined.Share, click = { share(userAddress) }) SimpleButton( - generalGetString(R.string.delete_address), + stringResource(R.string.delete_address), icon = Icons.Outlined.Delete, color = Color.Red, click = deleteAddress diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt index 8a663b36c9..60b99c620f 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview @@ -95,13 +96,13 @@ fun UserProfileLayout( horizontalAlignment = Alignment.Start ) { Text( - generalGetString(R.string.your_chat_profile), + stringResource(R.string.your_chat_profile), Modifier.padding(bottom = 24.dp), style = MaterialTheme.typography.h1, color = MaterialTheme.colors.onBackground ) Text( - generalGetString(R.string.your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it), + stringResource(R.string.your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it), Modifier.padding(bottom = 24.dp), color = MaterialTheme.colors.onBackground, lineHeight = 22.sp @@ -130,14 +131,14 @@ fun UserProfileLayout( ProfileNameTextField(displayName) ProfileNameTextField(fullName) Row { - TextButton(generalGetString(R.string.cancel_verb)) { + TextButton(stringResource(R.string.cancel_verb)) { displayName.value = profile.displayName fullName.value = profile.fullName profileImage.value = profile.image editProfile.value = false } Spacer(Modifier.padding(horizontal = 8.dp)) - TextButton(generalGetString(R.string.save_and_notify_contacts)) { + TextButton(stringResource(R.string.save_and_notify_contacts)) { saveProfile(displayName.value, fullName.value, profileImage.value) } } @@ -160,9 +161,9 @@ fun UserProfileLayout( } } } - ProfileNameRow(generalGetString(R.string.display_name__field), profile.displayName) - ProfileNameRow(generalGetString(R.string.full_name__field), profile.fullName) - TextButton(generalGetString(R.string.edit_verb)) { editProfile.value = true } + ProfileNameRow(stringResource(R.string.display_name__field), profile.displayName) + ProfileNameRow(stringResource(R.string.full_name__field), profile.fullName) + TextButton(stringResource(R.string.edit_verb)) { editProfile.value = true } } } if (savedKeyboardState != keyboardState) { @@ -229,7 +230,7 @@ fun EditImageButton(click: () -> Unit) { ) { Icon( Icons.Outlined.PhotoCamera, - contentDescription = generalGetString(R.string.edit_image), + contentDescription = stringResource(R.string.edit_image), tint = MaterialTheme.colors.primary, modifier = Modifier.size(36.dp) ) @@ -241,7 +242,7 @@ fun DeleteImageButton(click: () -> Unit) { IconButton(onClick = click) { Icon( Icons.Outlined.Close, - contentDescription = generalGetString(R.string.delete_image), + contentDescription = stringResource(R.string.delete_image), tint = MaterialTheme.colors.primary, ) } From cd2eb9c88eb78a435b7b572b74db58481f54f63e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 25 Apr 2022 16:30:21 +0100 Subject: [PATCH 22/95] core: test async handshake (#569) * core: test async handshake * Update tests/ChatTests.hs Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 5 ++++ stack.yaml | 2 +- tests/ChatClient.hs | 41 +++++++++++++++++++++++++------ tests/ChatTests.hs | 51 +++++++++++++++++++++++++++++++++++++++ tests/Test.hs | 19 ++++++++++----- 7 files changed, 105 insertions(+), 17 deletions(-) diff --git a/cabal.project b/cabal.project index f8d20085ae..dba79c9c94 100644 --- a/cabal.project +++ b/cabal.project @@ -3,7 +3,7 @@ packages: . source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: c6dde772b459a2d8f392ad5455d6f8fd6f16e867 + tag: f8ec838912579159ca0ebd956a559fc20bf1d7ea source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index ff3b87c194..f13c239d0e 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."c6dde772b459a2d8f392ad5455d6f8fd6f16e867" = "08crpg2jnpvj08cl372znbhf3n0frm8ldbl2bwva0f0jq5j364nk"; + "https://github.com/simplex-chat/simplexmq.git"."f8ec838912579159ca0ebd956a559fc20bf1d7ea" = "0kywhk2q92h3j24pp9lxycs062myl8ch7wdwlyd6v82lcm14w8pd"; "https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp"; "https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; "https://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 64321d7e6e..74233f59e0 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -146,6 +146,11 @@ startChatController user = do atomically . writeTVar s $ Just a pure a +stopChatController :: MonadUnliftIO m => ChatController -> m () +stopChatController ChatController {smpAgent, agentAsync = s} = do + disconnectAgentClient smpAgent + readTVarIO s >>= mapM_ uninterruptibleCancel >> atomically (writeTVar s Nothing) + withLock :: MonadUnliftIO m => TMVar () -> m a -> m a withLock lock = E.bracket_ diff --git a/stack.yaml b/stack.yaml index 715e40f2cf..1ed52e5f44 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: c6dde772b459a2d8f392ad5455d6f8fd6f16e867 + commit: f8ec838912579159ca0ebd956a559fc20bf1d7ea # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 - github: simplex-chat/aeson commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7 diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index cc4ab3f7ab..3fa5f3d4dd 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -8,12 +8,12 @@ module ChatClient where -import Control.Concurrent (ThreadId, forkIOWithUnmask, killThread) +import Control.Concurrent (ThreadId, forkIOWithUnmask, killThread, threadDelay) import Control.Concurrent.Async import Control.Concurrent.STM import Control.Exception (bracket, bracket_) import Control.Monad.Except -import Data.List (dropWhileEnd) +import Data.List (dropWhileEnd, find) import Data.Maybe (fromJust) import qualified Data.Text as T import Network.Socket @@ -81,10 +81,22 @@ cfg = testView = True } -virtualSimplexChat :: FilePath -> Profile -> IO TestCC -virtualSimplexChat dbFilePrefix profile = do +createTestChat :: Int -> Profile -> IO TestCC +createTestChat dbNumber profile = do + let dbFilePrefix = testDBPrefix <> show dbNumber st <- createStore (dbFilePrefix <> "_chat.db") 1 False Right user <- runExceptT $ createUser st profile True + startTestChat_ st dbFilePrefix user + +startTestChat :: Int -> IO TestCC +startTestChat dbNumber = do + let dbFilePrefix = testDBPrefix <> show dbNumber + st <- createStore (dbFilePrefix <> "_chat.db") 1 False + Just user <- find activeUser <$> getUsers st + startTestChat_ st dbFilePrefix user + +startTestChat_ :: SQLiteStore -> FilePath -> User -> IO TestCC +startTestChat_ st dbFilePrefix user = do t <- withVirtualTerminal termSettings pure ct <- newChatTerminal t cc <- newChatController st (Just user) cfg opts {dbFilePrefix} Nothing -- no notifications @@ -93,6 +105,19 @@ virtualSimplexChat dbFilePrefix profile = do termAsync <- async $ readTerminalOutput t termQ pure TestCC {chatController = cc, virtualTerminal = t, chatAsync, termAsync, termQ} +stopTestChat :: TestCC -> IO () +stopTestChat TestCC {chatController = cc, chatAsync, termAsync} = do + threadDelay 500000 + stopChatController cc + uninterruptibleCancel termAsync + uninterruptibleCancel chatAsync + +withNewTestChat :: Int -> Profile -> (TestCC -> IO a) -> IO a +withNewTestChat dbNumber profile = bracket (createTestChat dbNumber profile) stopTestChat + +withTestChat :: Int -> (TestCC -> IO a) -> IO a +withTestChat dbNumber = bracket (startTestChat dbNumber) stopTestChat + readTerminalOutput :: VirtualTerminal -> TQueue String -> IO () readTerminalOutput t termQ = do let w = virtualWindow t @@ -124,14 +149,14 @@ withTmpFiles = testChatN :: [Profile] -> ([TestCC] -> IO ()) -> IO () testChatN ps test = withTmpFiles $ do - let envs = zip ps $ map ((testDBPrefix <>) . show) [(1 :: Int) ..] - tcs <- getTestCCs envs [] + tcs <- getTestCCs (zip ps [1 ..]) [] test tcs concurrentlyN_ $ map ( [TestCC] -> IO [TestCC] + getTestCCs :: [(Profile, Int)] -> [TestCC] -> IO [TestCC] getTestCCs [] tcs = pure tcs - getTestCCs ((p, db) : envs') tcs = (:) <$> virtualSimplexChat db p <*> getTestCCs envs' tcs + getTestCCs ((p, db) : envs') tcs = (:) <$> createTestChat db p <*> getTestCCs envs' tcs ( Int -> Expectation ( ("/smp_servers default", id, "ok") alice #$> ("/smp_servers", id, "no custom SMP servers saved") +testAsyncInitiatingOffline :: IO () +testAsyncInitiatingOffline = withTmpFiles $ do + inv <- withNewTestChat 1 aliceProfile $ \alice -> do + alice ##> "/c" + getInvitation alice + withNewTestChat 2 bobProfile $ \bob -> do + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + withTestChat 1 $ \alice -> do + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + +testAsyncAcceptingOffline :: IO () +testAsyncAcceptingOffline = withTmpFiles $ do + inv <- withNewTestChat 1 aliceProfile $ \alice -> do + alice ##> "/c" + getInvitation alice + withNewTestChat 2 bobProfile $ \bob -> do + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + withTestChat 1 $ \alice -> + withTestChat 2 $ \bob -> + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + +testAsyncNeverTogetherOnline :: IO () +testAsyncNeverTogetherOnline = withTmpFiles $ do + inv <- withNewTestChat 1 aliceProfile $ \alice -> do + alice ##> "/c" + getInvitation alice + withNewTestChat 2 bobProfile $ \bob -> do + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + withTestChat 1 $ \_ -> pure () + withTestChat 2 $ \_ -> pure () + withTestChat 1 $ \alice -> + alice <## "1 contacts connected (use /cs for the list)" + withTestChat 2 $ \_ -> pure () + withTestChat 1 $ \alice -> do + alice <## "1 contacts connected (use /cs for the list)" + alice <## "bob (Bob): contact is connected" + withTestChat 2 $ \bob -> do + bob <## "1 contacts connected (use /cs for the list)" + bob <## "alice (Alice): contact is connected" + startFileTransfer :: TestCC -> TestCC -> IO () startFileTransfer alice bob = do alice #> "/f @bob ./tests/fixtures/test.jpg" diff --git a/tests/Test.hs b/tests/Test.hs index 3df06d6c60..5855fca88d 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -1,5 +1,6 @@ import ChatClient import ChatTests +-- import Control.Logger.Simple import MarkdownTests import MobileTests import ProtocolTests @@ -7,9 +8,15 @@ import SchemaDump import Test.Hspec main :: IO () -main = withSmpServer . hspec $ do - describe "SimpleX chat markdown" markdownTests - describe "SimpleX chat protocol" protocolTests - describe "Mobile API Tests" mobileTests - describe "SimpleX chat client" chatTests - describe "Schema dump" schemaDumpTest +main = do + -- setLogLevel LogDebug -- LogError + -- withGlobalLogging logCfg $ + withSmpServer . hspec $ do + describe "SimpleX chat markdown" markdownTests + describe "SimpleX chat protocol" protocolTests + describe "Mobile API Tests" mobileTests + describe "SimpleX chat client" chatTests + describe "Schema dump" schemaDumpTest + +-- logCfg :: LogConfig +-- logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} From 44de6297eeba976891e4e1dec3c500f1b0036d4c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 26 Apr 2022 07:41:08 +0100 Subject: [PATCH 23/95] android: UI for pending contact connections, ios: translations, show profile picture in contact requests (#571) * android: UI for pending contact connections, ios: translations, show profile picture in contact requests * update translations Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> * update translation Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> --- apps/android/app/build.gradle | 1 + .../java/chat/simplex/app/model/ChatModel.kt | 125 +++++++++++++++--- .../java/chat/simplex/app/model/SimpleXAPI.kt | 33 ++++- .../simplex/app/views/chat/ChatInfoView.kt | 2 +- .../chat/simplex/app/views/chat/ChatView.kt | 2 +- .../app/views/chatlist/ChatListNavLinkView.kt | 83 +++++++++--- .../app/views/chatlist/ChatPreviewView.kt | 9 +- .../views/chatlist/ContactConnectionView.kt | 56 ++++++++ .../app/views/chatlist/ContactRequestView.kt | 17 +-- .../app/views/helpers/ChatInfoImage.kt | 8 +- .../app/src/main/res/values-ru/strings.xml | 20 +++ .../app/src/main/res/values/strings.xml | 22 ++- apps/ios/Shared/Model/SimpleXAPI.swift | 8 +- .../Views/ChatList/ChatListNavLink.swift | 2 +- .../Views/ChatList/ContactRequestView.swift | 8 +- .../Shared/Views/Helpers/ChatInfoImage.swift | 1 + .../en.xcloc/Localized Contents/en.xliff | 65 +++++++++ .../ru.xcloc/Localized Contents/ru.xliff | 65 +++++++++ apps/ios/ru.lproj/Localizable.strings | 39 ++++++ scripts/android/prepare.sh | 6 + 20 files changed, 505 insertions(+), 67 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactConnectionView.kt create mode 100755 scripts/android/prepare.sh diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index 62bf846734..9d9b1154ba 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -47,6 +47,7 @@ android { freeCompilerArgs += "-opt-in=com.google.accompanist.insets.ExperimentalAnimatedInsets" freeCompilerArgs += "-opt-in=com.google.accompanist.permissions.ExperimentalPermissionsApi" freeCompilerArgs += "-opt-in=kotlinx.serialization.InternalSerializationApi" + freeCompilerArgs += "-opt-in=kotlinx.serialization.ExperimentalSerializationApi" } externalNativeBuild { cmake { diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 6f3376cd29..a379e1a00a 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -4,6 +4,7 @@ import android.net.Uri import androidx.compose.material.MaterialTheme import androidx.compose.runtime.* import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.TextDecoration @@ -54,9 +55,12 @@ class ChatModel(val controller: ChatController) { if (i >= 0) chats[i] = chats[i].copy(chatInfo = cInfo) } - fun updateContact(contact: Contact) { - val cInfo = ChatInfo.Direct(contact) - if (hasChat(contact.id)) { + fun updateContactConnection(contactConnection: PendingContactConnection) = updateChat(ChatInfo.ContactConnection(contactConnection)) + + fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact)) + + private fun updateChat(cInfo: ChatInfo) { + if (hasChat(cInfo.id)) { updateChatInfo(cInfo) } else { addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf())) @@ -200,14 +204,8 @@ class ChatModel(val controller: ChatController) { enum class ChatType(val type: String) { Direct("@"), Group("#"), - ContactRequest("<@"); - - val chatTypeName: String get () = - when (this) { - Direct -> "contact" - Group -> "group" - ContactRequest -> "contact request" - } + ContactRequest("<@"), + ContactConnection(":"); } @Serializable @@ -343,6 +341,24 @@ sealed class ChatInfo: SomeChat, NamedChat { val sampleData = ContactRequest(UserContactRequest.sampleData) } } + + @Serializable @SerialName("contactConnection") + class ContactConnection(val contactConnection: PendingContactConnection): ChatInfo() { + override val chatType get() = ChatType.ContactConnection + override val localDisplayName get() = contactConnection.localDisplayName + override val id get() = contactConnection.id + override val apiId get() = contactConnection.apiId + override val ready get() = contactConnection.ready + override val createdAt get() = contactConnection.createdAt + override val displayName get() = contactConnection.displayName + override val fullName get() = contactConnection.fullName + override val image get() = contactConnection.image + + companion object { + fun getSampleData(status: ConnStatus = ConnStatus.New, viaContactUri: Boolean = false): ContactConnection = + ContactConnection(PendingContactConnection.getSampleData(status, viaContactUri)) + } + } } @Serializable @@ -357,7 +373,7 @@ class Contact( override val chatType get() = ChatType.Direct override val id get() = "@$contactId" override val apiId get() = contactId - override val ready get() = activeConn.connStatus == "ready" + override val ready get() = activeConn.connStatus == ConnStatus.Ready override val displayName get() = profile.displayName override val fullName get() = profile.fullName override val image get() = profile.image @@ -380,9 +396,10 @@ class ContactSubStatus( ) @Serializable -class Connection(val connStatus: String) { +class Connection(val connId: Long, val connStatus: ConnStatus) { + val id: ChatId get() = ":$connId" companion object { - val sampleData = Connection(connStatus = "ready") + val sampleData = Connection(connId = 1, connStatus = ConnStatus.Ready) } } @@ -491,7 +508,8 @@ class UserContactRequest ( val contactRequestId: Long, override val localDisplayName: String, val profile: Profile, - override val createdAt: Instant + override val createdAt: Instant, + val updatedAt: Instant ): SomeChat, NamedChat { override val chatType get() = ChatType.ContactRequest override val id get() = "<@$contactRequestId" @@ -506,11 +524,85 @@ class UserContactRequest ( contactRequestId = 1, localDisplayName = "alice", profile = Profile.sampleData, - createdAt = Clock.System.now() + createdAt = Clock.System.now(), + updatedAt = Clock.System.now() ) } } +@Serializable +class PendingContactConnection( + val pccConnId: Long, + val pccAgentConnId: String, + val pccConnStatus: ConnStatus, + val viaContactUri: Boolean, + override val createdAt: Instant, + val updatedAt: Instant +): SomeChat, NamedChat { + override val chatType get() = ChatType.ContactConnection + override val id get () = ":$pccConnId" + override val apiId get() = pccConnId + override val ready get() = false + override val localDisplayName get() = String.format(generalGetString(R.string.connection_local_display_name), pccConnId) + override val displayName: String get() { + val initiated = pccConnStatus.initiated + return if (initiated == null) { + // this should not be in the chat list + generalGetString(R.string.display_name_connection_established) + } else { + generalGetString( + if (initiated && !viaContactUri) R.string.display_name_invited_to_connect + else R.string.display_name_connecting + ) + } + } + override val fullName get() = "" + override val image get() = null + val initiated get() = (pccConnStatus.initiated ?: false) && !viaContactUri + + val description: String get() { + val initiated = pccConnStatus.initiated + return if (initiated == null) "" else generalGetString( + if (initiated && !viaContactUri) R.string.description_you_shared_one_time_link + else if (viaContactUri ) R.string.description_via_contact_address_link + else R.string.description_via_one_time_link + ) + } + + companion object { + fun getSampleData(status: ConnStatus = ConnStatus.New, viaContactUri: Boolean = false): PendingContactConnection = + PendingContactConnection( + pccConnId = 1, + pccAgentConnId = "abcd", + pccConnStatus = status, + viaContactUri = viaContactUri, + createdAt = Clock.System.now(), + updatedAt = Clock.System.now() + ) + } +} + +@Serializable +enum class ConnStatus { + @SerialName("new") New, + @SerialName("joined") Joined, + @SerialName("requested") Requested, + @SerialName("accepted") Accepted, + @SerialName("snd-ready") SndReady, + @SerialName("ready") Ready, + @SerialName("deleted") Deleted; + + val initiated: Boolean? get() = when (this) { + New -> true + Joined -> false + Requested -> true + Accepted -> true + SndReady -> false + Ready -> null + Deleted -> null + } +} + @Serializable class AChatItem ( val chatInfo: ChatInfo, @@ -800,7 +892,6 @@ sealed class MsgContent { } object MsgContentSerializer : KSerializer { - @OptIn(InternalSerializationApi::class) override val descriptor: SerialDescriptor = buildSerialDescriptor("MsgContent", PolymorphicKind.SEALED) { element("MCText", buildClassSerialDescriptor("MCText") { element("text") diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index b38fcbe011..db708aaf06 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -241,9 +241,10 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt suspend fun apiDeleteChat(type: ChatType, id: Long): Boolean { val r = sendCmd(CC.ApiDeleteChat(type, id)) - when (r) { - is CR.ContactDeleted -> return true // TODO groups - is CR.ChatCmdError -> { + when { + r is CR.ContactDeleted && type == ChatType.Direct -> return true + r is CR.ContactConnectionDeleted && type == ChatType.ContactConnection -> return true + r is CR.ChatCmdError -> { val e = r.chatError if (e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.ContactGroups) { AlertManager.shared.showAlertMsg( @@ -252,7 +253,15 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt ) } } - else -> apiErrorAlert("apiDeleteChat", "Error deleting ${type.chatTypeName}", r) + else -> { + val titleId = when (type) { + ChatType.Direct -> R.string.error_deleting_contact + ChatType.Group -> R.string.error_deleting_group + ChatType.ContactRequest -> R.string.error_deleting_contact_request + ChatType.ContactConnection -> R.string.error_deleting_pending_contact_connection + } + apiErrorAlert("apiDeleteChat", generalGetString(titleId), r) + } } return false } @@ -334,13 +343,21 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt fun processReceivedMsg(r: CR) { chatModel.terminalItems.add(TerminalItem.resp(r)) when (r) { + is CR.NewContactConnection -> { + chatModel.updateContactConnection(r.connection) + } + is CR.ContactConnectionDeleted -> { + chatModel.removeChat(r.connection.id) + } is CR.ContactConnected -> { chatModel.updateContact(r.contact) + chatModel.removeChat(r.contact.activeConn.id) chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Connected()) // NtfManager.shared.notifyContactConnected(contact) } is CR.ContactConnecting -> { chatModel.updateContact(r.contact) + chatModel.removeChat(r.contact.activeConn.id) } is CR.ReceivedContactRequest -> { val contactRequest = r.contactRequest @@ -534,7 +551,7 @@ sealed class CC { is CreateActiveUser -> "/u ${profile.displayName} ${profile.fullName}" is StartChat -> "/_start" is SetFilesFolder -> "/_files_folder $filesFolder" - is ApiGetChats -> "/_get chats" + is ApiGetChats -> "/_get chats pcc=on" is ApiGetChat -> "/_get chat ${chatRef(type, id)} count=100" is ApiSendMessage -> when { file == null && quotedItemId == null -> "/_send ${chatRef(type, id)} ${mc.cmdString}" @@ -664,6 +681,8 @@ sealed class CR { @Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val deletedChatItem: AChatItem, val toChatItem: AChatItem): CR() @Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted: CR() @Serializable @SerialName("rcvFileComplete") class RcvFileComplete(val chatItem: AChatItem): CR() + @Serializable @SerialName("newContactConnection") class NewContactConnection(val connection: PendingContactConnection): CR() + @Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val connection: PendingContactConnection): CR() @Serializable @SerialName("cmdOk") class CmdOk: CR() @Serializable @SerialName("chatCmdError") class ChatCmdError(val chatError: ChatError): CR() @Serializable @SerialName("chatError") class ChatRespError(val chatError: ChatError): CR() @@ -708,6 +727,8 @@ sealed class CR { is ChatItemDeleted -> "chatItemDeleted" is RcvFileAccepted -> "rcvFileAccepted" is RcvFileComplete -> "rcvFileComplete" + is NewContactConnection -> "newContactConnection" + is ContactConnectionDeleted -> "contactConnectionDeleted" is CmdOk -> "cmdOk" is ChatCmdError -> "chatCmdError" is ChatRespError -> "chatError" @@ -753,6 +774,8 @@ sealed class CR { is ChatItemDeleted -> "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}" is RcvFileAccepted -> noDetails() is RcvFileComplete -> json.encodeToString(chatItem) + is NewContactConnection -> json.encodeToString(connection) + is ContactConnectionDeleted -> json.encodeToString(connection) is CmdOk -> noDetails() is ChatCmdError -> chatError.string is ChatRespError -> chatError.string diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt index 0faae49c64..5f380545cc 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt @@ -61,8 +61,8 @@ fun ChatInfoLayout(chat: Chat, close: () -> Unit, deleteContact: () -> Unit) { ) { CloseSheetBar(close) Spacer(Modifier.size(48.dp)) - ChatInfoImage(chat, size = 192.dp) val cInfo = chat.chatInfo + ChatInfoImage(cInfo, size = 192.dp) Text( cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), color = MaterialTheme.colors.onBackground, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index 6b23da0b21..73be70f102 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -261,7 +261,7 @@ fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) { verticalAlignment = Alignment.CenterVertically ) { val cInfo = chat.chatInfo - ChatInfoImage(chat, size = 40.dp) + ChatInfoImage(cInfo, size = 40.dp) Column( Modifier.padding(start = 8.dp), horizontalAlignment = Alignment.CenterHorizontally diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt index b764c8d5a4..02bc964b69 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt @@ -3,12 +3,12 @@ package chat.simplex.app.views.chatlist import android.content.res.Configuration import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.material.Divider -import androidx.compose.material.Surface +import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import chat.simplex.app.R @@ -22,14 +22,15 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { ChatListNavLinkLayout( chat = chat, click = { - if (chat.chatInfo is ChatInfo.ContactRequest) { - contactRequestAlertDialog(chat.chatInfo, chatModel) - } else { - if (chat.chatInfo.ready) { - withApi { openChat(chatModel, chat.chatInfo) } - } else { - pendingConnectionAlertDialog(chat.chatInfo, chatModel) - } + when (chat.chatInfo) { + is ChatInfo.ContactRequest -> contactRequestAlertDialog(chat.chatInfo, chatModel) + is ChatInfo.ContactConnection -> contactConnectionAlertDialog(chat.chatInfo.contactConnection, chatModel) + else -> + if (chat.chatInfo.ready) { + withApi { openChat(chatModel, chat.chatInfo) } + } else { + pendingContactAlertDialog(chat.chatInfo, chatModel) + } } } ) @@ -67,7 +68,58 @@ fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel ) } -fun pendingConnectionAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) { +fun contactConnectionAlertDialog(connection: PendingContactConnection, chatModel: ChatModel) { + AlertManager.shared.showAlertDialogButtons( + title = generalGetString( + if (connection.initiated) R.string.you_invited_your_contact + else R.string.you_accepted_connection + ), + text = generalGetString( + if (connection.viaContactUri) R.string.you_will_be_connected_when_your_connection_request_is_accepted + else R.string.you_will_be_connected_when_your_contacts_device_is_online + ), + buttons = { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.End, + ) { + Button(onClick = { + AlertManager.shared.hideAlert() + deleteContactConnectionAlert(connection, chatModel) + }) { + Text(stringResource(R.string.delete_verb)) + } + Spacer(Modifier.padding(horizontal = 4.dp)) + Button(onClick = { AlertManager.shared.hideAlert() }) { + Text(stringResource(R.string.ok)) + } + } + } + ) +} + +fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel: ChatModel) { + AlertManager.shared.showAlertDialog( + title = generalGetString(R.string.delete_pending_connection__question), + text = generalGetString( + if (connection.initiated) R.string.contact_you_shared_link_with_wont_be_able_to_connect + else R.string.connection_you_accepted_will_be_cancelled + ), + confirmText = generalGetString(R.string.delete_verb), + onConfirm = { + withApi { + AlertManager.shared.hideAlert() + if (chatModel.controller.apiDeleteChat(ChatType.ContactConnection, connection.apiId)) { + chatModel.removeChat(connection.id) + } + } + } + ) +} + +fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) { AlertManager.shared.showAlertDialog( title = generalGetString(R.string.alert_title_contact_connection_pending), text = generalGetString(R.string.alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry), @@ -101,10 +153,11 @@ fun ChatListNavLinkLayout(chat: Chat, click: () -> Unit) { .padding(end = 12.dp), verticalAlignment = Alignment.Top ) { - if (chat.chatInfo is ChatInfo.ContactRequest) { - ContactRequestView(chat) - } else { - ChatPreviewView(chat) + when (chat.chatInfo) { + is ChatInfo.Direct -> ChatPreviewView(chat) + is ChatInfo.Group -> ChatPreviewView(chat) + is ChatInfo.ContactRequest -> ContactRequestView(chat.chatInfo) + is ChatInfo.ContactConnection -> ContactConnectionView(chat.chatInfo.contactConnection) } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt index 80e3713c34..3144f92710 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt @@ -28,21 +28,22 @@ import chat.simplex.app.views.helpers.badgeLayout @Composable fun ChatPreviewView(chat: Chat) { Row { - ChatInfoImage(chat, size = 72.dp) + val cInfo = chat.chatInfo + ChatInfoImage(cInfo, size = 72.dp) Column( modifier = Modifier .padding(horizontal = 8.dp) .weight(1F) ) { Text( - chat.chatInfo.chatViewName, + cInfo.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, - color = if (chat.chatInfo.ready) Color.Unspecified else HighOrLowlight + color = if (cInfo.ready) Color.Unspecified else HighOrLowlight ) - if (chat.chatInfo.ready) { + if (cInfo.ready) { val ci = chat.chatItems.lastOrNull() if (ci != null) { MarkdownText( diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactConnectionView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactConnectionView.kt new file mode 100644 index 0000000000..b5636a3542 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactConnectionView.kt @@ -0,0 +1,56 @@ +package chat.simplex.app.views.chatlist + +import androidx.compose.foundation.layout.* +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AddLink +import androidx.compose.material.icons.outlined.Link +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import chat.simplex.app.R +import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.HighOrLowlight +import chat.simplex.app.views.helpers.ChatInfoImage +import chat.simplex.app.views.helpers.ProfileImage + +@Composable +fun ContactConnectionView(contactConnection: PendingContactConnection) { + Row { + Box(Modifier.size(72.dp), contentAlignment = Alignment.Center) { + ProfileImage(size = 54.dp, null, if (contactConnection.initiated) Icons.Outlined.AddLink else Icons.Outlined.Link) + } + Column( + modifier = Modifier + .padding(horizontal = 8.dp) + .weight(1F) + ) { + Text( + contactConnection.displayName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.h3, + fontWeight = FontWeight.Bold, + color = HighOrLowlight + ) + Text(contactConnection.description, maxLines = 2, color = HighOrLowlight) + } + val ts = getTimestampText(contactConnection.updatedAt) + Column( + Modifier.fillMaxHeight(), + verticalArrangement = Arrangement.Top + ) { + Text( + ts, + color = HighOrLowlight, + style = MaterialTheme.typography.body2, + modifier = Modifier.padding(bottom = 5.dp) + ) + } + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt index 3866fc0123..1140273b0a 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt @@ -10,35 +10,30 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import chat.simplex.app.R -import chat.simplex.app.model.Chat -import chat.simplex.app.model.getTimestampText +import chat.simplex.app.model.* import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.views.helpers.ChatInfoImage @Composable -fun ContactRequestView(chat: Chat) { +fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) { Row { - ChatInfoImage(chat, size = 72.dp) + ChatInfoImage(contactRequest, size = 72.dp) Column( modifier = Modifier .padding(horizontal = 8.dp) .weight(1F) ) { Text( - chat.chatInfo.chatViewName, + contactRequest.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.primary ) - Text( - stringResource(R.string.contact_wants_to_connect_with_you), - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) + Text(stringResource(R.string.contact_wants_to_connect_with_you), maxLines = 2) } - val ts = getTimestampText(chat.chatInfo.createdAt) + val ts = getTimestampText(contactRequest.contactRequest.updatedAt) Column( Modifier.fillMaxHeight(), verticalArrangement = Arrangement.Top diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt index 8f57b8f151..93ca20c5dd 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt @@ -24,11 +24,11 @@ import chat.simplex.app.model.ChatInfo import chat.simplex.app.ui.theme.SimpleXTheme @Composable -fun ChatInfoImage(chat: Chat, size: Dp) { +fun ChatInfoImage(chatInfo: ChatInfo, size: Dp) { val icon = - if (chat.chatInfo is ChatInfo.Group) Icons.Filled.SupervisedUserCircle + if (chatInfo is ChatInfo.Group) Icons.Filled.SupervisedUserCircle else Icons.Filled.AccountCircle - ProfileImage(size, chat.chatInfo.image, icon) + ProfileImage(size, chatInfo.image, icon) } @Composable @@ -62,7 +62,7 @@ fun ProfileImage( fun PreviewChatInfoImage() { SimpleXTheme { ChatInfoImage( - chat = Chat(chatInfo = ChatInfo.Direct.sampleData, chatItems = arrayListOf()), + chatInfo = ChatInfo.Direct.sampleData, size = 55.dp ) } diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index 313b9f39fa..5d42f5fb02 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -24,6 +24,15 @@ неизвестный формат сообщения неверный формат сообщения + + connection %1$d + соединение установлено + приглашение соединиться + соединяется… + вы создали одноразовую ссылку + через ссылку-контакт + через одноразовую ссылку + Ошибка при сохранении SMP серверов Пожалуйста, проверьте, что адреса SMP серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется. @@ -38,6 +47,10 @@ Невозможно удалить контакт! Контакт %1$s! не может быть удален, так как является членом групп(ы) %2$s. Мгновенные уведомления + Ошибка удаления контакта + Ошибка удаления группы + Ошибка удаления запроса + Ошибка удаления ожидаемого соединения Приватные мгновенные уведомления! @@ -132,6 +145,13 @@ Принять Отклонить + + Вы пригласили ваш контакт + Вы приняли приглашение соединиться + Удалить ожидаемое соединение? + Контакт, которому вы отправили эту ссылку, не сможет соединиться! + Подтвержденное соединение будет отменено! + Соединение еще не установлено! Ваш контакт должен быть в сети чтобы установить соединение.\nВы можете отменить соединение и удалить контакт (и попробовать позже с другой ссылкой). diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 1d840899fc..73219eff59 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -24,6 +24,15 @@ unknown message format invalid message format + + connection %1$d + connection established + invited to connect + connecting… + you shared one-time link + via contact address link + via one-time link + Error saving SMP servers Make sure SMP server addresses are in correct format, line separated and are not duplicated. @@ -38,6 +47,10 @@ Can\'t delete contact! Contact %1$s! cannot be deleted, they are a member of the group(s) %2$s. Instant notifications + Error deleting contact + Error deleting group + Error deleting contact request + Error deleting pending contact connection Private instant notifications! @@ -133,7 +146,14 @@ Accept Reject - + + You invited your contact + You accepted connection + Delete pending connection? + The contact you shared this link with will NOT be able to connect! + The connection you accepted will be cancelled! + + Contact is not connected yet! Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link). diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index af6803b797..674d047374 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -713,8 +713,10 @@ func processReceivedMsg(_ res: ChatResponse) { m.terminalItems.append(.resp(.now, res)) logger.debug("processReceivedMsg: \(res.responseType)") switch res { - case let .newContactConnection(contactConnection): - m.updateContactConnection(contactConnection) + case let .newContactConnection(connection): + m.updateContactConnection(connection) + case let .contactConnectionDeleted(connection): + m.removeChat(connection.id) case let .contactConnected(contact): m.updateContact(contact) m.removeChat(contact.activeConn.id) @@ -961,6 +963,7 @@ enum StoreError: Decodable { case fileNotFound(fileId: Int64) case rcvFileInvalid(fileId: Int64) case connectionNotFound(agentConnId: String) + case pendingConnectionNotFound(connId: Int64) case introNotFound case uniqueID case internalError(message: String) @@ -969,6 +972,7 @@ enum StoreError: Decodable { case chatItemNotFound(itemId: Int64) case quotedChatItemNotFound case chatItemSharedMsgIdNotFound(sharedMsgId: String) + case chatItemNotFoundByFileId(fileId: Int64) } enum AgentErrorType: Decodable { diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 78b6777eaa..8d96361479 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -108,7 +108,7 @@ struct ChatListNavLink: View { } private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View { - ContactRequestView(contactRequest: contactRequest) + ContactRequestView(contactRequest: contactRequest, chat: chat) .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button { Task { await acceptContactRequest(contactRequest) } } label: { Label("Accept", systemImage: "checkmark") } diff --git a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift index 31be17edf8..d139550664 100644 --- a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift +++ b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift @@ -10,14 +10,12 @@ import SwiftUI struct ContactRequestView: View { var contactRequest: UserContactRequest + @ObservedObject var chat: Chat var body: some View { return HStack(spacing: 8) { - Image(systemName: "person.crop.circle.fill") - .resizable() - .foregroundColor(Color(uiColor: .secondarySystemBackground)) + ChatInfoImage(chat: chat) .frame(width: 63, height: 63) - .padding(.leading, 4) VStack(alignment: .leading, spacing: 4) { HStack(alignment: .top) { Text(contactRequest.chatViewName) @@ -47,7 +45,7 @@ struct ContactRequestView: View { struct ContactRequestView_Previews: PreviewProvider { static var previews: some View { - ContactRequestView(contactRequest: UserContactRequest.sampleData) + ContactRequestView(contactRequest: UserContactRequest.sampleData, chat: Chat(chatInfo: ChatInfo.sampleData.contactRequest , chatItems: [])) .previewLayout(.fixed(width: 360, height: 80)) } } diff --git a/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift b/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift index c1b9abc2f4..c9a2bdf166 100644 --- a/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift +++ b/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift @@ -17,6 +17,7 @@ struct ChatInfoImage: View { switch chat.chatInfo { case .direct: iconName = "person.crop.circle.fill" case .group: iconName = "person.2.circle.fill" + case .contactRequest: iconName = "person.crop.circle.fill" default: iconName = "circle.fill" } return ProfileImage( diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 2744ebdc2c..57ca5f9cbc 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -340,6 +340,11 @@ Delete pending connection No comment provided by engineer. + + Delete pending connection? + Delete pending connection? + No comment provided by engineer. + Develop Develop @@ -597,6 +602,11 @@ to scan from the app to scan from the app No comment provided by engineer. + + Show pending connections + Show pending connections + No comment provided by engineer. + Take picture Take picture @@ -624,6 +634,16 @@ to scan from the app *Please note*: if you confirm, your device token will be sent to SimpleX Chat notifications server. No comment provided by engineer. + + The connection you accepted will be cancelled! + The connection you accepted will be cancelled! + No comment provided by engineer. + + + The contact you shared this link with will NOT be able to connect! + The contact you shared this link with will NOT be able to connect! + No comment provided by engineer. + The messaging and application platform 100% private by design! The messaging and application platform 100% private by design! @@ -691,6 +711,11 @@ To connect, please ask your contact to create another connection link and check You No comment provided by engineer. + + You accepted connection + You accepted connection + No comment provided by engineer. + You are already connected to %@ via this link. You are already connected to %@ via this link. @@ -726,6 +751,11 @@ To connect, please ask your contact to create another connection link and check You control your chat! No comment provided by engineer. + + You invited your contact + You invited your contact + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! You will be connected when your connection request is accepted, please wait or check later! @@ -830,11 +860,31 @@ SimpleX servers cannot see your profile. connect to SimpleX Chat developers. No comment provided by engineer. + + connecting… + connecting… + chat list item title + + + connection established + connection established + chat list item title (it should not be shown + + + connection:%@ + connection:%@ + connection information + deleted deleted deleted chat item + + invited to connect + invited to connect + chat list item title + italic italic @@ -855,11 +905,26 @@ SimpleX servers cannot see your profile. v%@ (%@) No comment provided by engineer. + + via contact address link + via contact address link + chat list item description + + + via one-time link + via one-time link + chat list item description + wants to connect to you! wants to connect to you! No comment provided by engineer. + + you shared one-time link + you shared one-time link + chat list item description + ~strike~ ~strike~ diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 643507ec7d..db6e5fbd4f 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -340,6 +340,11 @@ Удалить соединение No comment provided by engineer. + + Delete pending connection? + Удалить ожидаемое соединение? + No comment provided by engineer. + Develop Для разработчиков @@ -596,6 +601,11 @@ to scan from the app Покажите QR код вашему контакту для сканирования в приложении No comment provided by engineer. + + Show pending connections + Показать ожидаемые соединения + No comment provided by engineer. + Take picture Сделать фото @@ -623,6 +633,16 @@ to scan from the app *Обратите внимание*: если вы подтвердите, токен вашего устройства будет послан на сервер SimpleX Chat. No comment provided by engineer. + + The connection you accepted will be cancelled! + Подтвержденное соединение будет отменено! + No comment provided by engineer. + + + The contact you shared this link with will NOT be able to connect! + Контакт, которому вы отправили эту ссылку, не сможет соединиться! + No comment provided by engineer. + The messaging and application platform 100% private by design! Платформа для сообщений и приложений, которая защищает вашу личную информацию и безопасность. @@ -690,6 +710,11 @@ To connect, please ask your contact to create another connection link and check Вы No comment provided by engineer. + + You accepted connection + Вы приняли приглашение соединиться + No comment provided by engineer. + You are already connected to %@ via this link. Вы уже соединены с %@ через эту ссылку. @@ -725,6 +750,11 @@ To connect, please ask your contact to create another connection link and check Вы котролируете Ваш чат! No comment provided by engineer. + + You invited your contact + Вы пригласили ваш контакт + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! Соединение будет установлено, когда ваш запрос будет принят. Пожалуйста, подождите или проверьте позже! @@ -829,11 +859,31 @@ SimpleX серверы не могут получить доступ к ваше соединитесь с разработчиками. No comment provided by engineer. + + connecting… + соединяется… + chat list item title + + + connection established + соединение установлено + chat list item title (it should not be shown + + + connection:%@ + connection:%@ + connection information + deleted удалено deleted chat item + + invited to connect + приглашение соединиться + chat list item title + italic курсив @@ -854,11 +904,26 @@ SimpleX серверы не могут получить доступ к ваше v%@ (%@) No comment provided by engineer. + + via contact address link + через ссылку-контакт + chat list item description + + + via one-time link + через одноразовую ссылку + chat list item description + wants to connect to you! хочет соединиться с вами! No comment provided by engineer. + + you shared one-time link + вы создали одноразовую ссылку + chat list item description + ~strike~ \~зачеркнуть~ diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 1752f798ea..0df64ae7b9 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -151,6 +151,9 @@ /* No comment provided by engineer. */ "Connecting server… (error: %@)" = "Устанавливается соединение с сервером… (ошибка: %@)"; +/* chat list item title */ +"connecting…" = "соединяется…"; + /* No comment provided by engineer. */ "Connecting..." = "Устанавливается соединение…"; @@ -160,6 +163,9 @@ /* No comment provided by engineer. */ "Connection error (AUTH)" = "Ошибка соединения (AUTH)"; +/* chat list item title (it should not be shown */ +"connection established" = "соединение установлено"; + /* No comment provided by engineer. */ "Connection request" = "Запрос на соединение"; @@ -169,6 +175,9 @@ /* No comment provided by engineer. */ "Connection timeout" = "Превышено время соединения"; +/* connection information */ +"connection:%@" = "connection:%@"; + /* No comment provided by engineer. */ "Contact already exists" = "Существующий контакт"; @@ -229,6 +238,9 @@ /* No comment provided by engineer. */ "Delete pending connection" = "Удалить соединение"; +/* No comment provided by engineer. */ +"Delete pending connection?" = "Удалить ожидаемое соединение?"; + /* deleted chat item */ "deleted" = "удалено"; @@ -295,6 +307,9 @@ /* No comment provided by engineer. */ "Invalid connection link" = "Ошибка в ссылке контакта"; +/* chat list item title */ +"invited to connect" = "приглашение соединиться"; + /* No comment provided by engineer. */ "italic" = "курсив"; @@ -385,6 +400,9 @@ /* No comment provided by engineer. */ "Share link" = "Поделиться ссылкой"; +/* No comment provided by engineer. */ +"Show pending connections" = "Показать ожидаемые соединения"; + /* No comment provided by engineer. */ "Show QR code to your contact\nto scan from the app" = "Покажите QR код вашему контакту для сканирования в приложении"; @@ -409,6 +427,12 @@ /* No comment provided by engineer. */ "The app can receive background notifications every 20 minutes to check the new messages.\n*Please note*: if you confirm, your device token will be sent to SimpleX Chat notifications server." = "Приложение может получать скрытые уведомления каждые 20 минут чтобы проверить новые сообщения.\n*Обратите внимание*: если вы подтвердите, токен вашего устройства будет послан на сервер SimpleX Chat."; +/* No comment provided by engineer. */ +"The connection you accepted will be cancelled!" = "Подтвержденное соединение будет отменено!"; + +/* No comment provided by engineer. */ +"The contact you shared this link with will NOT be able to connect!" = "Контакт, которому вы отправили эту ссылку, не сможет соединиться!"; + /* No comment provided by engineer. */ "The messaging and application platform 100% private by design!" = "Платформа для сообщений и приложений, которая защищает вашу личную информацию и безопасность."; @@ -445,6 +469,12 @@ /* No comment provided by engineer. */ "v%@ (%@)" = "v%@ (%@)"; +/* chat list item description */ +"via contact address link" = "через ссылку-контакт"; + +/* chat list item description */ +"via one-time link" = "через одноразовую ссылку"; + /* No comment provided by engineer. */ "wants to connect to you!" = "хочет соединиться с вами!"; @@ -454,6 +484,9 @@ /* No comment provided by engineer. */ "You" = "Вы"; +/* No comment provided by engineer. */ +"You accepted connection" = "Вы приняли приглашение соединиться"; + /* No comment provided by engineer. */ "You are already connected to %@ via this link." = "Вы уже соединены с %@ через эту ссылку."; @@ -475,6 +508,12 @@ /* No comment provided by engineer. */ "You control your chat!" = "Вы котролируете Ваш чат!"; +/* No comment provided by engineer. */ +"You invited your contact" = "Вы пригласили ваш контакт"; + +/* chat list item description */ +"you shared one-time link" = "вы создали одноразовую ссылку"; + /* No comment provided by engineer. */ "You will be connected when your connection request is accepted, please wait or check later!" = "Соединение будет установлено, когда ваш запрос будет принят. Пожалуйста, подождите или проверьте позже!"; diff --git a/scripts/android/prepare.sh b/scripts/android/prepare.sh new file mode 100755 index 0000000000..363d7f7edb --- /dev/null +++ b/scripts/android/prepare.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +# libsimplex.so and libsupport.so binaries should be in ~/Downloads folder +rm ./apps/android/app/src/main/cpp/libs/arm64-v8a/* +cp ~/Downloads/libsimplex.so ./apps/android/app/src/main/cpp/libs/arm64-v8a/ +cp ~/Downloads/libsupport.so ./apps/android/app/src/main/cpp/libs/arm64-v8a/ From fc5cdc5eb1f1bca6275e293b5ca41f20f87a7dd9 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 26 Apr 2022 07:51:06 +0100 Subject: [PATCH 24/95] mobile: use batched DOWN/UP events, core: include pending contacts (#573) * mobile: use batched DOWN/UP events, core: include pending contacts * query style --- .../java/chat/simplex/app/model/ChatModel.kt | 12 +++++- .../java/chat/simplex/app/model/SimpleXAPI.kt | 38 ++++++++++--------- apps/ios/Shared/Model/ChatModel.swift | 13 +++++-- apps/ios/Shared/Model/SimpleXAPI.swift | 35 ++++++++--------- src/Simplex/Chat/Store.hs | 6 +-- 5 files changed, 61 insertions(+), 43 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index a379e1a00a..346f8ce8ad 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -67,8 +67,8 @@ class ChatModel(val controller: ChatController) { } } - fun updateNetworkStatus(contact: Contact, status: Chat.NetworkStatus) { - val i = getChatIndex(contact.id) + fun updateNetworkStatus(id: ChatId, status: Chat.NetworkStatus) { + val i = getChatIndex(id) if (i >= 0) { val chat = chats[i] chats[i] = chat.copy(serverInfo = chat.serverInfo.copy(networkStatus = status)) @@ -389,6 +389,14 @@ class Contact( } } +@Serializable +class ContactRef( + val contactId: Long, + var localDisplayName: String +) { + val id: ChatId get() = "@$contactId" +} + @Serializable class ContactSubStatus( val contact: Contact, diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index db708aaf06..6e6f172f4e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -352,7 +352,7 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt is CR.ContactConnected -> { chatModel.updateContact(r.contact) chatModel.removeChat(r.contact.activeConn.id) - chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Connected()) + chatModel.updateNetworkStatus(r.contact.id, Chat.NetworkStatus.Connected()) // NtfManager.shared.notifyContactConnected(contact) } is CR.ContactConnecting -> { @@ -371,17 +371,18 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt chatModel.updateChatInfo(cInfo) } } - is CR.ContactSubscribed -> processContactSubscribed(r.contact) - is CR.ContactDisconnected -> { - chatModel.updateContact(r.contact) - chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Disconnected()) - } + is CR.ContactsSubscribed -> updateContactsStatus(r.contactRefs, Chat.NetworkStatus.Connected()) + is CR.ContactsDisconnected -> updateContactsStatus(r.contactRefs, Chat.NetworkStatus.Disconnected()) is CR.ContactSubError -> processContactSubError(r.contact, r.chatError) is CR.ContactSubSummary -> { for (sub in r.contactSubscriptions) { val err = sub.contactError - if (err == null) processContactSubscribed(sub.contact) - else processContactSubError(sub.contact, sub.contactError) + if (err == null) { + chatModel.updateContact(sub.contact) + chatModel.updateNetworkStatus(sub.contact.id, Chat.NetworkStatus.Connected()) + } else { + processContactSubError(sub.contact, sub.contactError) + } } } is CR.NewChatItem -> { @@ -436,9 +437,10 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt } } - fun processContactSubscribed(contact: Contact) { - chatModel.updateContact(contact) - chatModel.updateNetworkStatus(contact, Chat.NetworkStatus.Connected()) + fun updateContactsStatus(contactRefs: List, status: Chat.NetworkStatus) { + for (c in contactRefs) { + chatModel.updateNetworkStatus(c.id, status) + } } fun processContactSubError(contact: Contact, chatError: ChatError) { @@ -454,7 +456,7 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt } } else e.string - chatModel.updateNetworkStatus(contact, Chat.NetworkStatus.Error(err)) + chatModel.updateNetworkStatus(contact.id, Chat.NetworkStatus.Error(err)) } fun showBackgroundServiceNotice() { @@ -667,8 +669,8 @@ sealed class CR { @Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val contact: Contact): CR() @Serializable @SerialName("contactRequestRejected") class ContactRequestRejected: CR() @Serializable @SerialName("contactUpdated") class ContactUpdated(val toContact: Contact): CR() - @Serializable @SerialName("contactSubscribed") class ContactSubscribed(val contact: Contact): CR() - @Serializable @SerialName("contactDisconnected") class ContactDisconnected(val contact: Contact): CR() + @Serializable @SerialName("contactsSubscribed") class ContactsSubscribed(val server: String, val contactRefs: List): CR() + @Serializable @SerialName("contactsDisconnected") class ContactsDisconnected(val server: String, val contactRefs: List): CR() @Serializable @SerialName("contactSubError") class ContactSubError(val contact: Contact, val chatError: ChatError): CR() @Serializable @SerialName("contactSubSummary") class ContactSubSummary(val contactSubscriptions: List): CR() @Serializable @SerialName("groupSubscribed") class GroupSubscribed(val group: GroupInfo): CR() @@ -713,8 +715,8 @@ sealed class CR { is AcceptingContactRequest -> "acceptingContactRequest" is ContactRequestRejected -> "contactRequestRejected" is ContactUpdated -> "contactUpdated" - is ContactSubscribed -> "contactSubscribed" - is ContactDisconnected -> "contactDisconnected" + is ContactsSubscribed -> "contactsSubscribed" + is ContactsDisconnected -> "contactsDisconnected" is ContactSubError -> "contactSubError" is ContactSubSummary -> "contactSubSummary" is GroupSubscribed -> "groupSubscribed" @@ -760,8 +762,8 @@ sealed class CR { is AcceptingContactRequest -> json.encodeToString(contact) is ContactRequestRejected -> noDetails() is ContactUpdated -> json.encodeToString(toContact) - is ContactSubscribed -> json.encodeToString(contact) - is ContactDisconnected -> json.encodeToString(contact) + is ContactsSubscribed -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}" + is ContactsDisconnected -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}" is ContactSubError -> "error:\n${chatError.string}\ncontact:\n${json.encodeToString(contact)}" is ContactSubSummary -> json.encodeToString(contactSubscriptions) is GroupSubscribed -> json.encodeToString(group) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index e2960a6ea0..4e21969b70 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -70,9 +70,9 @@ final class ChatModel: ObservableObject { } } - func updateNetworkStatus(_ contact: Contact, _ status: Chat.NetworkStatus) { - if let ix = getChatIndex(contact.id) { - chats[ix].serverInfo.networkStatus = status + func updateNetworkStatus(_ id: ChatId, _ status: Chat.NetworkStatus) { + if let i = getChatIndex(id) { + chats[i].serverInfo.networkStatus = status } } @@ -488,6 +488,13 @@ struct Contact: Identifiable, Decodable, NamedChat { ) } +struct ContactRef: Decodable { + var contactId: Int64 + var localDisplayName: ContactName + + var id: ChatId { get { "@\(contactId)" } } +} + struct ContactSubStatus: Decodable { var contact: Contact var contactError: ChatError? diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 674d047374..e719956a66 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -159,8 +159,8 @@ enum ChatResponse: Decodable, Error { case acceptingContactRequest(contact: Contact) case contactRequestRejected case contactUpdated(toContact: Contact) - case contactSubscribed(contact: Contact) - case contactDisconnected(contact: Contact) + case contactsSubscribed(server: String, contactRefs: [ContactRef]) + case contactsDisconnected(server: String, contactRefs: [ContactRef]) case contactSubError(contact: Contact, chatError: ChatError) case contactSubSummary(contactSubscriptions: [ContactSubStatus]) case groupSubscribed(groupInfo: GroupInfo) @@ -207,8 +207,8 @@ enum ChatResponse: Decodable, Error { case .acceptingContactRequest: return "acceptingContactRequest" case .contactRequestRejected: return "contactRequestRejected" case .contactUpdated: return "contactUpdated" - case .contactSubscribed: return "contactSubscribed" - case .contactDisconnected: return "contactDisconnected" + case .contactsSubscribed: return "contactsSubscribed" + case .contactsDisconnected: return "contactsDisconnected" case .contactSubError: return "contactSubError" case .contactSubSummary: return "contactSubSummary" case .groupSubscribed: return "groupSubscribed" @@ -258,8 +258,8 @@ enum ChatResponse: Decodable, Error { case let .acceptingContactRequest(contact): return String(describing: contact) case .contactRequestRejected: return noDetails case let .contactUpdated(toContact): return String(describing: toContact) - case let .contactSubscribed(contact): return String(describing: contact) - case let .contactDisconnected(contact): return String(describing: contact) + case let .contactsSubscribed(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" + case let .contactsDisconnected(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" case let .contactSubError(contact, chatError): return "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))" case let .contactSubSummary(contactSubscriptions): return String(describing: contactSubscriptions) case let .groupSubscribed(groupInfo): return String(describing: groupInfo) @@ -720,7 +720,7 @@ func processReceivedMsg(_ res: ChatResponse) { case let .contactConnected(contact): m.updateContact(contact) m.removeChat(contact.activeConn.id) - m.updateNetworkStatus(contact, .connected) + m.updateNetworkStatus(contact.id, .connected) NtfManager.shared.notifyContactConnected(contact) case let .contactConnecting(contact): m.updateContact(contact) @@ -736,11 +736,10 @@ func processReceivedMsg(_ res: ChatResponse) { if m.hasChat(toContact.id) { m.updateChatInfo(cInfo) } - case let .contactSubscribed(contact): - processContactSubscribed(contact) - case let .contactDisconnected(contact): - m.updateContact(contact) - m.updateNetworkStatus(contact, .disconnected) + case let .contactsSubscribed(_, contactRefs): + updateContactsStatus(contactRefs, status: .connected) + case let .contactsDisconnected(_, contactRefs): + updateContactsStatus(contactRefs, status: .disconnected) case let .contactSubError(contact, chatError): processContactSubError(contact, chatError) case let .contactSubSummary(contactSubscriptions): @@ -748,7 +747,8 @@ func processReceivedMsg(_ res: ChatResponse) { if let err = sub.contactError { processContactSubError(sub.contact, err) } else { - processContactSubscribed(sub.contact) + m.updateContact(sub.contact) + m.updateNetworkStatus(sub.contact.id, .connected) } } case let .newChatItem(aChatItem): @@ -810,10 +810,11 @@ func processReceivedMsg(_ res: ChatResponse) { } } -func processContactSubscribed(_ contact: Contact) { +func updateContactsStatus(_ contactRefs: [ContactRef], status: Chat.NetworkStatus) { let m = ChatModel.shared - m.updateContact(contact) - m.updateNetworkStatus(contact, .connected) + for c in contactRefs { + m.updateNetworkStatus(c.id, status) + } } func processContactSubError(_ contact: Contact, _ chatError: ChatError) { @@ -825,7 +826,7 @@ func processContactSubError(_ contact: Contact, _ chatError: ChatError) { case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted" default: err = String(describing: chatError) } - m.updateNetworkStatus(contact, .error(err)) + m.updateNetworkStatus(contact.id, .error(err)) } private struct UserResponse: Decodable { diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 9c381a7764..03760fe3d9 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -1209,11 +1209,11 @@ getConnectionsContacts st userId agentConnIds = SELECT ct.contact_id, ct.local_display_name FROM contacts ct JOIN connections c ON c.contact_id = ct.contact_id - WHERE ct.user_id = ? AND c.agent_conn_id IN (SELECT conn_id FROM temp.conn_ids) + WHERE ct.user_id = ? + AND c.agent_conn_id IN (SELECT conn_id FROM temp.conn_ids) AND c.conn_type = ? - AND (c.conn_status = ? OR c.conn_status = ?) |] - (userId, ConnContact, ConnReady, ConnSndReady) + (userId, ConnContact) DB.execute_ db "DROP TABLE temp.conn_ids" pure conns From 762024dfd9ccc59c67b58d36d0c6b0ddc9d2cd82 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Tue, 26 Apr 2022 11:51:46 +0400 Subject: [PATCH 25/95] test: fix race condition caused by small file transfer (#577) --- tests/ChatTests.hs | 22 +++++++++++----------- tests/fixtures/test_1MB.pdf | Bin 0 -> 1042157 bytes 2 files changed, 11 insertions(+), 11 deletions(-) create mode 100644 tests/fixtures/test_1MB.pdf diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 95c2047a13..b97b7f5e9e 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -1306,27 +1306,27 @@ testFilesFoldersImageSndDelete = \alice bob -> do connectUsers alice bob alice #$> ("/_files_folder ./tests/tmp/alice_app_files", id, "ok") - copyFile "./tests/fixtures/test.jpg" "./tests/tmp/alice_app_files/test.jpg" + copyFile "./tests/fixtures/test_1MB.pdf" "./tests/tmp/alice_app_files/test_1MB.pdf" bob #$> ("/_files_folder ./tests/tmp/bob_app_files", id, "ok") - alice ##> "/_send @2 file test.jpg json {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" - alice <# "/f @bob test.jpg" + alice ##> "/_send @2 file test_1MB.pdf json {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice <# "/f @bob test_1MB.pdf" alice <## "use /fc 1 to cancel sending" - bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <# "alice> sends file test_1MB.pdf (1017.7 KiB / 1042157 bytes)" bob <## "use /fr 1 [/ | ] to receive it" bob ##> "/fr 1" - bob <## "saving file 1 from alice to test.jpg" + bob <## "saving file 1 from alice to test_1MB.pdf" concurrently_ - (bob <## "started receiving file 1 (test.jpg) from alice") - (alice <## "started sending file 1 (test.jpg) to bob") + (bob <## "started receiving file 1 (test_1MB.pdf) from alice") + (alice <## "started sending file 1 (test_1MB.pdf) to bob") -- deleting contact should cancel and remove file - checkActionDeletesFile "./tests/tmp/alice_app_files/test.jpg" $ do + checkActionDeletesFile "./tests/tmp/alice_app_files/test_1MB.pdf" $ do alice ##> "/d bob" alice <## "bob: contact is deleted" - bob <## "alice cancelled sending file 1 (test.jpg)" + bob <## "alice cancelled sending file 1 (test_1MB.pdf)" bob ##> "/fs 1" - bob <## "receiving file 1 (test.jpg) cancelled, received part path: test.jpg" + bob <## "receiving file 1 (test_1MB.pdf) cancelled, received part path: test_1MB.pdf" -- deleting contact should remove cancelled file - checkActionDeletesFile "./tests/tmp/bob_app_files/test.jpg" $ do + checkActionDeletesFile "./tests/tmp/bob_app_files/test_1MB.pdf" $ do bob ##> "/d alice" bob <## "alice: contact is deleted" diff --git a/tests/fixtures/test_1MB.pdf b/tests/fixtures/test_1MB.pdf new file mode 100644 index 0000000000000000000000000000000000000000..13ce57492fac05cd5f7a5dde88d2b91d621ff2a4 GIT binary patch literal 1042157 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

Z=@w~z=B>4gO`pBY?UXh30>Px;$dBDO{Lk{x zAB0d@;@GeC>>Lq|1dq{@y!{u8EMR1=w5eqTl5SIhFV4>j9ZAo0lhM5(Vq5PY5QLR_ zXok(day@ZpB#mCVYqs{shMuQ+FsI1wRFdX=0k62)p%UwOa? z5Y|A4Y-}=1(@NiauI-~I^^wx=IUIBZ7hZ81Y^m~pAX^KiYK@ovo04M8J5+svFLi@7 zIDh6y=5MNLM-GMK>Kz%xY;JnTfotE6cQE^%a-|48y=G`7FXLFP(hzymN0H6Hg9bb7 z7$$8Qlr1vS&Dj7B6mY4$yD)o;F8V;q_iT8O zAz{T6&M-gT=k}}K&4b18Mdj2^HquO`!!h!dwMz$JpzPTY>c}7MY?q{NTp6 zaEq|Sj=t9b5bNY~H~V>QkqDc6A}+|!&gweLF3j!L4Ux2KuQdK?jBR?quekzmaJ003|S`p-(IaRZ*v~*mM-;Ywf;cm4BtvJplHOL2TM@ zSp`ceVe;X6YRK8)f2_J1tG1PkOFP3q%TSa{-zokq!jyW-8U|B_YPsN$k%9M?MT=5h zq5X_KM182d3`_#GCP=*k0YY6a{|!PfGB0%b+FD9@*D5g{EWX7Dq-QUG@AhLslP_m{ z^ORR0A26 z#O_Tmq(ETzSmles=K2xLv>-La4fcIPm`&EWOtC#U;D4rd z)ho*926kI>OI|=!!9t9i-&$6)6vQ6v=ga=mBHv@_!w@o*>BM*S4vI zHEu8lqG|?~tQ87=EP9{h)Lt2Wzpt5>3|I}}HnqD~@fHBo1owzSBR?AAI{VxSfbLCq z-`$i)7IBUDd-l@vxc<_@;spC5p4{$z;Aoc@JNh#21h0qV#@Kdn1)3e;^QOcAu5#`R zx=rI%R|eCw&%H$t&(jll^on_V!Rus9WVGd>A7H5`5lZ**3DUN39iN?$j`$@F4%xTl zmi|foY&07(HA06J(^W(JIC^Xis93Soj3zSs85QhDyeR(8>}>c$PHf5E3Q(z;Ke0o$yO}`kPfFhjS8C!IC8XZ2hThTV8f# z>2wY6E-d*c-hepBn+ur%sS+!Ja(6KSQrg1d?BhIqu@>!Y&8SAuF`=gEs*s>Gus+nV9zU(Mgy&FSB;o*(a-ioni zH|`N*>Eb1*YJ@FQ?`MVRTkMW>#uV z1&=00zVt^j-5C<`vXdj-k)X7z=t9$DmJ}YqRfmqu{x%C+c6*26?-jn(H<^;Hi>!>o zz;w1ygy|l+rE&pZU~0e-Y41^jjQz_RVD?mgivEakS7}c)*QDW_VfnG(iH&WCgp2+t z-?(GVQN7)yH49E(G6v<~RgF7@n{Nfj#iI&px1O95pD-vBP(|Y#txqWb00&j(YGe79 zZ0z^G=lr4PsajE1)A^S#u+iyDg4x>`edn@a#3hva)E)?Yp{->vJ-UVug^kXta|Y&( zvo~<*vN6e$>cn*b-tCzYa!{N%*>2}wc+z` z3WTTVoVTD(_v!n?D|6|Te9VcT+zpywaqoT?3qY)d!lr8gz5yp$6#2DN>%Y}%)1fys=ijF+ z*E)p36z!}#5!X*SJebP)rmsVUtR6@ktXy6WH@wKg!R4K2z6&_kzNhx&RXI8W;E0Nm zCQ|Jm`nqr}Z`W**|M7c@54WinunUAG3GIC+ynqFW8-e5B`4nRvcu_hZQvk+!S< zxCSKBwxKGp+mE7e(r456F80{eWE3wXuW=C96w0a5rXFIs&QEyEOHb*euRYfIQx>A9 zDt^zk4Sj2v_`W4pI;2NR0-`2%?quwzA0vKB3SS(4If}I~zQ`*|inqArXX%I?D%CHDX2Y=<{Q;>CMV2 z3g{g=6=EKOi%~2ee#~fZ7^WA0n$B8;B+BJMl^Mb0W}#dHd-j|ptcQ<8hp_)P5)0%W z%w6crA}}ui0uYAv3kp}!vnUub+6RL;X3j&mm#JiNt33XCpK7e3n(gmHY~zKa@Lpb) zc3xLZdLu8PwsF6n&%WI{Nrhp)RRW%R!~n}gv$Ls^gI~nT6)B4^3cM^p~8Ea)p z7GF$1lr~wPS%W5Zak_7=d=~QijK>AoP;Z9xtm#w9d9H*@dgjXBik0=BFO7&zF55*v z&eUCWa&zQwx{GMar%_(Gra$UZrt__YIBbdL8)~1!4g|GiW5cLQaXjy=bKWLcBid^w zDLyW~rQ(3;A}U@-SX%XxQb=p{*jZYTfbVVgCiC52N0L@V%L!%b=EFnJ1N-mF%IA2+ zN)8ZzS)tbat6vK*>#mVcbJHk?u0c0u&}>pO`ECIEbp>Lcf@Z z)Q(9IW}ANfeN(nY;^(M)Y>d`Or_uNs2cKL-rIQ)aoVw&WYy+?f3md+_DsbPX#Y2Jc zBlSNMmMQq~<8lE!qT!H<*$9nhMF%-#5r~@tl-3Sh=&0|Lc3G?--bd zk?dgedd=K$kpyrvWl~RgM zbgq)ARN?Z^Gsa86vu)P*z$Vv4rvSW+{f-Vz=!VOdYP z?{QB43FHN*k9uLFfR>cERkkr89%^cs&V5NJc69k0-Tsg}a)B8G(klW4aTa@G?lDMp z!<4PI(s6{{0GKJPB7vK|((+K5VIDngkQP33c@gB{{ zP?NG-!9`zg_U%PfqeHN*d8^C}FB*AG)mUHzqpIL!g0coAPLMREJT~d@} zv8a1HQd>{2#nankmQmo?7iSn)XS@I7@>7JfZ%`IxeFHejBHtVI1)X}sO%bN{b~c`h zWGG*o@9aCfH{y=&@^7fvtZU#&UJo%TW1X_LaxYCHybQvreVd8X2?Ds9Z)K}z8 zK3KPYWUdh{P`*&Uahji$KG=4YJ|`Uq{=?PTcGc0V-gsa)I3$**rnjF}37pH4iw()r%)v}h#A+t0Bq}nTQg^8)tCmtB!MtnQx*d=c!cyQ!)rn`Ak zPp~2DO_4F=hqpx@bNuuQR?1jl1yoQqGeg2PHk7A)LY$3n;J&f)+wL3FGfNNvH^s{S z8i3}Z*9g&1YanF$mHU4A&0EYg8AOi9hgWCR-waq1=Vny2l9Vm$xvW{l}RR z&a-)_D`g)=+xYk$t_5@xgqAQ#Vuh9jjB#zi``lg5^yrNz>vs~qU+QMh6bi7tDB+cH zSD}R)Cq(=%_%@`@!rMwm!WaFj(4gJ)tFAPRCwj_yrDJ}f&(;2!Ey<@W|IYgB-Ox|g z^HFM=V$5zb!SLVMCe3O}(4?9b)u4(L=Z(WqzbJQ{nl?kvk_-1c0atGfFWYv43(b5?PVFO$TN>tyOp)7Pgc?aPhvBwtM2~5$REa-z zH<5Oel6qxiLRI-h zU`U|!2)~nV2&IS0mIiy1P@Cta#NcLVeu5lAgZ~}qYk6bk9*ILby#t9>O70Kt>zdyqq%^zpNA0}=YM;UOA+Nc@x#>>Sg<+Es$NzzI9C0m+Le_K|0 z&)HgD!g2ZO+eI zmg7t3F+7yF zD6@_1#(_ZFgS?OFbUh_EVOjj#_oC}ev!Dk&^8?wj5}k-tAhy#dFYIAtr;M1wJ zMlQkMWQ@aPpNz$XH0PB$&Yu*`mkD)bNmcJhcR0?Zy31%ItlA2C&z><@x;d`Z1?Cdv zqiV?Yt-=r8I~Si(9juyuyMdNvc_Q5mkxA?~RQ(APHD>W*fKo9*a+k~G0%>2f1hoXr zI4j#FQ9`?u@ZrWh&qy0mjR!_{@!Ae3PzU(lyQ7aD&k~n`Q{HFGmvCt3jJb zQsi=FIE%(Br6syprr99S*MK7KB*x5iQ7Cp{c$4C`3$vx(byf?rkyE9a>2VnaoGJ=y2SBBGaszb|BZjs4=JAn;|if1%Dz6l=THRPojQ- zrBNs18k+pQpNkEPk=4xP4m7Vt^Uo$^szXpwn1-%5i$)J}a}V-0nIQ+z3$~18%;r?> z7ZIoPAl}F@4*3 zA^bdLDO`Ds%YHwdfBo`L<=NBPj&DYFk#;tWb-bH1QZ>}bA)>po&(SUNx^(AbnM1~t z+833TlvvMBcHS-+k9*#*G~G3}i)$Ml^1--KxAHI-mkL?NIm9(ypvRf z#mW*knG@G9aqBa8ytHqMm~PA#Dd*FmX{eXCCN*mnIU0@GaX6uSq7p?lm^;2#X?vLq z!}tLsILB5H&ygtO4Z%ULU-u49n5E@09zt4fqHtE8jk(KZ6$Ifz=RNU77CVA^b(=^+D$Jbb`#-B*@*QaUI2$DbHoOy@k;BQlFQ|GRMK{WbdoxBo6kyjg4iBEVKBoxGQM z`~EF0JkgZN)bJDKpG1@@wV6oH8@?+o8$l;YCG=lC;n4CJe|nQ7G6(Eokd00+o-X-v z^GNy^POT}rf)@t;mc#Nm4s*ixz7zPRH1#wslKUK8W9Zc~@@sxoRN)PGYiU5VR3Dec zoDyCz%5gip)G?0xotf3Mq`K)UGM7)QI z*%<%uRO~w-|n~?r-Ne1oQq|yvhws{49RKw{U710KffK6Z>tj$-y8vKtcd8 zIm0c|qVR&-b-plfk9mIl%Y|+hsk*l%rqu%+?3nq%LcorsNWTw2M2PPN+aHaPyf0_6 z@!@6-UZ{uO&(@uOF>-g-AIzk()j35Q-O0@BF~^x(S~WvW`80*g@1b|b=h&pRTJY0V z;utxTG;Uj8IJ3LQ)Ua+)`jZ2iI8nNFD)(9M>WjQ4D<3y<#dq`BVDI2T9><{R2j^bAgIzdkO+p<%~Sr>uC_io4P1?x8OlJD^%F zsWp@zERQqOKPYJeJX$6;*}km~PpDTy`=||5+Bj|NAmRFNt)A6TL`r?C98jYP`R1p1 z_o?t;l)%2@Q>YMP@GFIAdvp2+a^`-x?SNopnNSZga*8z~*13rtJ80~tAi#cbWQeI8 zSm$xB0UQ^MF!$~ThnD+)w_;0G=tEVni=lai9+RYYfy9j$iHd5WLwN_XTrDS@vpo3; z6~Jq@c7_eluxrXp%493wtFGTougG%9;Uuob3AFFPAReBOw`+$}sf-|L9!BHew-KLO z=cy%!T^alfyk!MaI~((hF_{_WoavAVSa|W9;jE%etM|nNGmp5{^zVTM=$3$4Ij@K3 zWY~S}N!BfHf*Q?a++;kkv2;g!np7R=e;hecCv2zbF5RQAu*vudso+&OfBI9 zT{Y7f59>XSQO3fe^o`{XJ)u%p3@VohKkH^-`D}Fj@a@2>I8}xxX|dKE3ig{=`{@z? zJ=xi<{(oGI3YSaRtOVg9iu%I2&`M)NM|4h!%@^B9)33q?_#>e>g(JbLM1s}*7pXU8 zI|pWvds0uSD(G<*3y!Qb)x591A{STZ->HQLzV=h8ZT1hkXZJ}fgQWW`ku@YRniP&C z(^cGHE*_(zrj~vFPS0j+r7|yXmEW=Ypm7C~S3dTw-R9FGc0y4cwR8izy7%E?!&n(a1Swi+W7b@ef>$sUCkA9V1#tmFn8U z_*I>oI)Wd*2rR*zjcZ+p;|JImZHlkf#s(~>-A|OZ#`j&ye210#*g+Ld{anCk&)Tfk za}y4E=dV3{7fA$)F>A0)+gNdANAuDimv>4GdYcvrilkD(KWd-e-jMq0ouV~c&K-p6 z&$;9|U2bS*_c7HeZFx-TK1j-;zjzgf3OMh96sZ%@8aAuCfg zTWUymU=3>E4~5nCb@uw_8spa^EAfSu+8xhM9Dz-SoA(g8?LOu-0f+FF-r3##FxTK% zVM$;SijdThFE+;G=HLzLwOPB{IdaXwuz~jF-nPv>aQJTkIYfXPouiU%a3+Kn|a`|pCztV>+#dtdDxx?o~yV_non=DwBG_U;-qG4#)!rrYK8Bo;^2At3YU@fV2=mP~y^|6XdJ-c8XBKKo5NP4i23=?U(3>Yc@(9cjeF*OO!}EB|f>=)uz?Gp`Bd{q(g$F-U}EY=o^=v@>-D?_1zB*?S9C6zsXv zmD4>(Q~d`h?@!U6eN}$N>pn7@`P4yc_hkx>u;-b1<&=HybmHX^2`;;*d`!RM{~Ycg zR+92Qg(jr&E=ke3|1|W7@2BL0=T5UPc>*Wk6yL^mc{Xg~yj9xeE{zY?o6L_an*l4I z^H@Ianznz(M?IS_3OEo;X5=>Jt*#-!fLin;cKvftm#9IU-{&iF^yl0(E0+9+UGQd9 z%i%{Jd=341cwp&xqs}SnXAN(#a=>6;N9WH+a=QotD>bu{n$ST6{5gT9O0InJ39_5K z@Hi^E;D>#q?WVqMAYkaC;8Ea9=x!n8F(s^=3lL~t{aWGL1lw{+SvPy%M(uT@;=CY7 zv!A_R#Z&2SPHgk0v|vu@!hV{arArCljh2Y{@lp>0KDu8qooh)+UM_Aup&|$@4we@7 zCd}^6>6w316$|pt8ifb4emd^dK{RbS>+Alzz|(#@<40R7y<3Ir)C_N(va9Krst@xL zI#C;JNlH&^8;b0#Vbm_v^DWAtE z>klvU1tXN#-KFj<+)y<$oDL2NTA>A7r@SsRqD8M|3P_JiFp`cqn5ir$J_A~%m}zeP zTV;FAmdcaqc>%aom+|N{>&eGsfi|YCr!@-}rysWh&i}ix??Zc&vN}XNMcXs+gOvG7 z64kGCM~t#J*LKAHQS@&9?>0J%0jE2JX1A%myE1F!*uBI_{ zW6>R_)ctf^Qm7Qb|Ad?}$3a$5eLapZsK}tfkRo4yI8PS@9-XINvn@%vzqISQkk3*U zm&1w#Vu`t?6~tgME8@%Hq3IRNQP1}vsq;@aPO&N7z}y#o!rHy{M!o3Vp`~gar$LHe z#a^aob4>RKLR6p10}HqzM%glH#r;w2 zOxT#6YrD+YE)&GET5pOq8Gt4wQU_JCXPl#;R(WP>a3LOq-ESpkho;d6RhA!+dbZ*cS13IIBM+!h)!*$8d!1BqB3;BM@_OWq?~ z+(>K$8?)RZGP(un3f@%%TI`qwnIs5axEp+n+nqc3MyiNpMlFattf4MO&?|3-W;0`~ z;-Q!iFKi>zWyPpEd}W9w=78Z1NxUXG==vqIVdLj0_RA#^D0k+{q$pWni5}TbAk1-idHXT~xDfDVGGpHiuOgvw(@Meug}CVQ7&*VYe|lT6@d5XJ zn>O^z`OGqcgqhW?M3bf@1OzP!5XdI@pp4Hf!|H5^m!*0w4D-IPV4VYz5fSf*bwLp% z&F9tJGczo2H3`#ALi$@1TO+VM>F@Ed9A3E_eTL8&ovqMyAglz{4!KVdH~C>$S}#@y z+R4+ngS}EO-Xc5DDtk^VP?SO!DDaJYa|$Cv zJK}$7dncNjvcdjzwB{)H_t7O!Xu(nLP~mq`%>5V$$HEYuew|1i?bS{TJ>Dl#O}z*J zUC9~lc%W@+^s90(5A2&%AnaJRl^b*Ad$m{@u(&;1Hn3Wf=aSkzLJA@}Rnu;n4J^Ny zr1yP*cVBwhFym_2cN(sacnfd0imJ1hDYiEw$P-4c;T!wj@V?cE*4RF}Gz2{#mQJD3 zXngz6RcZN%8okfQ|GQAbo6@BG=SI{4j7N937-13kI7#O7&6nYtI#03-|GVInYa|j_ zeKD)JW{GgVd?ev`;$h2h66$#D37xV1IXz!`ESjxsO^ZC`tWt9+G{rSfjGbT0UAF@p zJxn3th2;0e$hj`4x6M03MgwQuPjavcAVaft8h&*cbE)`u{7G-7EN!^5n@` zjHJ|Uqpesj_o$PCb~cPA({zC2uJpkbPqq{O*EB_Y{OhIZwKwa`rEf4Ip(bkZv{Y*R z+ky*gCQQp*QiP=3|1PkyAtX@862_>)744>mL%~bDg&9`qA~Ha1eCb0CY_Ef2jPxVW zq-vWC`daOi8$a1K>)!BuvA&3wzhfh)EMpBx;#L$ODyr>|YzegWqhr55`xBc0t?At~ zBJfbksKWD+SIr!t=Ld#>(vzK>r| zET!ar*LBS6YODCVtoCTDE;2bVnDFFASAKSc4Hhr|KZY|l;c`{zBC zq+z>S{u7d-a>ALg-pRIgse=(Y6>@d9`9MGYX*Vs&Fv0Rw&3;h*B**mjBgo!RmuaAO zQt4HVSNE@)%`z|O^4j$|qMv+&WH=Nsnm^x?mT4wstNy_e2fk#a6oTd+3$yTUGzi<= z%eKLp4=~-Npsj47DsEZ27t%I*t7baq3}9DN+mvt5kKXs=OMEVS&L;>rT?eMt1|CQm zTcLu3MsXe&GY(Ql!j!{y8y8O*x9B{Qr|)bOj50f&s0?AtVpi308Ap;4szh7mQ}$3T z;9G=jFkV6JN>(JpGL59X;W4~rwPhz2x@cxN@Qzo=A6_HMq3<@MEhqOEK_UF=fZV*C_G!=X7hWbUc_%M=EWUy3|h^gW`OHCBcPKNUGC z=3Z}qtb3QkVRniqMaLqh1d$YMGsfj&Z^%rY(6x=rC$+~*7Y2I&A$X-^wcql;Q!X#R z9|H3b#_>=6R`3Zdz2C!&odUKobTG!bx#s4k)s|PuJ#+!xdL>97&qpRq+3IpEW&mZ| z+E9Ikj|WbB9L{PsZrAa83u$)=a7iY{>a-FE53hyNt!##BAyBx=}~{5V^Zj^4|7ZRKB20|C1J@@=cW;9J!4{Ce3G6jFR54itZ9sh zRrA-13;%{I(d@-V(@k_Dc8`ZfE}*X=kEIbA2Sc@8e7P*|_9H=`jA~)ZNzJD$=q-1| zd7jz}>3zGezdaYa+n$}u4E#(%?)z*FN_Z@L?kGCKiGI0vNXTFAq~7mEb!MF6O=GT3 zL;k}b*AB>`p0&IBGyw5F_rvCWGNN=u*w3bXhhHbS{Y)9Vc=p-{R;+sBRdo2@g_gFL zDK?ByVP>8}maoUZ05y#V(lWnkL$zn5b25JZerixt`4f@P*K*2e2XjeF>E}e|qGHMb z?^cad4tsQfCo}c=A|?)TVp<|7hc+0VTosmflPrIbZl2LwVy?$k^?s%(dP-f9k-zuc z@c@pi+rXqn^OkIFihI9?bzLJ;ODv^INr^!*8S!C7ViY&3rmwX%b(pKDGc8uiKcq9_dL?xw;971Y#iWu zr+b9k`j2%aT&J}c2napN!atBe$--%=y(u~$2c5t&#`g&@i-ONG7yz++_nw&~9EA4S zy;MQUIOQ--X7*(^1_trobIF;|7Jcicp?lZTy=rTneJ}Gt(}8r5gSdK(W-M5iSOIZM zoyq1a=Ut^(h;WPrBo;p5rd(s95^9Q=ub)TkqO++>@)G?a$d`V#u`T6Bm8r|}v$+0jfAEm=EZ}i>Oq-wISQp>+>=W#{opPpp9zHOqbpY><9CCo>M8PKCLtoBn(x`@ zV|-U~=Xcg-)=|Gp>1_IW!M|^4iXLO_(zCC67u64hYtN_-fA&@$ENr@^lRPnI=R~Vr zaJN`CT8j*`7kw4*C+w>|?He3`NWP%Wh*saY;YH-WWtxA}F=c(MPWUvFi+=bO&)IZM zsx)~?yph`sz-AoKwgHoQ{l!VgK;n-PQ*WxT2%z)vWcG5vgC7i-SK!pgJ7%R{kJG}l ziHW)77o+ajzhZfTe5gX>pyW{?c%2xU{`*j$q*D0m;{t!hkx)}R6!VjJ0qQY||4P2) zlcmSH!j2P3d*=_adB=OI;ph6{KsAmH4AHVB1`{oO?4Xb?hCa^wNBX%;2Y0B~w5^Zj zSf6+ZVFgzR@;a5^@0rE6YZmh8BQaEVC0K%5-60idY$iFEF+^v{bf@Gc;#xf$Dm^cy z;R+SEeQ&O&`W5iys|U7nCFd+tJ)f5UZ1WZ?a*FA|ntiI&%)~pm;f?9TL)E{y0Ns>fNF3c!3=Ci=e3m zer&pYuz?MhW*ultU)+RrE|oY?glRAZ{Y+%#T;7+^Sf+r8?+vnT$C>zHIfrFZqNAkn`-EkI?6WQn9f0AYn56%Jr|=CQ<;4P^>GRFE9goV zD*R$OqnG;U06KiLPb4pU0J!{=!5HGr?P zQ+Pl-{atf{~A$mNoj%87J?qm`vK(QTNqj=;18vDLKQ^IiQH z`dQECLgwS;b1N10bPo`0HS|{S`^IxK^MTFea{lmh(i7OdC_#T%l%pHf8XFizQr4;T`p~62Jeds*u7KCZt!O%W3`km3^>HX z7AFTdCo_HRtj9GZWsn8lizP1BZ?gKrkjeO|y1$>6v!|n?itv+i?#|A8=8d~LZU>Mu zZHZkq*+y8HKPI zFNR)*d)-lQ%ihkF6B{!f=Z2|=fYZu#KuMi1>Mg^@QiWMmg%23Jsqv&W|XoLJ(SbXL`C#1 z*guCm*AK5r3HgMY*60-l&!2%ic706Fd27ARhsnsUh=oKP)T)5UERnsi#1pD0w{`7w zh&dXX(vE0QTTJ($N~{6(pYd?6U0x&6n{ZQC(bYh~^)xn!KquUm(<1pVJL8A7LyYjB z1-{x{1(njw7uotOsvYmlE>y)zDUobz<78e;lU`RCcNSK4itnRey^M3!S)lTKjUe|- zjoU#O_AZ(7JSEyXDE&xItfjc)_fh~$!(E5p@DW`ignvRa5Kz+xd#+gXH<}p4@FSnw zX-v>p-@&A#0w!`m$-8sb<^f6XiGL;?6fJ0!LJIQO|1Y;@B*T7VxJ2$5{SJ|S3YFUeoAj9@^Y!oFRsyfj}j>Z0=ZUp z)dIus!ZZD`xh^s|x!ics9&{Jh^~wlg(X-bK)+=~tv0n46ZmOyd!P5@bU`&_4U(B}H zjmCqB@)EudaZ*qf79jHdCrR#CK zSv_c~7GFqU=-<)dICTvS94Rq%iP zj+VfvLSM`>{EQ1|x~_~YC7i1?%B@VM*Wo_09orWdVeAqD1yebbYggr6lqSu&T;^&^ z5>+w(WIEQ-Y&s{QD!iVhj(#6{vZYfOZMHB3< zPrMbrAtd}1OjZS77)fvNXm_bOi!iP;mjK`A3}9+HPPod~8X_%(%uF`-kXEtN?{UDs zPA#0}5u-}@=B|1ZDN`yX8HJ4&myePf$YWh#`s}Q`R`|}p8GfB}-}#JJu55eTI~h^Q zd)I7uJZ<;C3+%a(PR^lLic5Xq**al0v-LcX`g`Am)rxR6R)fsLYekH~EQtZd*{)Km zPEaeRYBDfigrmwgFbey4I&$}F*0CDy4!4`&8O=+1W79P>W0E(8dug}p!J~0aM9wk3`6eL)^S^se|8eL ze88pR^kYDLdG`LBI;Wo?ON1{GJ-{A6Aoop+4_00v;X7>H8SpexY-s-p-8N0k5*!1g zWoZom#URDnCX{7;zl#J+yKyIGv|kKZ_kG?FmRvpcZK9r*GN~3CZKsd9R`_CTze_V- zT8d??SXWmP@qE+ewy#FSo^D_Qp}uQt+r*dJOb=I}h8)(vxLY!Md4DDQ$=7q{PMvmH zcWRB7d^g5Lzo>CsoANbff)he+uFsGslLjU=)O2G(tZ3dwzJHyaPZM$f`d*?XhCg@l zXQYe~4zB7v|L+3idgXcv_!>{Fk;(M2CMPr-+T_=f>!w_x#@AMOiofYAn{#c~vV<$* zp_cwHviYA($7mf94zK7?c^#@0htd1oN zd%Ae)gLxwBGu&B@fz@3;v&buV8VxP!3oPqi_fb;0K({iW$cw-4tH>pcRNEgMXm<aDW_Z8fW;P<&>qRa0p1iIQJ7Jo~nN`=5T=PcTbfD+e=Ef^o+w8N# zr&l!WhPfS&W+TBhTW1efFay$aScUK&4@Y&u5)P0P^KIssTkuh4$pevXyCP;!WISa& zyoT-W<=UleJ0;mrG*`ul*>Dt@bF@o@yevCp(|DlJQ8tS+65=Ev)S;Nx^V#_%*d;Wl zT?v4Hqbk|l+VVl8sjy_u9hY={dK(5#Hh}MUE$`dLbf$uQj9_pbZVG zx9xVu6udj!X>#Ig+5U!az6`!yD%(k4$rA#t{y=!h@I}2PDJF~34DHIlBzM$a;oGmT zfkZtp`!x}0**dCmJ+kd*lr?IM8ZL1!7(y9s%mwQB^fB#vLBdv8aeGup01S*7=c|S0 z#bzCgSM_jAJx%@Rh9SXmY=hBE_CR{T3;kMR|EoE?8J6l@AiYxN_PXev1gC2S07o7o zhGg`(SJMZ6U4gmxY1{siswYt*EmxrIFn|J>KR|U-jsCqk1OlXx(U-iluU% zsk-34QJDiR;`@Rau_%P51GU zn982*;2AwJjWm7#W-=*y_aPkOYJ(%(ZH+pmTwcC=&zw5O)%^hp7-A{5d(<^f2XP|+^>Xu=CYJa z%L3GJ*pC^MDz6@5@*Vg-^W_9(y9U2910s--qyH5=jToy5yA91;;l|T)ZSG15?Vq#9 zKk6$DytEnb8nVG{b%|Vg z$9)@uU$-sc!@MI9t8H7Y-{%?BA_rgrQw|)!itlrf(NVF~BEBR=Ny<>gYxA$|(Ad!5 zjJC#y+rAx&fjvo7c^@e9rOXP4H_ZuasIMJ+o(Avmh>y~j*&t96xm-mF<8Sr8agwnK zai~HU+#Zy@RI$~_#;5nvuy2j2;k$Bz8$%njQo_@sncR0yf>s}=SER!zal+-T@>cz0 zVktlDm67!{B8N7G?SX^zV%UG4>Rc}{hvIZbL}LLpLrXQct1`o<+lh~5g%9rc8f*OH}X_7}Vbs<>Az6Gbp|v{0Y5 zpmBgMQv6PX2{V?L*0pz@K1~Ykr9c>%&^#2^Qo>SJo1gj|q)L0RRJGfayNI2AZ(BO0 zJ^DtLbQEezBU=7%qEZOUh^nSQ_52tj!rts(H}_i@Eg&x$eEKjUZ(rQ2A_L(ByaO`8@|4$#g7;p8~Tj> z9eTLfS4kThmyGcexVyENIhl6aCrkyBk(ZSGu@`(lre%U^*%f&>x`sD7l@-h&+Kd>@^ zi%Vb4cS>NyWhnDCddgg;f2JfN=ZPJIh0>`BB`Tb*j)8}Ecq_du+k7uASRY{JZ!81g z$=`0zFW-`q+@j1KD2gp^gmP(Dw^KS5^u@{UiOZp0j9_JboXaQvm1!+EYyXpOg&yl3 zEbjRWwMwja&)uzEmqsC1%dF5U(5eM{y`2495-9gn|Mu7}^)y)HS$h2YoNQr>KT1Et zh9zoT^(MEc*L1~;Khe*);TM(|uI`~#ZwA^%YEQE8I<*Q_*?m&@^xHA)d z>zzH!VSt}v6euS^lDWqZ$d+r^pETcX4Aug&!Cx(OB*wo++P{fyXAlSMJV@Dn?CMDk z))CWs`x4Rqz&=-?@JY(CaL7%@u~~~w1RZ(VgsOlRMr23RZmJ@!X7+XBFzAswgFJw$ zz&B9~R=)bDddVN`I!?4yn>xA=#4G!tarfh?IxVpuLSO+=z7eD${Zm5z=Rt_Lpe>ET z^E{yq-Z^?ItV46G4{~QOvGYrJAQ*(yJ8X_;IvLA-e<)2g1)v#C*V|6-iSU9BDhaU4 zBGw|*0T`>L>L%+zYH)G+FzERe)xi2~2?ov@Gxsq(ok8wsK9(s!V9XcI{>!tGi6vyT z>St3be^MZrV@E$jKnt=jJ*++P$mvisJW};7NAOp88P{z&Tjqj!YuXY%wr7spuY4lf zd;HoU+;aK>Ma5BzalzMTA3rt87~$h(fY zHduMw1-m>@!3x%)D;YDm)No)C4jovc6X%|8{ryP(cyqRYj;_$TpGj$*vfh}X)i0Cd zn8{J7w)m$#gXCIV>wFQU?3$aBO0(WRM3}#Bh+NR&V_VZK)*38ec6QpZ1-cc_8Wg2QFa9%5u3=@rep~PW} zE)kbsFUeb#QKB5&I3tM_jC@Gb{kaemfAxOgBNqyJel<(nYw38aqr3ZF_3&O>k}pS~ z{@81TXjfkLTAqfl*}e8o3+cxk$UpfmA^XyKkus>#P@beZrF4$bh<5AHLz2W~Kvx*;b&) z3)lj0zI|)OL-JQakoF+(+DO;z94JJGB91{gsiSnbYTc2OW#*fxO;(zcq5I0KILR4rD_a!y8j<(=!_G@h11hYlHqDL>? zgtS|oYfYFEmr^xa>bd{2ovf$x_=&PnP!)CrAufCytwuaAUrKiA;RfJZhICzr-Z! z%umhm@P_7*SE{r-Jx3EbW_A`mf0|H{-7T^F>y&mGb7%RFR0|g-%s|*v%{jaC3|xk$ zyMMwB&5P#a^Aaw7ITnQU6K?2&JFymEsvRs019Iw}3xGD!kczkwR!gX)GAz#R@2IZR zy$-u57WSH2`@iIiVku%;hIGVdeWM8;qR9)^C`}JL7OHZjNx9>k=_|JYtH6T1AsHo+ z*6dIL8lr3?6Ri)H6T^hCk~5>~{egi6QN?x%_0W>?L%DDSl-LHOgB|N>bjTRPa(>$T z^aYUU>^s!ElGwYG{B89S`n!L3u(G=Y+X3%H-*39n9ad(DeUkiUegOgn-ZKU)Nxu>1 zuQJ|epQtMazl;yRRWL}yr+AgSxuuS9c>hRdyX2ak8nYg7yn6|V^#g{Ma1D>DS}+=g zZ89f#8IC_#$yQT(_jOsZ#X{}}=cg650 z7nWfQJ*UD0wJn4ny58{y$uEc|bGU%|b8a4%BL#iBSVq~5#P`o07~W{{%eEbHO^98srmLcUSOljn&{B3Ey*IQ_B@ZrlU2I z^z}Aq!-lxq-Kuq7Z0dwICwV%x_(aLfPD#d6n_7o8MFxj0USk~JVr}aC(tHOFz8bxO z+rc!?RM_N|w)BqRtl2~kj8EkN@-NFr9vyx2o;>s5L^pSi{J#vLnyrxhy&ZKG0n{Wg z{zv4gKz3W#d}H&BkC<22b)M6k!9F0(R9s5@0Sdm{I%fJO^hvA&#iNh$lwa{6)O-}(K$|PAm-e4hK7a<|g*hB+*IWg>vOR0;iOR^)+A$=A@32vUt0F%mkrkKwh}@E>^E z=BvefF07QHA5uG9pfxb+2}yeX03oKZCrh$ldH@CwqNj8hnB_b?YL*|}9`q29y>E-TllnJNqx?SZ`M`=A z{+X^hvYR6;r!!&Kt#&OXTlz?%T=ozq`l;<v$W9O)Lu5y@Okzlv50i(5y5m%DbI9wA)Z|p%aLhGuy7gNg`AQtpU z+YOb$M=%SC8~Z38dWT?jDk7P5hW&KJ`JJvb^zi-0b`9iuQU%=ZsVy|ob}}b&B6@j{ z|3u+l3{ctHIj`qPVZrJr2x7xkv&wW4WP2ej^Hqc_yx3B`&3imajf?KXu!RuFg-s*Z zU4U;#B-!ivwg)EC(tv96Bk!o|pXE|(H0pyN#jpBf^=WJAgo)1n;qN|9rX21(S5yRS zopMSb#qffB%nhQD?3omDc)8zTd!27{)%jEPRu-1Pw8|z%ko`_=`evvrnIay5hoFCx|cTk+8I~J zGm`&u;^|9e-gASI!!fzup6O=A8F>$Rx`aND*+o!(UJaFBjB0FVPX3KS7ntwZZd7J@ z;i+vFzl=<(dfH=86w2u_X;Sug~v|r@3(dY z$wuWeElTGQZp+8(ey$%PRgUWX-RJyyQzcb0g^XMoan^4wWudGn{5^=h=WKe92Qjq} zA)GoIux_R=$d;p6Rm_o*Y`D**_YEk_R9t%h)+-y^e~G{GTjwno`FEa*1DzWsv-c-cI>TD8a_aE)DDjW5B6O{=Bf- zbhgljoUNq6l`yqDmJDS4U=z#GDHv4*%{6%PVDWuNeHXoSBEP@XJoLTI@+xysrqn00 ztf3dsxV^~F?VyDJ=3YQeI8`Jx03Vwc&scsN)W(RyiXp}!<%RwF33F^`jL`v;dQCvR zdnI2Uafh97vskvj;cK^V`{W}zyMuN88l|^49>sDbz5ZHR&ZfhHXdY%LaFuBWN;G%t zoqhl6oO{Eg1u})wV6haQT;9zP?7y{%_?ztWmhW%XKCL%PfS*(5)}Y!mHpXgyqMLcu3t5HB>Bd}9bzGL=)#ZrxhWOZ}8dClnz2EW~gW z=qWiRL01+Ae#$a%8f6g3=p6aK{}l01(#>1LW>#(*BuFPwsr@Od%@y5GU2ty&06WT9 zL~cv7M&?a!k9f|ml*jIqWyY#?`%TD51#)m|>0jNu$EoKvLPkCTe;G#=RI3A{wXNj@ zSeP&ohJQ}=R%KL0mvybOIi08ik!94SA$jrNlqKcd1@*q2lX_y@y_#n%o4zz!x9KXr znk`q?Lk2z(J}1AWw|b1z$ZsGxeH(xP6n1@Rm`&AAsb%yW|*RSQ;gVsh|(5dYchdcSf&bvx&&I{5f-vo4BXNj+8@&6wZDtajT|V1C6Y1In=wU&`^9D1lGj>_ti!NXthi~W-?>p7oAvm9tC z(#OynJA+g@;z^;kB_S2AM-O>0G|)NDx;@V2kyHsRzwxT2oDg%*aA8U&u}>lp_qqeN zXgK5u?Ao7@eH3Ic*h2-ChFyD;a-3)oQ!=*~?5y!1IlmEAATVm8arfuT@5#vqb-rrK zHS?Vwi%3^TqPf0Nr;7pj+tQO6v5NiFUDuI2@-^Q+#R@&;c8CK6c3c3vAZj0a*f~Ec z1(+w2zc9*VVJ3Nq;%&ys-TFiUPi66PXf=#(WWRx_z_;GY zO(TUbn}@Qj6E&@p-jmfIFe&S^JJYR>$~vs)6vy~l@2JY{obu5SA-w{TEoxQY z2GDn)j|C`@XXreMQwJy>3dVz>shJ|=GYo?c>#v2}8QfCFTa){BM#$=d79r$JZqqB2 zjcab!1ErTVKRoGf16;$HX6LxN>;=T$^#q(2Yhx(Gx6=b&M^4)5u@(aqre~lcf;4Q= z8@cj=uNb)t)^G-A6J$lyY?E{qi{@|WZ~O5t0`g0!E}~=$XOM>FN1M)!L7!x)a7Ri= z?~)m~Z$j>Y%fS1b)3WW?H{Uwhq2Msw2di_Af_MW-A$s`6_RPhc53*}D8sspgf8p*M zUgZ7-LMRD6<-HsI;4At`nhVs}3ENmz6P@l#ApZGH$l-nCl-U{%uOLG%pdw7UgM^O4 zlE=&i?VJ!Javx9SqUDg5sY;@FZY~!0w)rcN)`**Jo!}XjGe*XzhH8x;t#SnR7}1MB z%u;sbmYFVp8Gc0W6A-N@kYrW$)-*)urz&QDXdaXF__yt#yiv$G=v^yr%HyZ*Z;m|) z5w0J0V+0TvQ7{Y;Z{z@olMa;xb+Vhat*oY?}dJurw6U*8m-*`f&poUakBuXO52 zO>b+1W$N@8l=6L5u2c5UYQ_mrgdED`erm}p5-xbXOKY~~V(i%s^HtP-EY1J^Lkst0 zOtK4|q-3{~44^(r0*|b8+JcVIQ!Y;w)Q3M`rROe)rq!w`Zr`;^G`rb$`%K#GdQl)$ zWAP$wRYhrtpmMRg z(D!O$3gbi4zdUg#)9Tm2ck`}Skk|P}X4ATQ93B>D4-NfF2U${&h!2Jx*Cq)+6|Fc3 z$9E?dH>wvKNDGN)7~fwwuB_%S8)f|0ckKq3UEjZF0|*s0ZU@Dio9@Nin7FJk_Mt~@ zq)uCXJc;W}8gymqTBjhSOvb-B!Wu01GPwyeWvO#+ z<$kc^^}wDCGnA=dg8itO9lF0^!uLny3SH5#r_cTeSRw63U8VwDGcE)xTVsJa8uM?9 zsG4t10KKf^PaSu7LG>P-QT?X|J7qU{`6pqTgz9th&WP)7HRQ{ z?2d`!FUrF>naY!yi!E$R!b!qLj_6ry@Da!R1qN_U7c<;7B_5IMijD?%l0X%xN>gn;JltlcLLEZ3S4 z5kVt(L8@aJ%!#}Ezt_-Wklze6&m!M%O!-+d2>KYp>>h#D?9&=r5)dbP3JE;}G1A$h zLn>06vcZB;jGNg2+o5tr7$C!)o2DAVA-tTvYz~G|cHqcBbB)4`#o)sTHbbI`zvRJK zwMaTmV8F;8M8l!)%AMl7|p zNBD4g>lPXg0$He*vUNY?P|V*lwZ(`apzZ)zc59W6ib2nqEQu&qy4ygm13LCL{I{L& z@sHe=Sn0NGt^*-enb8LZZg;}&L#yvY8^K|sqSY9E7E7vROJl6{Arc|X^O|uh$YhI2 z(EfPv!E3>YL@J-uoVzI`iY8Sm8pg<XS$%1j6wE6S}3 zkdDk??OKi681pm8jf5-T^8|?d!bO;ZSPSBr9JBR8VfH_*Z(pvnMxJ*cuSfGWuCO1( z%`9u@s+7w#sM)$4UU(WW?^<6uD^&h5acYZG-gzur4MgNR?$dfauo3SXb@e?hW({+< z&{ue2GB9k<0^KmdD0*(FY<8eLw4(2Q!& zM&0#^bJ}T@(WiZC$60)$e1> z`+9>nW|mS&)uw}^t;6uZdgzPi)P}7wbuRy5 zXM#`G&3#7zm=1<$FWsbMx=02rDfN z7nIMl`4+|JfmaDvX#d}QvrS)rUUSmq1CxTB$Y@%-8YZp>69Apq71t?ZLU7XB{lgz3 z49zy5YK6L6H$2?G&mo^fJo!SImz}s4-$<-KAl4U(SMCenV>=-vuI-SN<&txA{AK>` zXdUb4+xf;Px`JSK{VS?0%c0)5lpgi0%ita0HowV^sY%RTj0#WAL8+?qx2tT?n|w{B z+Ep8425ocVj9h!M$zjE<59cWYL3!mZf<}RQBZ*+EFMDpe=05qqPW82R&e`E+<0&d} zkJ(3m8_e+ZBwd6>-LGn9b0whL5Egq=j zxJjl_r@}0*X!2W_4dnP!#ZY$O8q-M=yayeQbJ^iQTtxAO=6BuSV|Kn~*OZG_M2$c9 zYUzBn;1e|7zG!uymO|0hWVUe*-pQeVA+&%gt9oj zCC0XYTbw0nge+Y_PelKC1*`+0^O+XZZ*4T?VCczQ-i65PrW%4P+&bLH*=BHJk3lTs zZxu`1gbj$&^|QxVAL-eD@jV=y;xGo=GyyNbL9K4_rX%J_O0Ap*N5Kmcph=VCf=+{& zj*&5JnPcA3Bk={=Y_d2`RRgE;gv|_UAaKtTRyr5h4h8M6{ubwdvT1ncYW)}45}`dS zVsZ!3HzTz}l@IG)u#6U{a>+c)36v-_&V`YRJVFyvh%ocqH<*uK)jzzN!iS_Eekt1S z2%>b(efdpdqmAGlu8ui!#tn^dWfYVr^+)y_YWI8=24ZyI-;e9t$_^;#d!F#HRwTC~ zQmEo8b?l>_?Y9|0C+xX;*4QJubDurI&*hm1vDqnPvZe{%sd367H}^N}d>T?3>t68! zoTTXgY=?LrJ?c06G_kC15VFsc=BD)1_3eVsdpdYRkK0pU)s1V-i6GsEi1#+h!U#0?ecO@lNRz)kA4nGX$E9Yj?I1(*9YADx&0}&q zXo49OQ$8L6ax71-NDLiyr}sG&Fdk92IO{;0el3h-I=venS{7y?_9dSS2hc5ZSTWh- z9iA@>Ybs7dws<;!NSk8$^ad}JFv3d9@_wISegi2~OAmX*2GYP@enUX6)B_ZLLp<+b zr&f24@Yt9lEr+?^7FbFt^uOP+Z5`bV;p^FQjvy_)Z4C@gWhYmKIpsiVn8VNO^Y^>E zKlgYTq^XVDh3n3NL$c1a=iHa+fdQVqG#zmtK$`(y_cGhP?+Y?V8mkDs7HHls79rF@ zO-3$>tEGI9_Ao7GE{u`OlL~q0Z}OzJFz7Rjb&ut#8bA0taiFpRG8e}&PsN`|#Ssn} zVj66n$j$VuFm26yHzlarevp&q69uqXnnJ4LMkLaqu(2h7K3#>!I=q;<9X_3EfDRdH zr;;Z7Fp8`1#4})g$ozo+dw{R?dN_j1mJgq;C$m_ve9pH?GnW|0wByCYX%og_ePn68 zoxyn<_cBf?e+OE$n-mPY@J7t7kRi1xWm`EO8j4+Kp2K+I6UWF(7_E8G5#qBCw(8Bc zh$v4J*5J9w9Gn`?cLdkd-rxeg^s~CESYZY0U4-x&bnY=zVK~b@PC?de<5i#LA3rkR zPSZCqI(OTwk4JZn+~^FsBsTfD;h!DrPz=e;S#mmV=fA~O*|RJFd&h=gVVV@$)q8id zo=vOCpqFFox6Cq-F_h)w%PC;b!Ty3TWfiL&-(>BD%O2vJmIZfI9QSufF& zU5L;11G;u{5Lyy)hlo=+a%c?EaK_gcZ60+#sP*Z@LUwe(I$%QfUDCNG73gWH#Zmvf zY&(*csrEW~8H;Xr)>zLIz)ZT~ji7jb2Wi_IG-Qd-K>?12z(`Oaa%QUC68Svl35RVU z4#q*{F-IK7@3vlGYjwbi4~jK*7)+-h%cB31ToRZQEZm%&{3C^^hURyhOVV++w9YJKwL@LtD|=d+=CQccMAK2k@>#Lsv6!_=8- zaelD*gin+%wCJ`v-^9jzm{ZeGfXr%3NPBTw1Lw3Ie=`yhP)%=diFqosqaC>NXG8?4Vw;tiI{CXhsPt6@)@5zrNG_Ir@8-P zK-=OFJhmXiMl53^=M*5JVORJQhnp45 ziS|S#>CB8e6SxLPQ@R#+k<44gCl4kvX$zoh9G^V?*32v1Z<33Dc7^^LIZK+?O_6lo z38X@n-T3mnfEiXy&~QO?`9r#|X@UT)^a^vjY(3D;WEJ_e)=}&;S^cx`(|!C{OT&tY z68xc1;xsms_6uOt1W5OqxlM(K(ffW&Pi4+wwdsM!UoXlP;JfVUdjq&)6kx@05yn=H z+*|A;oSMidozQHZ?|Exv6|R6%h0h?yyfUtTuEzx3W{&gX-EI3+1@6&(r9wvBSvyc8 z<(dXIz0&Vo_6;5-*`6vPg%trMha%Br)<61EE7^_5iOa=}Sd;W+!W()n5zj0@t!>ft zt8xN0Tbdhp``w+uA3%Wzqn6?i0nZ)+7GN)D)Grh!L+r%J(kae~C9VZ3;)#HlH@BUN z8I?q3=O8R9ej$XeuYN7aNSizG5h*Q-G>YUC=FB~l0}(BQJt6W3KiB|XGI0|o8qAsL zUqfr;mJ+^J;z_47ay=@ctVE;Qn@-q z$25)nKMfH&8lS?$oOF~lfYxuZ;wy{T%rQew(bcuQO~6&g9)!kfi<$uDg^NML+_tb= zeP}K}Z|Rab{h7#t1U5pA zS(3{YK{KUp^rpH0tEz|N)Q2y_N)Aena>^W#j^s8`8>HB96l>!va`F=mETC1a?iH$T zZIWxN6B4d`BHWdq4V9>oAlRB^=@G8KnOJ`?jUj7RQYu{= ze0m>6dX>IL%(|=|`<((FrmxFlkD0W&*Fho=vt|8{WK}*EP8+6ayV4{*i)q zy-{%7OYhxZOEe{RB6Re=FYJ+6zRc{qfT}`mMnyemS}eEY%)?Fesc}9sl;D3cySfnW z60Zf92IR^oDwU}_u*_SY*ShMR9GHv`DSN?Qj=>HBpc<{iKldVQpEeDzFl3#k?YG+G zGxi242lzX1QB1ofxgc+Q?5WG=21Id6ho&tP0{!3E3avgIvGD1v_ErxbAWZ-GOSFN$ zr1=!U!51pHg2|0dHzWr6!mQ1b6%-=kf0{&6Z%}x2{hQp-o3H%OkeC`=gWs*D3AgsJ zYxyN1D_QLzvR=>2jqVMv0cbF;uYcU-9_((OeJQ=l&>e3#i_bSXGQ!r9j3E=fs_HUy=$Wq&Qx4BaL zG9U;}J;)4UOG!V}Y(zd_RL}|?i=AOnd*~pPyCTBpHFtEK39CTasL!hw&=#LV$2fNN z**)TE*zl&9=Hi^C5Zr-2rf(u8fiejfQ#Ki*kSBXazq+ZL8Cwm)KTu+!iSTM!!PhRD z=KQ?<@x#=^pRknVL)o=?t@?|1E#LtHX?M+!`eNyse)HzS`k(jwI_ZnK9C(ulU#(sA ztXpy)Q+&}&7$UixgY}QLU`CTZ-4yu5BnxxSBOt`CM|58j2l1O7XWuV(q1MPm)xUCZ z`{k_+B|PkDZTX^bAo;)lEQM4>$8lFAcXhV_RLrv^U#e$EtT|bq@$PY1HbjW)!>JAl zWl*a}cA0NuH_FMk5g=H_A`qbjaJmrS@s^Qv=0;fH=-KEej1`V$wYIml=pYH-G7qa#K+%-%%pHY zJ9rRfEou1O{6prEqD1T2vu={_WBTQin;LS!3-|Z<3O!_)P5P7D>j*XX z9e+X#^gtBrG>b0e%cLq%+2eu`%=?EN2;aT>$|5&?u2lPTIlQMF-_j;} zGn4d#e(u%4uVk%dVb)g+RQa2Z>U1Liu)rdi-ar=hD}7w9;a-(*KVb~<&D~OX^gx5X z^+(90EPlB>TBHmP2G!fQfjlH|(M%tZIn|dijKa4GHYL`21Ix{h&xLDc2+|F39{y)& zXR|kn_i-UZTKx?0j3Oj)?zLwBQ(s}bg2x_<<28Kv-me-t<&r2(*T>RM9RFMu%M(Pt zg01c3QQHqQ=>~ho0+9YDAUMUpTur8(NI6Hy{ieA;2vbg0#=n7=^mne`aB;Q&?>}PB z?5}kcZA@Iq&lAp0=FG55x^7{GwfleRyxvvG{JoX8oS@@RzV5+qw*)O*@s@g6p1eaP z?0IL|$XzsG+2>(Rxw?RX^tk~^?E7})Low+h>C$J%gEo^|Bd5;u`me#eZBP3*FfUmg zxNG9VxrkGYi&^wtE;NJ$&dTrMQg(+^LW0Z}iFRQ#M|s+uc5wZ#CJRv>dJBL^2sE{M z`KD!aWa8HReMX)JpA*86eNyl**acgB$Z$|DK3?(?%jBg$@g#ZmUz=>bsivco+MPqC z2#~Pk(&Z-7TA^?CvS3gl2v_n9v;lIN3ybpJM)F3$j#lO%>!i-(Gttp^@#D$(1#LoB zngKSuU?njC!GR1@1@5(iWLL>p@qvkUgkfGz+3#su zquEa=k@jlnqh9jXzS*+w*+vN_>Cn=!iA)vuRpBiq)9#y0Kz#r4M{{1-NL5&bx>shT zX2tk!rbe34k=%P44dBXjl%YkWCIf2w+eL@LW7j` zRE}mC|Mddy0;77}3*~sqEgm4%$P3VgL^D%dgu5G{==)o(nks(I)JMpO^M2red5Q+ezk3A3c{< zWQb?Kf-QLZyYjLg#|%-1y1tTLJ_^$Ch5YfZ6#a9{A_F&cN6j_Kv}AHT-9bq~y}eRX z3w^0t;@mv@?OlEv;fZM9&_2-}{&NFF<~&BnW_QYPB=J%&?1pi(v;@bJ;~Hb3(VUv) z1;R6LdN_P1EI4v8RWlQ~5^ZDBP~YBb`x3AiR;NsRZ~+SUyMB-VkI{%Fqy5^C-AGj@ zg}Br%);2YrcQ$uE7hRXmDQ2NW%9+0H=^U81&A4k1JPjC|4=J|Cn=~?|4@B_@i{NVK zk2Hvceg*7%T~3?9HcZxt;7-XG!#7XT-8i26yzPPbI;Jwwyy`^K4f(~SK7T7>W)wwy z?-9)p+c{M8-@-);*7;4-{v@7**>ZZm9U=uEoeR9C4wz#$FqTVZSRS|;sZZ@IQBJhE zBO}GefvaP_@}f|skrlzF6clquMe)DA0hs>pzfwxBnIyW@R@9XHnUH#+OwR*Aq&(&7*X4I=AE_Y({>{UR17Uio#nlEy-=Vcl} zl~&HAArxBZ`~Us}DD&zG1z9>gmL@kpiaJuu`63aud}WvM;H&@Nvv6qa2ex>Ckq>;x z5KG3ESG^G2tk$rj(|x0MAzG>D0fOup;PZ~ZG`xe}uhTE#J&o~bO3CfzJp@|@Q~C`3~h-x7)!2Zt%b)bMD2 z<6QBk=k@cEr?d!jH*NF0kW}P3Pq(1RTP6lTUhe!=UWh_8n6T9b0OF+Z4JZEkq+Z=* z6tHU`%_5>l@XrxEig7ed%d`=}0IM=$_J~}z>>G8KT<`!Dzn{Iw5E?Ehe7YB~v4Xub zc9>g8npXd&P+nlsx7~JFu89=1{^d5~<##$eDk78+?diOI<>Ag870YC`7wYlN(3$gK zP5#I#671Rj(-Imf6L!QTqD5I@N`3QXxcl9ck(MWa0g#As-A7JzyikJ7k$oi8 zkTDro6Qb7Z@~AG^uk+EYnu246`6D9ubi6>dq4_6H z1d~&P=_!43dZBxYOwUPIl)_0d=>nN(E##BjidbSCx)gj0SxyJ-HS2(1{2-;6FeH=ctW~6Ji+D@u& zNMwtaq1s*vKD?uek)lrw+sQnXS5nwz_sGu-_RxY?y~qn&xCx_}7luu4#{#Z6iX;#9 zQk#`b`}st(yzcC0wmna--5{qJ#n)~{acZ#p9-#6~kJ#2CxWCgfEFHp>-3BTN$Ghst zf}XW}!^LSaukR3%fIo$pz(ybJxI!QS<7hS#Bes=mNH(C7%R6( z4HT`~H9c((0s9PcXuBXlMU-@8?ST5jdtxQcG7p}%OSBL4zIVR66_^-hwj7uGieHVe z4oaeKLSY|)QS$WF?vd6+NSTdES>=%ttvXy)9=UKTx?>R7oV5a{O`Yz!pKHF?@4|;? z{GB8!6(VNiA913*-<~NrC{5(Oy{*KD$5p3b?mpYn73Ak5ZJvHFPL!~iT{;mz63jLk z{dYl~E0xE$|#y{g7omXq%j!F=!;gmo7!<9YV;;ylRLA7v{chiTc5I{KLg;H+`3PYor zdhgQ1z4=tvG>atB@eGZ#sAV*NK4GYlPHABnv~zXv>ho&zaZ}f)&<1>gAc@X*E03PZH8SiO}cWF(@+ZEP)1O(y{5{WuY)qc zRw%bWy4+(niUO8R5HA34;9isecwZ{Boy7Nq`@Ef=dpP0;mTEfz-p4Y*AQ2# z)YjizKLQ?pHgdx>qG`i7bWMD`-Ou7JtEcDWH=5>B|MN57x!y4tEA}H*CDBG)vfQ&@ ztT0&8bQyJz^-PdIM}lqP#H0MToEotpOdTE!-+`9JQL4xTLT{JI9}OZh+&G(R$ek^l zxy#S8PqAp(<)bJMiIUz7`VYHTm)wvvb$Q-`2vfI~#I1mBm;*N#5?OTf@x*S?HC~{- z^gU7FnmoGd1|$?xGmR%mWLtmS^YT8QHnaZ}>wnAXWji9M`+JIb?M0olUJl zLf5n$v(xMMfdQ`f#LrTPCjA3m1n|!tH9r$WE!&J1BlCJLPGD~nYHG!OIP!LVu_>{V zOV0MG%Ga8E1BS{NO(QY# zWHPRI!zFQ_ZC8}s@mKHdU9(vbor9r-M;dpEJrs_FRFG*jS*E1-$L3TyiAUDqN#2K_$JYWe|kzK(P4NCi(dD~(Tne)rb~oQe!X85n{Vbepf|!?02q)T>Mtt! zj3$qXh29Zb*Mhh2fJ#3pzInHWt|AvDT;7!GVt-flC{ml5+bGJFJ9}pF0=JPpZ>`?A zhtq#ZfdaXH)lTVQ z?HWJRjMKSN_W;}?UbQx4Nd<^>Md??PH)+Yu$TTZS@M7w3E5AF$_j^S|9aXD@%n z=b`{E2(|fRU@)#E$e5fU`MfKMr{fW^Gj71#sCby+klQ>ovDNo&R2FIR9*4Oq5$y3n zHr$4ot&105Y(L=Cd_2Tc%r~nv`!yv(5m~TqV;l7KQaQ!-7JqvhGbk-BikP?+t8y{*vC$(XWRN`LmU>iX4VAc| zn>qfo=B)q7OfN;4N~G<^0aVok=ocgEwXO8EwyeQj`rJk3Se7kY&*~YzHzmq?hXotI zX65G2!zDa?$l1e;buG=Tcx9LP+2evf&>#~uAs?iL^EwiyuZ}Ld;0=wiRfQQB_ za!X>nAM52t7>bF0n>{03f%p7dQR2^P2@PyEyUu#aRoi|{1|RTYK$3}e7|?pn0(#}K`yUE@syX&G%Pn%$cg==3qK#q zVC#aX1XnTjy&i5w9MFp_YDe4SrDFp|NvW7pNw~`g{NQT8plUJQ6ecuj(Etwdb=e6j zRlXB+x18`_ux?Ht^^{G*R;$i1I>gK8D8_d5n7vr9*pfc^^2q?dPbdG-YF4cQ@kT^l zWn!}iDtr(<=x!`g7TEl}wdEQH8laJceEI^i#$O`6K>FnwV6E&$s@pfzrO=J*pnlsZ zl#tx(G@F2RLpc10VydSxUt$JNNU4au=D+*npMedmEOVK>s_C3J;(d*@r^`^gep z@`@`Ay6i{joCXo)5WWUhc_vlFu{Evjov=>QFHozmc$?BY8nY2D`2o^_))LD^WzK*x z$F?=d+ylPM_q4!}kw)UWGtkV9#HT+vY_wDiN1;l&s{*tenMTXs(9U^Js}G-xcOs%~IPg-}jz4f}hpX0$mfP z`Ak;06FewKjAx!Xu}vj6j@W>^SvtW%D_-K{*r0Yb718@cW_)xNI$|*tesPz<6TlV6 zLfH9?%e|A_>Q_sa=hV0VE|mGr{eqt#y*j88U6y_y19;_@vLDw*-a z2>H!I!qioiRyn>f_>rYCY4A&x?jko_x+jcW-VHKnacx(Mh!Wifeklige!8pJMCi5T ziFJ@wS@%Q)_vKA3m;d!asmUCToGG#9bb|g}Xn}iwVL8hl{;Q7&JP&_$5h|BqYBsp> z)mbdYwE=NJd4J;%ap(7zhgiM zw~()lxF$%r-&n?)%q7CR;+g2%kmDu0@rV2E%z9Ke%`w_0VSivWdW86Q;eNuq@&np` zJujPx1dy{^Elb)0I9F|rJuml3YgSkx(%D>T@dgD6RCbx8jkAv2@(#&!!<4Xo2m2w1 z^mZ7X&+SCbiE`2{Np)b^Z_eYKv= z^Re=gv+ND`W<&zc=WI-wDXuRHws4*6rz)!S(rhc-^q=A)q(A7^ml{4cyA%%kbOJLbNgFmr2U zza5`8=ble)kl#bG9Y^sMb6uKMo3f~?aK}JegqQ&>eH={E8L~S@0OJU$raV7@3~e)T zV|~VP=hC$bu2t1diB)vpTRg6^f@$ZfM3CW@NsuZsMOCVNd4t zs^FqUS|Lqe8Xtjn)K*RRfiHQJAj|=<#E|mKpCCyk!k103_?FkBIONqsoJcy&HFPyk zPC!Y1z>V1f*u2BoD+d5(2|L`8g9d`+sGP8}JOt13W%L!WaCT+AAfs(AyDLsBPZK~G zb*3hxtYkdij!Pq~7`j+-X>>4;?_e=Oz@3_8LtlXU1_E>@pAG(@+#*pXk((ij03l4~ zFFo{ZFp}c2WzL(CtjI9-PpRj6M!ep>9%jpw(#M1y&VAe<0+NJ!lp6zT%*4pzKb$@A z%BVceCEG+HVn)n0KwAZ4h8-03imQT7^(h-d)+kX*7$ET-iy+cSez2)Zl@(6LQ7pL^ zixAtHVKEh(NRGl0Xn;N!Hl5G)?@SLr>+cVRpU`Y1BhCv zWp)G8iaC+xSP(bzRG9UfnM*&O`YCKtX06oZ`LBrdu@N#TRPDgsgvgoEY?#55{P{uJ@3o4Z9UdkCV z{|h!<)N##$sJ>oP@(&>-gK5yKo8HeGaKM5NmqY0yrPArZ6G9@4RZuzt^TtBZQzSXC1 z=S1n|3#R40qdlUJ`o435*R^=jeFY_&W3_qkRGNoU*sS4pOn)|Jr_{(lFPCm-`1*ZH ztL&$1eZ=xF0FV5%d&Tg=3*Rh`Og&ei3+;E>q_&Qrf?Ifn^29Q!{VQSP^+Yf0OEqne zW2|9|TvgE*!gaeCB1uPh_2&V1c^e0GPPkf<@)YW=s1Yov;7;s2eMgU)36-G3uoeH4 z!75LMpNxNcmyn)baB*tR%301`dH<7a<3>;qA>C)=gs$zUcu&g>^!)^CKvw!vdY`#^ z)zrGpwsx>GG;*Nd#7-V{%ThN(De$SljwTeP>Q@6D-Cc^9OagK;tRDbnG!OU^c*LPo`r5n1Z_?S+3AfQLsD zYd$ZBwt03R5KV|?gYO@1mah($bR;0+dd8UeUz1n%=Z^Wt5|gZ!q|L{Y_mtGej_3Bc z^Q8pENicN5srXQikN&KeA+5>jWf$P!6p+3=-K->=(G#J*e&W7m?e{J-9>o`|a4gd7 z4bm;Yn{t}zz6JiSAF8;fq z^Y=CX61&-shXptWwHciLxB42+Y=8-!$Zkp|bCZ=~TvGnR-$6vus+p8G_?`L8*!2KW z%1oC6oAUjK4%FYPEjmC%$z9hfcJ9KQ(U%~$Mc-q=T}s5cwbB4Q&|)jhQn$HBmNZD*wwZK1Ap~e!f#AD0 zGkxN1@pKQt4gW6O(ehHgbEKiAKq#5lcWk|}D5xAxw4{B3)yQrXoI*X47?&7dF9+Yx zQ08tAUGlZbXSyq$UGp;`!Rzc-Gqr|b`9s%@`Z?Z$<})uaFP3npqqX4S6Dm#H_e%$wOOxbQpgNPkC+3&MTY`t@7mh@a z)9$VZ3zpE@L&S;fK6xj(lJ#;(^DaCje#83+PCDY52Y%T#|FR4FLOc@cxLm!8P+!dJ zsVc+wmcn^{-ydR)jb)s4x?LRlCA{^d^52CB-IXYXJwJmf9dTIv)t;p*Bg?%1EmJY2?iouvb}+dOhl3Z!fQe4SV{(PWo8E5-|MxU&D=6yc9K{SrP#m(XT52;!;Bt{ZCS?TCkDL zB&PRhXwuqca*o+zf;uc+x%Tw(M~QX6OPv)j{2Q9rQ(iU@RME19)nG!ivbc`$Jb1Ej z^vy=kXZAiLou|}V&7Wr0!2n521#QUA+XP8m9zJBb1(=?FwcL~Y3=Dh-Yf_b14N*#& zIpMrav|4|B*LU2!_+wVHvQd;dkW}#ym1`Mh`qs}v4^AZ_q3u3>%@H1UPI{YX3a=3m z;+I%K+-U&pOCQjKN?J${>U{(WwFHJ}Ly2+RY(z959cGLfqs6of=r72+Lt!_31YQHg zx2dZy5e=ytW~iU1SH$n$RAy2JC0|0Q=e%fy_& zlGN0?<{F{Bv}^Z^+h3;^W~PHZQMIjzJJatXh8|K29e3r;4phEg=WNj}+w4A5EfsxY zd7g~&WZI*>E$e?5Br;J@^00VqQr4@N(l1_e6s3+9rEc`W(&a$1a#6?6Q8=YW z_^$ZQ^q{oruAo15eRMR0K8jc$VTgRJfSg>Pd{Hq6^38Jg@PLE`ckZjdb)Zp3ox*T% zB-BJ@D>PaB>eqH_49JQBWc%)ykLU(F#vw@`-z#`>!5iiOT6BKxNX~)eC69KW9 z0e|)#>;m%-BzTwNm%l!*JUZlx);1H$8x>2G6T=#Yq6DTh@&4%jGj-+*C35A? zt<9=}-kPpkdsCE+w;bPcX+zfglGMb*jD^`K9c}qf`6T4Hk(62tss zLpGZwkBsims(GtcEd06QA|p+OEGNK8h;EpKZh60P>lj#seWk!+JB0sCVd*5aChqoL z;G8xEO0;URev+{F>-#;W>3K(6kB{#J0fH3-89$3__Mo>ptcK7>;xab53TDUbIkUrP zWj+3hL4-4z^EyijQXVT?c-xEDw%;cw{L#+fP@N1a>l^H=F=DST6J^CP6?Y$hqjw*F zrS}%ozWhDyB`S4_;A@$h!V|SC)0? zp6_8XHr8}?*D}fvk;5#yp*H7fegZh+Fn=yiW`zZQ{ z6O;N3FT_&l4PO>hBqS)%07c3(AZ2#QQnOq?3nG~uA!7=+Ozg{5>#e|`QrkgWNjhTw z&ZV@sf37^?z6-180{;)Tp>EyK}95 z<7>du*Qu%QpRMTbWJjmXmrcbD7vYplS1$L!22VI{W=U^Hb9L7zz_&a2*OmQiRclJwFhCOk4=ckKT1h) zTnv#Z`Zxx4?WjobZ>1M8EKD^Eb!Rg>95~wj!ri24~;0tJtzf)vR^qvl1>vuFH zHT@k;LXoDe%p9AK`E)sd@aMn(bIL6C9&h-vwJP8eurJUfvL2`uo;UmCb~ck^ zP%{Dc)wDGfwzQF$!!r8m+~h1cGrkn;9g#P@T{dv5!YP@Gnyt)7cUp_BfmcR_Wf{W$R#p$#jR3-uIwY=N<%f zdCww~Wg>?p#4dh?CQJ+R<&$yU@uH^5gUV8IllNdu(@12=h8lWVJbVEL)=v$60CErM~+23@7 z>(Jeduz_AR+0A;`j?{oqrT@ms)IylwOwWuFpuD0WKKJ^j2Z!rj$Z8MaIpmhFZsAWh z(l12H)M!`T`tty0FU;23uP=PRe}A6cn`~`7wn_<>h2EOKBuNCch1yb6s&LM7xBp$p z(35$;7Xy?7R8ScI_&8o^l)1xfr%cZ$uI9~yZWk1M5R{Zh2k58uaA&kz6&&lCS*dnmbY=)MS2R@E*d2)ycw3D?5BM$3np>a^&gd%V({A$pHd6pxAW4s^j`T zumNt}%6%+yQ9_&7hCJrp`Cu68k?c)%wg#zq-tTF0Ma6x5#h;rW66;@|QZY)Iy@R;P z@6C+;x^)S`YjBuu;@3%&U+duwk3?tl5CzomyAg=tT&MOU-4*`ej zynB1dOl@^pQ7?3&L)r0g959ZeJ{vhzyF~iAnZ`d$#XVU}ob_>gJ=2`)yPFqMAbl%n zbO@>Tp8vVd2& zKfFv5sAYzK7uJq6@NfDF`%w--=s#d=#Eo83T9Dm4ZtC|C#Uok-j#qcyu~Lnm!N)L1 zd4`{(%&as5-@qQN}5pC>K#yRso~eZr?iq83m!M$f%MOp2{a zr~^l{uz>ReJStV6y$<5#Pa0M&nTgSWm{bB4Ic=>HmdY_Uw@kP$+eB(yuYfdbet`6j z9UwZdn>X;p7FiBG>J6W#(-OP$qkqOvvn`YEYs>E7BW=C}uPi(2&TaU{2bxrwLu_i5 z%e~sD*H|BNNUC~YBewFEI1BO|-0WWPu;aOOvk!dNJu$%HGvnLd-B7x*(UGJ(OWv9d zP735h<LR&u9vNQ zerRqDZU`B3zkr6wD$iXi9?K4bs~Z+`k43I1TZEvFAV9nQBwmxxrf;|e_)^S%G}rAa zJSYMsIJe23SL31Wh5cGmYx5qLAM!FFAx`2w0fK5Eb3OdpI5l=vb ztu{}0>JJ*-J+02QuN(E_(NKvxl4AToed&$xE?6IV{cLB zI-v_QF7h9ZG6Wg3HuGTc9(c&Rv}d+Wb>#fULq2-_3GP3&Mkv))n!-4HqEA`_xsUNj zAOn))#Nq_@te-HwV}c&oCdcE?ST-~ChntVps`ÁY5GN`$(IG4Usx>EH>7EZUrG z*WTwd_usk`l)O~112s?jOtFRQCuEvUeRCRo2!8&Ijr?RYE1rFp8n_yk4ocFuX?KtE zrlj^q2HBi#m3MxATSj{JP2D;MT?$OKi2ZgbMjZ^0rw?_RQAsqL?~2%sjS;7aSgo57 zp5AT>7~}OZT+SX0H3RM6&wi1CPy!KeIPyV@W-fuia+=Zc9L&asLAgZqbk84!XCnci zSzA6Z-TKC;m(qWLg5+@yEk~K0Wmg#+C63WZ z3UubPC3rGOhzkxqbQpD8c)O{O{FwzUx_dVj*A$ToTl>Rsg#Jf z0O2{#let%SH8{clr2tNTD$sIA)_oTyi@lS_XeyJL8r^EnnQhc}_z907Wgi88y1;C- zsxgxyxq=qM)ahYumUyLC`=Au1%{YJoiH?g;O9`(fmZ1((&odTeXSxNAH3TA$c)FNf z3NHuJF?o+ZHEkW;E4@}y0(6-j#wBiIJ)T#j%xuX(jc!m}R13=}%3S+BSSK``&i*{5 zcg1hX-ww2SKV6qO{!ds{gkE%zQq6muX3i60gYhp&laaUHve(mnmXsCHIq}u|9%9sg2ZWxYdQ7rDqkiShXa-C}WJD&!_+9r6^zDJ7&#JR9 z8XV!UeOfm7B3*zQ(tk5PM>cat$DS@i4jJtK8c%q0d5Wyy#q=R{K!8wU2m-zmOHBA$ zVXd<>#~qQY+M5&M#?Xk{N}pc#_eUX0Df|-zqsBcGP>{@rP}ERkDq0D@cS72c(l)Zy zDx-TMbjS_#G}}?~Y6+|EDv~Ysp@PvL8yh(=LEtT)=G$3SN-~U3lzJ%6|ImYJk@tgfiRKah0(XzGVvm= zk$R}*q^ve{vENcaD$P7L7#S`~Rkof^X|7Z#NcJtO|tEmC@pejnT&?c@mSm*pGAU&#l3? zgbpNB*hm|oteZxBBz7ypdO{N1?v2s2LiK(aIXMZ)#B(qJo2aZ@FnXEEcrN*e-KguX zxO>t)jV2mYiZy%q0}wxl=Xbr@5Dev!fudcCxBPbrO>g=6N%vZX_StfDzkyUPNVw%1 zz9c;ieT#grzFz_!3uelCXOYP#o%hikYNm$391+EDiiMO`Od|LRWS|^;_OBP*SfN$&6P%3(N)v288HkiVNDZ!@^eR;Ucj2+Z&vW74fi!0MFQstiB)2q4k z=hC)w_~jEoqJEo}99Up0_=%vHpZAh{kj1@oRQB!=Zj#PH(5Y-+##m5fV21qJlynKG zI9%G>DY&$CPf018k^Sbi&2EQEz;L=Wts11 zs=E{Xm@6C#*{~UUSwZ^gkk;@Zm6SD_)U6@ly7T2Zw4AX|L|gNEHf#JMx&%_>B^o{(sD zCq~>p^?pXGwmzdfoMdL94E77VNCyDT7hua_XlwYo;Q|l;D}n!{A8ii{aW=&>BBjo3 z-nG}4AF_+0^%=cPJGkTuBcy*F$Vkl#E4?WkuYQ*fn~##VVkhFRe&VH4cR7O+YtGH% zU(=GQMn%d1i;G75bMnv#WV(v1hl56h+^d)pd90*Kg{o&?XEt)=)i@Bx#6OvMpP4RM zKCn*;oM0YW(X-R0=giZTYAG#)`V3NnFZ?ijr%eHo%4}EI}e-^Jm*1pDog%Y#j@Smv@fM2SA-Y~~l~$Xm&bmFW3bI-}`- zcpFGzvd|3GWui%8uEyPS6uoybRm*gz4Y>!gZ5H{on&T<<@P{`GbK;&y(X6e8HqvWo z*N_Q3nsdn#NRZ3CNw`^@dNqC5DM>e-iK3wGFvwErAj6Td75$bJ>9%Ln-fesZuVbD2 zt?v>;rivP~vNlHYT25^BOeo zKV_z!WNjfYS%P4J)%|?#$y+{a4cW2yhBgVk^pMBhyRciY-}KYl-k*53h^bsM3Yk-G z8t_us3WE&)*?qK*x8|Do^b1oJL%X_<+Z^UP-L>zO)1 zP3bCrwq!)R3^|%MDxZJD-hA1 zBwSBO=P$H-RIp=#PVi-HsO6wIA}e0R1w@}cxjfQ81y)X8Gx!t z1Swx_RORO8o?-ozG4?O>uf;25B z4$H0=1>CK@Nr>ZB2f;#Wx?Sb)bJ}_4^!D~7Il7PBa(~DD4IuL0Mg36&DEuELUZOfqm@AWa&9gl%zR2`x_CC1R8B}oNKT9R# zo&iDg)~8RcNLpk0H*WoQ6|o1}Be1SP|xj6t8F;yZ%}3PKdfdog0tIB22@A>`F>}p2S1&Z{3{B z{m_|^)jL)J^=|s){&aOb7-uGD=D+GrQJ_1u z-?(Jc`c+}f%bXqB%AXW-?JMcG^(xY@{%vqu7cR(VAj#@o|R zWY8}xec^8C`n9EY18uY+x1&!-;i@i=l)@o?7^taKK#(=3vznd}8ELg_F-ep~3$=XL zZIdMID@`?#D;D%#f(-Fq8bryZl_O<~9Yh)S@dzR}sFXOlug3DjY$>|;qY>heAP{*~ zq5*RIRtlVM_$_?B&>(E{+&U0u;*XQ_ESS_3Oyo|kLN_%VooAcS)#wJLc?Hl6NO0ts zw_iM#)Xhd}%CC@|q)rHgN}sav5P0-w074GbaR5c|gb^(KsW5~)0Ry?-@<`i>Gw6li zliuAumB9WdM}m;bhais3jQ;lhu-n5LEWexORZsgMft3RH`ExwgpP{9#&PN`k>hm%2 zYs0>t^884OoR`R1A9Ho9^xj2(ctdWl;Rg@-@%8QWzA0d-OL?F8= zIv4<5CFN&=Ld(C+uxMGY?>E_H#;={=;C)woj~g>kcZ7B|m)z#qs~Hbjp&2|+y-&d_ z$40-_gGb9upW&_0&%dT^YD!z4XDJgl3CJ-KX6PxueRB$=oI&KlTXur;qASo^mGbp$ zFZz7MWi)?Lnr>~8@b5x`H`n|1Jbn5qjB+i(Zm@{dsnPR<5As`~6=r>E8cYl($l+$v zxvBz0O7{9dN}GaKkP*717&P=u0PY0;KGl$Yng8>?UKZa~LCr`l3p*L!6maBl@oXIT zGt&1UZ!#XwhC@OJXXFH^i8kg{TteA#nTHF;B*bJ9WTv}#PMGKBp6}(-x*h%y-J4kh zK{wclEMJIZ40xmkwN*u(AxL8KIp{hBc>o;PnvlGdEJ18J_-vfa0Ga{*G+Fvs?JFVj z1XqzbppxxVbpvbjsCKvM9d1m%6@K+Q&(|9aJh?&XefP;b0Evb=q{{tQrEIwTLd7Ek zlW~5$)giQyCpLF^i55m4>Erf>{RedX77%m=m% zk1kB)f{~R^2$Be$rSV)sp}~OJn{Nq*=G)h}qyvhB?gdFwv2SztmBA*wmT8x4JR_yS zPE3@v)sSHSDUx9CqkxNT9~Fqv&pjKbA`vt9dA5FXM;`&{^FBA%4S%vi8;K@Ltd;=F z>9T7mMk$1bMuljSIo#v_#pUkGFz7;Qy#9HuQGfl1ma+`O=pX7#Bs|@?@<}ly`Qf#;ajR!k?xYF zrEd%vp#Pa4`mo%SK4+>c;YxovIw6vuO+h)C+EGns&;L}A;uj^Ma8t%;}bO0damcKx3?fq?g+xpT}7PT&U>ltIn?W; z8Mo?)Gsbkr85C(%!AW-vErG^yfAb!1&haW1dBc7Xz{fS`3lU+HNCc!-#0ju-=A8}3 zmL9*(G@{HC78J9*xt0ZJw&W9N-b1&~w^ZWP1s^YlWA_D{S*^YaI&GO_LXcM&OO;*3 z>ea7vta|VoYU$+5Jy@_jlpNpa2Eun6Lz@=IUUYx8npfye&$rio^@jnaX?br)mVO}5 z>dGbJ250-2PNz@z*Xz>?y*MEGAygtv{@1Kg#ks9DwV72i_(xny)M3ymfB88&3Qo3Z5eFc9owE)@RO`+(dhDJZLw<~Zp!?`?|)$Gg_68SoA}+=vmB*MfWxx0H4WUZtRM847g{WG%`+r{euUR6utYh?V*8s1N`F2)jofa>a|`OILms=QeT z!~#u;#W|YB&FYCJ`tUM2|GV&%EV3T~;-3(_q`+oZ5+SRX_qKIiIL%ZRyDtdvZ)vGiXdV_~AsW0JN^qH!;lw7KX+gX_-%pr!m&O5L3LR0G$M zAFrToYM<^vX+@0J#lTAhacGDunaA6|GN0MCg?O>&&7a0P-@ zHGc+ZG%RSch3^Xm!Al}Yqdwy8{lQ5H;}<#2&-*v9~OHMc!HMaAXHArslO`*g;1D6vYa zU{4_kKd-SKZ9lr%S+PjO%&s0X5B~u38!9k1t!TOmSm)L(5Ug*_;M8~20Zmx2zvDC8 zd=3rZHI4+*lTmf(AecMRvX2F?`i-!j10gun;IKdoNYI2u2ZmWf{ z_X{jm6V7tV*thF8C$>9+xPC+$y9-m5Lb!w(KYPyhT*PE;3vS)JzP>%2!KXhwaS56U zr*8VtolOp2*&BZ;Fy%qKHin+P-5FD?BtF4>LRCKBs0_dTri-g_XIql(a2#2`WFDt6I0dn-0RO%)fK?*g4qEJX}bFAR8dj z>eZ3l?{tzxP#p#K1_h;gYs}*)2Z%P`ff}Yar{rb$d7_)X%t4-@>!o)XT& z!rJ%;t-d@K<8uO`7@%xTIr%(qz}ITo#x9g6&_w5xs*M83W$5ZHqGcGojLsLTqGn)D z>;23;sW3JQZua*#pZ&;mpajiMe^Dg(_7^%f325sEct)Fz(N5Bty)#L`Y>LGI@RF(N zPh(QASPX9+VD>3La|HC}w<$kK)DrGRgq_d=_T#Gm+h*(BFh9fJqNVpaB)1e_)4is{ zrfe$q4oL3uv?C9_&oNL=d$jhKpB05hS6Y^gZ{pMgy?t2(L?sc%wVyq*xOPJDH{*RuDM*>$1D zxWOcR4&Y0_t`DC+k0(*vhSZ|Xm72TQYudp+V%mD9_lZ6CkD)S-+0k@|mz%7?6zAg; z^Q_h?n_2>f+TFP5;ytT>9uWhrs2ws7K^CiSqADbEEz=z_i=_i3aNOvD7mo|X>Pyhb zg(|9x}k?3i*)Ke1lH>wLEEUOZT=9Muyu(a5rXj=}GgOc?-=O(VjyD!O@I zy1I-fSD;Vg^>Eo1(g0+!&j8i86=%qX9R7TFM}S#}&FAU&HXld%qUNoX-aEkEp<%Py zZvT*{A2`VYHaKHc_(W=IBq>lP59Y{5o3ajPx)rTyid-yxIJ6`d#f{0l{yoQN7BxH; zrk-Mq7(?P_)#_cb>lPG64RWp?pe%D!Ig*GPq_-KgX9CWuHkBE_hBoq-F>;6~!eR0t z(*V;)66AillAtSLVYY^Mgt)<|Jx!qh@ZiulC!<)d%kLtayV=upc~F-+Wx6U@?^Wq1 z*;v{ync*t5pa%k~cWM|doLg~Mr?JCGK(P_L6wPc8Gwtu1AP2YIQA>I4<%7Jz8<`#+ zo2EG{A|#J)9kt0c9U=pY%!8GKAgXG_9eu>-eR(yZ9KSv3fUQYel#*Yd+Z0a+3qk1@Iw2?YaYAdmn#+nvG1 z6Q*0|fa?htzCO>=+V5g)(M$8($vDvERAlN|6WyUB-u`K7nXz4kHFi-Hd!u+}TGd+^ z=5J9E|2F6j=PkG_=U|Wbz=^?UgBIr$+O9kE-=Qd#b}T(D(2cdZXP5TkH~k#L2itpG zJBPxMzoQMRg3tP)Rlf%B7>N6eDa{;o|LCPiVRHG|3__Rk8SL}oJ9+=)e1J+_`Oz#B zexf%8K8x~L`FWs7!~I<|Mdx>|%YV=g!O?vGE_~b8>M>cO#xH+9v)-x{ZSR=v2_0Rp zK8x1U?g@&O`E@>YEV^O3x2P+e>>*8UXJ+wrR-C=wBe`li{Z!?6^j&e!{bK)~n|Qc% z&hn=pG%o&;)!BZ+2U;h}`#f}CRqodcOmt;e%hm9B{oe)e^Pye6&q&NJ_HWhUnXnEy;4MH|c{#HSrW&R6!O z{zuh&$0fP`f57J`ski|(6&FL$a_<~DKm^5EnJZI3({g7XXN4OD6>z3z;Am-ynrUTe zIiRdW8(3CUR9aT%AuAi-hx2>>d7jsOzl6{Adhy}n^1AQ)8t)-%m!&0HpIm+W!&5Y0 z|7)O1wjpQIbE6J<;GM>AX7lNn)slRlEp~ZI^J~Z7p02#%CwU$9;kOa=8-Kpi1uNS; zO|S!LmX4L8{J{}euu^Wwg5GXXnCaf@?`Z2<)}Bmn8B2lr2-=|K`sY`5=g-w+oxke{ zh~FFA+25?Cfx`v=`wOeF@vA-k1-roWal(!2-?W6&HoKWcRxdJ&eN1L7=YlgQ)_|&X z(@E6CysX7ptABpNetNRDXCECkW^76>9{VZ?`uu^_vG7gvpRM~>2``q{eV>cOsR@%p zf*MQr{rTX`9u-cFv6zwFI`h-zsPzW<*Z$SLM=!E&EPO-Wu9*4h`OhEUkwO%#y%*~^OKedvA{a5){eW!cZ;z!rcyJMFe z8BV?Td~SzcGxad?R*EVpY>0)EC&-3qq?dg|v37BW&WMhN(9%Y_c z^2T#U#+NLECv>V8Zp=|?T}{EnH`Un6K9xD?M^z6wY|?)w6$B(vM$AgyW!d&&!lQ6r zE_L17duRC&LCxrnNv|k7W7wvg|J4rZxMg z3_jXvj;9o#@>6OtQ}WI7jzi<%UGoV^E70V`@TO5$kMlTc)!YNR&0H-&sSY;Y;|(&w zQbAd;3sMWOgI-j1h1ygb6HGDE#l#YaV1kiKGQyG^GKeeZW1!UMTgm*-wM52m(v$I& z984;?@_T#CLUcH@b+S@-yb;pDiz)}e*UiuQC{n=yC7O!~w!B#_TgGOxeLV-~*C8h4 zT*-YWo0%ql^Fl-qQc0i&lI_|zCnsU4#(O$D$16yNAkKZF1Gc=^0Yg1+^ob6SQ3~9V zv{$DzDS;ygP$+c~%Dw?)Vy3*4iY`Xx!m(7aew^$#Xr1v^GO70}5mm_T<7aY=6i$cA z85rs-MQa}7ncb3aRSfxFUM6yNIFWS(zt}ICu%A!>&fhy)ny=4EHY!z~8&jj-Z=XE7 z1EN1!b{;zKn36Ky+bwl^rzsEG;QmGrRM+snpN?yJSP6@Z#f6QGoZ8oIfT^V zDoq&wnT6oGH2+VKTBb(KRYG88fBvIH{eMmb{y&1$|F1|E3S~+D-_tneFFaxnF|#o7 z+I5#uX@8)tIKZzHs~I7B$n3hzLta*XcIFcKA-z0SZUJtVpM|e0@Av;S&DI|Varu=d z)JhxlIV6*#%SKL*N;Pmpi8Dy6RZ>v7PRSnT<;p$_f8i-?s{)9|pJPlY{cD-Dr?UK1 z_SY<1s9YGi}7y&}ACsoL3JuAW`HJ6|Sf0|2em?mnbH@n2Q* zWz|ODA2}9#e)BeJmiqN5N&UFj>x+{9_t!=UuFe|(=IAl4&pcpw03TN4HG}6Ksy|Vy z)(rd?QyuC2a13$;w*@S_v~cCk?^q{fx#k*~eGajWq`=B=pNzr>&y&O@+Xxh~t_>aG zaXW=@wrJ}41bRyIh7)?LS(5O;TrMfpu21f6@=icgmX}q~(_K)53J#R{HUk%7;#}$D z^p&9M%vE zCp&4wem<@sE6`j`X*S|4pSYr!RxU)+Pig|Y}e@#_B~4KS$?0yaVy&wVkSc$Iq+Da$0zHA2%~1r)~^AxGS0 zSxY8mDaEoFUMS1%9xN9F5qBuk#Igh(fbXUgJAoO*;Nn7XH0-jhA~*jlSS$Y!*RmQ= zDm#j0RV{zbF;rSM#J@MhugF?P)>5Y)*U!6_;BK8kMlxU_*Xj9suBRV-?A3HUN5{(0 zV0^XlL(68?9HYwr+yNB3xHPx_Y=_h|(WHQDbea%JKW%S77a;GA)ByjsYmAd|T{$~? zzo`RS4>38{O!0`TGHsrCa8kt+<%6w3R9r`ZYIO$&zZ-J3_uI(93?ul&cH zgjray!_25wn(Q!uC1Tmz=)Ba?Rd%EEnwz6b%p4<4r2bq#jKwysRJ8o@Kw;GYQRu)D z`8rZ5EqZbv%}Fb({G^CI7jOmNyqXjyXSB!b^e23j!dDwcUB^eDnv?zkVi~d#Mh$z$ zz5X|k7el|vuO*{^QvY@M(ai#g6L}9{S{2%Hn8!*XLf}Vwjy&y@@roc&yHJLwexU5; zSE!lhVHeoi%3MiWIysZ*K(p<%eIFGeJ=%gCzm$>wfvVvfh1;a8?%3&*`j`_4Kag>}rc6 z^*gg|Ii6>ZyVsYNt&n}B-tt&*)wx?#umyfA*Reg00n(qYUUu*rDs~`||Mtmh4n7&k zfxr9wcZCT0blt?ibSUf80JtQ66}c2bS3Y5+euG#Nh0K2PM|CyJ-K#z(a;(&Rkau=M z5ZkVT6W2#pn7TKhi&mTz3bpshp4Sdb|MXWB$2Q}p33T>!T^hlL_*NmF0YzFCe{CUa zb3IDk-+z`z`7uoEaaCWB^yt}0)58T9g8SbvqsYFoN7!q2$u0ST$vc}F2vMc*V5PHh zC!^>RL#NRq|FED@DgNTpVUmI=_7V$GGQcX_50+Bj$z4WAEFYr3aSpT_k$? zZidWmq7;6EiOZjcD8JOMGiDODv{iBdO106#Gt@kl03Yc+`hJ{@iL2?nr3x#16m=pV zw^N860f+~D1VQuHRH# zO}}n8!zv7^-`IWf9gB=ZJKF3z%m^yvL~>>7KnMYPx+UiBCNteNUM$dEE@g4o<l_|@&%ZttynuW0z3EZ< zzOM|uVTXLaz^RWSdP-Q}PY3HKFcog5tn_H-IjP?{4>kNLZ-9m$X+Zy`k_icmYtYluKi-(|hB>uD(+TSdTvs_o zJ2~l2DRcP&1(^l;hPVf{`~;O_41I08R4^e}S~AvB&;AcMwsG=GN){k+lDkR|Mg zEDzJ*ZYZ;>MR&l%RnOX&j2-`1Kdxqqo%!`VINxuiqHD~ycN4u(g2?Rr+GkXk=9nMX z;UndbX+%C!&39Vx40(Riu^kz8zZ2yVxU#bs(ly;YPe9|Pu-^&?gdM*xKHfPxU!=R= zcg6MYL1{bb5{qk2l(f;#tm(@E)>PqF`Ubmxzx0Zz-fO^Eq1kkkZh zX?*U;{vUdX>L6rER$SsXJD(Hvv9*Kodux>(RTtMW^HmK|rZH1Gp%}2*dU+UZ*TcTH z4&08tb^EFZ4ZX99uih-femgIa1cjDklYVm33FL6=l2y~4^yRv59B@J9G|LC~R^rg^ zWKzKcG+O%{1ZE+O0Fr}K*vbjsW%79MKt5O+KB$TT)04WEs!i@SBFIH3d>`b%b#_m7 zJ2KoGo9@K!yv;196O9{7U))l?=lJ?}je=FZZ$d8p4>A8o?S@15*ubq%HiGH{9ZLfS z|NH9#uI*??*5`$9`p1GLtoOgy(f6MHk+f6{#7=%ZCyD+M@oR|UFxGdG{OLvv!}4_a zx(VX>Ve5Y@;Z4J+xHfHt-Y%0D$ghYe#Lu)aoaOD&<0~iBu>N^EA)d=%d8G5#oubA6{RQegJoB|7$oTrr>@@Np%Gt;B3N|3N~$Hut_?ROtIHmvaM;(e*w>Pb~r zWU==(pO~mye-P0$cQ8C}sJbPu6x{t$dDLZg@!bXnd#C7se}ya^-mj*rH8L2rt@G@k zW=;RXKO!SraKx7SM;`0WgG{OY`w8=k{y;QgL2gsni6*KDjcDk(p<>k{rTL@fv8^AF zEy$D?m;*Wv5T3LAa*xF$hsNrakLMJ+yJ^tKN7$~|zA@XC{b7zQ0@}Ch*qfAtI=v;` zWS`hqr$p@Ovl@wl=d=ZzC+;lqM!9ry+eUvSIsOW%&Z~0v4}42Knwz_H_Qm!k#%b07 z2~<=V0Ui}>0eR}fYx!TXz(wo*hXIV{h1Imc#xjN(54C{A3>3aB0m~(XU!!O1y5AN8E|W`monM zyX&3=_ch(=h~FOl8KG!}igzyq5>{hMV{iWYyO*&oQDXnT~-rDi*E9eQrDNM39mlyh9_Uh2oE0-Z;y zAixpxk7j4IYhNu9Uj1TvA~zRBys4h;?~{8M2RIwHuT9r@WU2 zMoJ4c-&z5q>SlMf!FBu@Rsgl#S&cA95`q*1OX)_M>|UT|R=n-Z4IC(Q82V<4YQf%B z54liw&-i4~q`8v#Ld46j+|8dK`iH6>zgQ;h9NlCQ*~?_9Qy8>e8;(3{s*~agSh2>o zpo+{8D_+t=_`83#xb}U8((}C@KW6eKQwAtQvF$h3Z0QX)=dX~e9M~pdz80u}l=|1W zlvJ-SJA(|E&MQ-z8)Ap>%nK;`4cF?K*&<7$~%HhDp$yI zhqiXQ-mS$rYYK#{Aa>8R&c#T#$EiA(^{>47*!)C;Q&NY8X;DokI0y&pa+rsdi6V%F@;Xm`+^fNSbY_58~I zyV2Sa8H-@hRSv=};)fc#-feur2vKB{j=UEtSdCAdoWT6hga|a4FC#7U41$s4s|72( zgozqINw~9n>8RG!#>mM9NfXBlCkPhSau?9XQFx=;_d&I;0zk4pIVSm?--Stb=LP95 zG25Q(yqw&pAQ-5`!&DwrO4GSAuOwSF_ey;E^5p(-2mi0NXQpjd?^IP1WBdLLv1}_F zK{AP5h=B!Y+rF*6mw7(S5il>35xz6vpq(kWG?{-ySt0L}wt_DG15(FXN6-C!{D8qp zpfSI<1-uFQeC7%-tpE|$ns&M`m3csCjRbj zqgkljbgx^Z+~$PM^|)AhQqPIkw=Q8018UP0kIfC(Yf{ZV>J>3_sb|3^zG}Evr(VJ- z*|T~b0O{(v0;zyHV|X#N+jO`7BV$0ADY z^2Zg7bR(#C?&}MzQ{K+E4_U@Wui@J2x6KSL7sq8MK1c}V*(E%QLKro5ZHkK(O0g^W z$EY3K6s?vN&r2<9{*DA!YyR~k!;+J& zZ>~5^KvTwM{iih69gpx|9U5mSom84s7n{{6l$LUBZr3LIUh^jV7iR~3-9BVdOQ4Qz zkHnzV1`_ok*OqC68N@!wnoDQCE%nC8q*S2D$OzQqi(XI!O!)mhM(59bHoU!Nlr3fMvOJD>*s?5ec0k)_5?9s5dsq3()yaR6a53Lw)%d;semr;Hp=@!kaxithC>O6}2HUPiloe0Z) z9TcPISD@z39#!gz*Q5QI3t8s-3P4x%4U}_t`%Na#2a5v4{yGN;PyH7K@ zdc2=}^c3(;Q{t{ZXJpQm1^zzctnX`n8Lfp+WgLRk3j}HK+{BIZuHuf^=M_Cfa2}ed z#$QTm;i+|9HN9m|buq}Zn|>)J2Fp_sL)hAt*luaWkR6e8q*x*F3((9TU~%wzvDCU( zN=j_g)>dBk**xz7!b}2@`M|++`Q5O)%qg&?)#Ox|+h_Ir@uj-Yx?oQAQ|fAOU=J@R zbPeh+4SfUY6`PA?1Kdfk2MpcX&eeqhu|G4NwUl^MU?Uypcajt2GP`aGFX0pz=ZW9v^>~ern{>!I>jC6U;!Q~k z7CK$i0?+T$rm$1!OkKkdJLd7|;8DbQ7i%x-RZ{cEsuvS})U5rADmml|ac&T<%U`=@ za^&pN$U%*4r&oTHO&Xcia_NXKF?CnKC-9Bel*6-7^A*%vnHx|-hV_H(Ta$Gjl}M_4 z3pCw+fhU)nn}?%l{Ni%7B!QXWqgih*Avzk;JS44#S8P9_HJG-n1N>J|p6i6oPf6TU zlBi((lfoxwJ4wzRtr4s5HiXn;iPQWS#VER;Bodb7G3sp4G6^aKDzFosoS6Gq>CAcU zT=0u5l#pf@R$wx%5${5tY$;T?Yo{TdAAerK$p%I%$1p`EGiTqw=qb`5JbXldo=rLV z2LEEhwm5qrvOlFpp{7_??cc)W3wG8R9D{r*DV!R$Ayu9TefpoJRUC^BHegqy^ z>O9vMM_PR{fb1Xn+>Wqq{AWDO=~Uyt4>0dd z0=PePD2D{*0~EBJ#FuG#gW9!DZ&@yVN==!l;Lp2!z?&ksFaIMfzSWt+idPeS2V+p( zz(6|$|D7>PFBmXfH{e}axPm46z=0d?V#&TbQJNZ8Vb9{05lLWWDF^=R1N(n}Rp0CP z_($r2e*gM0!b5tj2hCQ1!Y&+l9M!pHv*i)E(XHE(N6mKn3-7uxCx<1TJ=J9 zNKWXsE{slt{McN=fTJa@4Xu>6LoLrpNLjoAT$g*&j=j1 zoSs|F3g2stxDLc*F+&-%6e3Lq6POO4^O>FD(jQqoq|lmZNTpNEkT}WCAoqn zq7k5ZP|!*}gmV>1)}FA3ZxO`XfvoaDB{zGil`=H*{=eP9f|IlkIsLf95`L2cwwVfq z(u47l#(;7DV;;_(opjjUIFpGpTcvI%#R)2eCniWN7kiT_5Z4J(lKGr9CT)@r${z1y zqHeAgvUpv~4eW{ZaT=Qx&URT)BH=*Jy;|g5htJ#oNp|w|T2J%+SF)jtUYoVI6MnL< zK5#_!faVkP^I4@YY39-})ht0FzKaV3_FRJ~W20$gg3mDsPc%!Uz&1g|g@8AF| zqQxz46zx*Qb3m~7c8o5xmz5J2HWz4nsg=2~6g^sZNnI4ZryUBsI z$HIvk#1AaO<03mdGVb@?87$)=m@7R7b8>GUboGp<;sOd-zlU6ks#^@6gd!R@??0@? zKur>@{=#x^Mmbn2{!L(af`!Y?qH?s%>FW3P6&_=J7Pc(0l;pH@f0WV1*~*F>ukz_F zJ^;}W2aa0q?K5*>oZMnW+ti=yl!k?wS}I+@j<+6Bh-hOFt_KR-SrwFeX|oKRD)k*8 z#_MSq>cNtazI|&U3eH?lM~{j(cmqiS8w!IS$_kvq)+7;+P;T~CP*AlNFPqaY= zsMkFy(^5ZXRHvM?Gdmv7kAygDscqWD4%4o1rMT7Igi=bMP5vGEF#f42&gBPHS;qiY z?#!DZ4Uy(dZ)7)R&tB8C(jLJDH&FQxzxJF04#HGh#T+y-~uB>i^3(N zSC$5hTNNX-V%`jige$YG*mv?)rf{2!VRukib1nA$P&;|9oc!=(=VPN5^n&v;r8U3j zx7s3V^&rmcGk?a4)~-Yy9e<4|P)l<|4tvZn3CY55WVfB0Gu=fv2e^aRWI(k=$0?%L z6nt-=tM#utzS2lnko*Et4Z`L&9O@YD@7OPY)w}_&3+Ul-#=HHG;LBM z%>&#AoZNo=NttimN~{9}BoqgF_0V&yL6i5!Xpka|psmue7 z>N>$w4x>bt@H~jP%H!TNTF`C^T(Kxg`QV!lf6D0^p|(b~$c0UEP|}h}@?h5;PaY@AMf!rKBf; z;m@B{r7d5~e4=?K6-Np$%khUvxG>Ll4AS>03FEmTj4teXQ=g+({Y`}pW>t7Qmbb5Yo zi9hSBQ}XcJ*)(eiq25C&3k;B=ETije^PRt@OQZB$FUHUVpyV*`&PPg`ZB-|$PT>Y2 zrYa9}P_iMj*IelK5qu!Eov|_Z)8Az4ZFTG zE9B&D3XQEX;2~WL0#iPgfOH7V5u_dM;&QW-cB?da%W!0}6kVj7s5#)aPE^q{UBjxD zbi(;B8reM?_~xADJ1AgrJZ+GaGlAIoj#@MnloVic!lw4{$h^rfBSnmWslK@L73j{ow4m?5g=p+}ynhfTjYO0YG0 zjn+VuC zQpAKSaSyH&n<SIPw~S{!>w&{RMCY z@96kCy0DUbm|>{mx-CE2(sek~e}{f=8VMJd>!i>$K}=H|Yv7FE_t z&qmj%iVp6(9dI~hWQV5JR!R4jt$)zmAlW)@U8CmtIoKIHJKBQ0szYqq{(Ur*(J*%T zYb{1L@jF<$TFyC&;#e%w2$wp^6uho~}2|aVo6F{uZzZ z4GNnUYOOlc=*K)ro9;qklJu5jooMeeu${1r8D73=(*yW55yO8mx{2{CHX?{~(SeLE z(Gd|_QZt1w|3v@bilfo7=mBdxO4_M(ir|~GC=nYmX_+-6WH|{bYukm@$c?reS3p?$ zz?m~YRr9p&0~}LtYY*bfTqXblynlc@e-q!9vOOqy1AQO0bzkd_4d?Z2egW;}OU5sT zzr=T>YTaB}FDC;38Es~I30Sa$RoE4B(+F4{&5n^0axLka8*t6$R`7Qt-A1K~M8mjL z9wxh2hL6B)qrO$zwUvKZXO@e4t7=CHu~ue`__8wJq)p9k>%;LVc}?QCszT^rjOL>MI`@cIw2Lz{wA_ zJ`0Gi9$5$WtR!Z?*!SD{LUwlg`+WDfG^Hg@li5@)uWuBoxOk}zPhYbBX#0FlRwrhF}xcq%JF z>@IpzENwoHKhz=npQ`1Xs%`g;vZSO$My1^Gy?__e$wqAeFaY&bSIg5iH!8Xw!T$J2A2YBVR7MP-tvI^2e8*!2y#!d z4N-C(Z_5qs7doMnUQP{)-6#}is!vR5!-AkFXz)vcS;h|#ZLiQX5?92+^c;PX1~;q&AXS1Gv+I!+2b$d!Jms0nD)HV zSAu7Z;pN*|ZV`UoN_GGHOJ)~`UAaDhW9tiQ!yWQxi?;T*Chu&d1dG}eapH!qXRw;m zx?feh?8Q0QJktV(p8e&KCy`|F>g)sKOsDh6CbJBg(wEJ%B}(h{?AZ3}oxs{dIIq(5 z9~>D@DYy?(??m@$MRs%dRNYFNEBnAIvOf<;OSxfxIuX0k+rZ0X8h#ugzxhmTql4{c z=ur6FB%$Q6;&9t@#x$_kyF`8$#Yd7oYuQYPphwy+6dFRFqEue_letCJh}*s=)o7P~ z7>XzFI3wT+l%&!d)GO?1;I3*RC3y+7icTHcusd9=1sN_ox#bx{NF}tr9=|XQ)4&LS z-_~C9&7iO^Ewg6UGn_n=s`+iUZ)MY;)Sk(M|8gnsnWm>**9^9?VqNqv02(slC9QKe z$Q!1+$h@7@1bSUaMs2E&6w^RM^Hkkx_Z_ zSJHD~;2M@;riZns!Y`XrT6V|tm);>TGRK5-gVskng@=qW>A2@*%d8_g=_}+-6=Jnt z2gV?XCT2yUW+-{Wp`<+bavxx&p%nTSc+Tg+C$~L*dn}%eH_Qb2UVcY}tjx@{LvjAt z0nk&yI{+ijJ0&2Oicm8<;wOU+oz-!&2dS4pk<4V|ifrc03aTU<$4#s<6__is%iBSQ zPva_m)G&+UE#Kk^ZHA(3>11Qh1uVGw9l!Kbc`(!SE0>$zfr^+nTlUt3yMgaQFrZOK z-efbl!7K#wrm@{sJv>Ld1L50R*L9S58I2vCH^Xd2>EI;y$u+9<`z%Fd3ugOI@NX<3 zh7n19u>pPK{n6KInl=V8XnumHJY|JF;kParHYbMK6aa7fa`*E*xL7yQ9qO3NeBk|q zW}yzwD3P#1%Q5}|qpk)Gcn$t?WxaAxTa za>QUrpIePsx+7*)u=`9U|#|F$;lsfLN8kfDV{Dq!icy|_d6FTJ<>+sSIZHThu!8&SH2@#V${ah2zx{i zp2wv_o%`P*8gHfcn5z|5d2H?8(C^G!x@;{%9Cy0omJdM_kR7L;48FG4j5QM9)2Au`RIdHR7?ZUXZHa_=myO6blv3^DW-5c>!=j4Pd5) zxy>*g9{dklHmcfTWfRPtcZ0Z>mir5fZo|u{&mUNo`BBVwKQ{IA7qlz!AfnB~oq^EMPn2C_t@oy?ziKOf zTppwA>6Vc~9>mYW`YOrrP&kx}FqRLDYbKPm6kEkJD7zs2?Q3OeGa;-WW{!W|xVHVh zv$_^Uxw`Il`%fdjJJZsoa_L2}RS$MPac1PATtf$2%j8u`Q32x2AMeBl^4f4|P5f@ITb;25g60 z?#NcT(+Yl1-QEhj5l_&g)X0fWq9a|^@3 z8>se&4(e=4*)CjmfCEed=BI6Ih4uX4<_Jhiy`=Hq^@L<~WQ*;K;(XrCaLN}}uxba3 z;FIywa1GP4kXC!eIqAc_>UNK^f4K6^F#^yDdOBDT=yv$z#jSI9I>m%*%r6+uARGsww+lSI(=)6wzOk za&Ajb;&!b$ZySHCN5MEDTVQPYu(yj5`jX%D%DVdWRTu9&A!fvoXLQmG6L=dO2rBy8 znP=EeLv{T#S~l^?=w-#+nm?psmbD&l+31|TzXyvzW3;bshq>rtvdh+D;`(SL@)D-i zZ(Q_+6sBJ(nGqbH-!M-}h!anMgA;nv+YRge)gQ!X`G40@H@l~|VW_NC3*Wt~u-zu` z4&a^pwrfxb53w-Q^LtEh0d)(#pxw=~^RVr~y5}RO`;TT9skT>A*?iHTcT9)E=D7ia zQ_6SP41C|uS^>)JFd1VjQ~fBtGw38psn_iunz!i(UnvAGp)sj$`GMf6=XDOdBJFVK z<)oy@q+qQ8Msd%Zvn*U7yjkqK*XT9tKtg3a414fY^%@Ij@zo%?JsbH>)+Y&4=zOWG zS?N^ovcS%3dKzUKJ7^MsH=%N5m}lyiu2|-Z?0ymXGc$!HT_1Fvif9)fU_O5*UmyzF z2ipkdVd?mZL(|MIr2q^u_%d0%G*EVkyLHogZ8wg14O(^cU~26WQ8F`X_YAleQGU0% zq@rO<>uvInwHVC}caw<|<1c=jj(R}RjnSNt-H+aG@N0ZmB#=r( zF$HdPWT_?brZ%^^97#rznYqU#L8XOqM~q8)M)k{tM=)*u+eldz(7j}YCmGoU51rL@ zy5%x}<{>AWkP+<+yjw#^fWKx7;X$2^%fOeUTf=Bm5UW4XPWYvE`+ZJqHdVQvr1k84-h& z044}K$Xt}4C#ObyUKnP4$IksLkk23tjuh5h-7rW=AWhvuoEHT} zSCBK>U}v*JzJt$=iv#vm-BXHNxV%P+7Us`QnOvsoh zbMx-l4brz&<*dxCj71LD&izgy8+WgC=8Y$OGPhYrkmJ!Ic@R(G_+k?&-gOM6gYCvw zhOLfY^_c2>V=L)UNE#{{ido%xQG~6!Hm2IdI>e2J_A(etUz^&uc zQ(7v=jA1t(IVPw}&3`}#5lNG5tJ%t{a7vXR_SLbq=0f1A+tRvu*n!}~nR}T}$G@Qd zDJiV#Vtzk4_x{Di(qn2%KmQ*{jueU2O(?+C2~0Ugn{x2GKR{F1--T()SUJtL@f^VCtA(Z!hLl4&+l@{47cEf_^jKGR+9 z$Q*UrFF+aS!f0;G9AaZ{*6o&7NX_)~V8&Ju4YnT*v%Qt3rI@M%)JAQeqhw5i(f~b`C|AJyZ9eM`s;xDkU#*4YMMXeBq)> z&1ol{K;C1Oo0oBlfOCr*m@oXjg|}+%W9H-bF|!*h*O>B6DeVtck;hNbbt(~9YhqFo zzakMQvp$L)#$8?6{#(bEezdoD2?4Zb>W0jeQOWH_d)K|r996AYECYSAb;31I#J-)E zlDrQtCHkpeP#iyDx=N%hkq%G5yP{={3OpUrU1F9RcLvQ>z@8tOdz-=! z5T!KYx&inRYQ&i#n%xvN(FS+bXq5zUFy|S|#YP$2pxXN+BN~b8?BlZO+Fvqz<{dqy z-J{4AA##`jZoVvoG~iBj#z1s&ulLK9O=|3DuPI-SUJi0W*MkZ`z}aTW{qL$TFlm}@ zOQPl%lC)pTZ~QrlO0J*^p`U-5Lr#!fUw2k6=++M%6D-pw%b$8SQ!Y??r){pKXGAxQ z!aHkHU$r#8RO+^L(!UJBWx?V(r`qA>)8w&kw!f!QKg1TgXmd%DCwC z(@2`#Y76JO4MDag&726DZ88E%GUig6HeC+5A)EvozCRL7*L@NphCo-Yyw(l*CzX1= zJSL|I<&eKi#rC~rPh_~r5!Sc1{Q}cRFY=yAY>TVv`G3^kH))#sr(IVFU*&bx`Y6KA z>x=TR*)Vz;Fm96L1b(!I`^bT@^_-HKW2e_T3?h9!H{PQ^(=D5yoYq#8uo9Btq13Gy z1&vXM0PABDAi+T*stB_75BxConzv>i>YzzUiaS-I> z3k3k{^N~d!L0w-$+-Y^TBpk_;Q|E@u=YtfeBfr`Ce8O?JCkW zAF|V4AQiJZmDimXKl?;31&$9OTFIo6AKIlH)KkBemT=T}onJKk3)2#sq0Ir3_LfJC zn5$rRp*VTnnNNPR=CI_`2Qk8Sz)OD`te&Ziw6%+d*c`xQyxOuo^RuzVQikC^s8t2c zBGr1G2pyZT9QCQ})3QeYoh0D74P#ZPPHM=2PNhMGf3;G7F>F{>X{wA>mnxI%CYUsH zf@xhlVEI^Y$Kf<0Vhx)eSElRKrlLoEc@q0xbo2?K-Fb7@`>1;@QkR8eu?l(i7x3kQ zmE@l){OQls!9tp~!dG$z<&Y>>P2c1LB4E5}6#Pnos}<+nCu>S z+dGo(0a2OJE%G(T?U>EEilcs2*R=T+ zHno;<#Y%cI+Ev>Xqo2SjJ*!HRPz8q{+8sPs4BS%oo)ydfkgu=Fg^;8?S)b@kEGh=& z!4W+GSVF7n@TVvXkLTG?+RTGhC+#x?&pYLmT7VzH9xkC*YUmHNHYlb$ELtwI+eh2OP6s!T5n_Cf1j3O3UZ<=2z{GQ&oimyja zUZD>5uODA>_A_t3Z8M5q3J@I(N8Zw$-E#2m7fjkdP+_fs@!`xX;5Nn{wrwsd_EzTc zrEA`mDd`iyZ4=Zq$$DuwzsU{ANE*ewnwqjh3pA%+>PE2Adb4|Bri-3(l!ZYJITq^| zSH%I_zbz}8)AjoBD%>S0C~?cHiro&Trsf`}_>###ff9mXD|2{uwxsbT`OAAxj`MOc zL8nD}qZ`T!svTcvQa=>{g;093Y)>$2vHw{RP?N5%l;*dT8_E73Xa9H7^^&MNIEzgqhUyU9)X7ibqnhPMgQ3g%zgEO41TNFj zX+P?A%5er6uWy29m?Rx=sZ8@7WU7r1QK!aL(NP-iC|uqyD-ZJTn6r0zP(2`rX`iuB!AGK zIl8`A?p$$cCgxE5HQOI7+5C1~;xM5UA64vP*-aUh1Oc_;`XEh020ek-t1HR&g;0V~ z*Zoj$27OwT%~Bx2nMK#ndyeeeDoDV^z@ z;j94uSsB1d*;(O0Y6s}nBoah2AtYgpGB}@(Pni@cFn?9K3wik&- zz0}j0GGQy_oO3%Sf2nOt9aov;|Gaq&9L)7QryM~zuNgJXNTM_(TpMwI38ZeEaG1OP zw~~+9+}M9yv1I)rZ+We(@)_8!S{Wjh2>fPbFVzDwbf+f?*)VTleq8JjNUAIA3N7DS zYZUetr+4O)Vp?#1xQyvAPkDFBy^zc9%bZsu3hfHMYfw4 z7hhp)XcGJH2@R{fhW~>ZPS`+)WF=r9 zOXJc)&hQ#Rh1GY+w|Tqh%|*+#k3ydZcwOc9QP**A^?b6<)gr_3R~Mj?-R`^#fOnZ2 zR>)hn7oPa>`6b_$a2yz>elqa7%&aA7iI8MhwU(Ybp>`J74sI>(4-dF_MW5+W){jkR z<>WYto5RHPdFszPo|u9{$mEJTHFy|iNlr+hZ*>={+-luVU|?>3OipyrQfH4(6Q+>S zg__$C=gflqGzGhT>+8qgX`kxG6V5i8Cu=@sUOZf=24{uqazb-9O?hggkvgfM!_;3@ zmnVWmjL+h#w9?O-f|vmGt?>s1bsWoSF0)!I%Z~YG8kkC_J>;YvR`|5jcTPNauU>p< zu^M78UM^0)3F+lN8Cdn;_3N<8|x z)@;oaep`wQ+*qzi?Gwo?ys+-!J5ILqtfpxe|8js}zqCfre|+#)O+AGs zI`p-9HDU+M%nDb!q8{9CRo1m;A0Nu3OPqUxcR*t|j}ohGq7{sfe|Ald_TYJNQ=o4~qaj)0R!cb?~AC=A1yl%y^Z@F=X?$z?F_LKH0Gu$;>_Ha;`0*uJT z^sZ8|X~!9-!8;<_dKkY(DP=k2H<;p3zFWOy5kxxv#Q(GJ=@@03r5ZawAmd_Y*X~A9 z?#s3o9StT>{GCklDCNARrDK>Q*PzF+zTBhlB!g?Xcc@ppQ&Lf>Ri|ychGx!gN{;r{ z5!J=%?Ua~g)MgNSi!Fl?DsSICc(}-pGGT|E`

@d{3}g`?V(WQiHA`I`OvO#Ebuj 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

~vX5@Bai`JX*g;X@d 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

U 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

XC$KQp6?XLV+O4YDd$MBYN;KGDk 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 Date: Tue, 26 Apr 2022 09:08:39 +0100 Subject: [PATCH 26/95] core: fix mac tests (#576) * core: fix mac tests * enable all tests --- tests/MobileTests.hs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 0ca78fa798..9e73060f9e 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -53,10 +53,10 @@ testChatApiNoUser = withTmpFiles $ do testChatApi :: IO () testChatApi = withTmpFiles $ do - let f = chatStoreFile testDBPrefix + let f = chatStoreFile $ testDBPrefix <> "1" st <- createStore f 1 True Right _ <- runExceptT $ createUser st aliceProfile True - cc <- chatInit testDBPrefix + cc <- chatInit $ testDBPrefix <> "1" chatSendCmd cc "/u" `shouldReturn` activeUser chatSendCmd cc "/u alice Alice" `shouldReturn` activeUserExists chatSendCmd cc "/_start" `shouldReturn` chatStarted From 645587431d0599cb12bc2fad4ea4eddccae6c77e Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Tue, 26 Apr 2022 12:52:41 +0400 Subject: [PATCH 27/95] core: fix asynchronous file transfer (#572) --- src/Simplex/Chat.hs | 8 +-- tests/ChatClient.hs | 27 ++++----- tests/ChatTests.hs | 142 +++++++++++++++++++++++++++++++++++++------- 3 files changed, 138 insertions(+), 39 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 74233f59e0..22f43ec461 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -879,7 +879,7 @@ subscribeUserConnections user@User {userId} = do threadDelay 1000000 l <- asks chatLock a <- asks smpAgent - unless (fileStatus == FSNew) . unlessM (isFileActive fileId sndFiles) $ + when (fileStatus == FSConnected) . unlessM (isFileActive fileId sndFiles) $ withAgentLock a . withLock l $ sendFileChunk ft subscribeRcvFile ft@RcvFileTransfer {fileStatus} = @@ -1675,8 +1675,8 @@ cancelRcvFileTransfer ft@RcvFileTransfer {fileId, fileStatus} = do updateRcvFileStatus st ft FSCancelled deleteRcvFileChunks st ft case fileStatus of - RFSAccepted RcvFileInfo {agentConnId = AgentConnId acId} -> withAgent (`suspendConnection` acId) - RFSConnected RcvFileInfo {agentConnId = AgentConnId acId} -> withAgent (`suspendConnection` acId) + RFSAccepted RcvFileInfo {agentConnId = AgentConnId acId} -> withAgent (`deleteConnection` acId) + RFSConnected RcvFileInfo {agentConnId = AgentConnId acId} -> withAgent (`deleteConnection` acId) _ -> pure () cancelSndFileTransfer :: ChatMonad m => SndFileTransfer -> m () @@ -1687,7 +1687,7 @@ cancelSndFileTransfer ft@SndFileTransfer {agentConnId = AgentConnId acId, fileSt deleteSndFileChunks st ft withAgent $ \a -> do void (sendMessage a acId $ smpEncode FileChunkCancel) `catchError` \_ -> pure () - suspendConnection a acId + deleteConnection a acId closeFileHandle :: ChatMonad m => Int64 -> (ChatController -> TVar (Map Int64 Handle)) -> m () closeFileHandle fileId files = do diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 3fa5f3d4dd..9716632e9c 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -8,7 +8,7 @@ module ChatClient where -import Control.Concurrent (ThreadId, forkIOWithUnmask, killThread, threadDelay) +import Control.Concurrent (ThreadId, forkIOWithUnmask, killThread) import Control.Concurrent.Async import Control.Concurrent.STM import Control.Exception (bracket, bracket_) @@ -81,16 +81,16 @@ cfg = testView = True } -createTestChat :: Int -> Profile -> IO TestCC -createTestChat dbNumber profile = do - let dbFilePrefix = testDBPrefix <> show dbNumber +createTestChat :: String -> Profile -> IO TestCC +createTestChat dbPrefix profile = do + let dbFilePrefix = testDBPrefix <> dbPrefix st <- createStore (dbFilePrefix <> "_chat.db") 1 False Right user <- runExceptT $ createUser st profile True startTestChat_ st dbFilePrefix user -startTestChat :: Int -> IO TestCC -startTestChat dbNumber = do - let dbFilePrefix = testDBPrefix <> show dbNumber +startTestChat :: String -> IO TestCC +startTestChat dbPrefix = do + let dbFilePrefix = testDBPrefix <> dbPrefix st <- createStore (dbFilePrefix <> "_chat.db") 1 False Just user <- find activeUser <$> getUsers st startTestChat_ st dbFilePrefix user @@ -107,16 +107,15 @@ startTestChat_ st dbFilePrefix user = do stopTestChat :: TestCC -> IO () stopTestChat TestCC {chatController = cc, chatAsync, termAsync} = do - threadDelay 500000 stopChatController cc uninterruptibleCancel termAsync uninterruptibleCancel chatAsync -withNewTestChat :: Int -> Profile -> (TestCC -> IO a) -> IO a -withNewTestChat dbNumber profile = bracket (createTestChat dbNumber profile) stopTestChat +withNewTestChat :: String -> Profile -> (TestCC -> IO a) -> IO a +withNewTestChat dbPrefix profile = bracket (createTestChat dbPrefix profile) (\cc -> cc > stopTestChat cc) -withTestChat :: Int -> (TestCC -> IO a) -> IO a -withTestChat dbNumber = bracket (startTestChat dbNumber) stopTestChat +withTestChat :: String -> (TestCC -> IO a) -> IO a +withTestChat dbPrefix = bracket (startTestChat dbPrefix) (\cc -> cc > stopTestChat cc) readTerminalOutput :: VirtualTerminal -> TQueue String -> IO () readTerminalOutput t termQ = do @@ -156,7 +155,7 @@ testChatN ps test = withTmpFiles $ do where getTestCCs :: [(Profile, Int)] -> [TestCC] -> IO [TestCC] getTestCCs [] tcs = pure tcs - getTestCCs ((p, db) : envs') tcs = (:) <$> createTestChat db p <*> getTestCCs envs' tcs + getTestCCs ((p, db) : envs') tcs = (:) <$> createTestChat (show db) p <*> getTestCCs envs' tcs ( Int -> Expectation ( do + inv <- withNewTestChat "alice" aliceProfile $ \alice -> do alice ##> "/c" getInvitation alice - withNewTestChat 2 bobProfile $ \bob -> do + withNewTestChat "bob" bobProfile $ \bob -> do bob ##> ("/c " <> inv) bob <## "confirmation sent!" - withTestChat 1 $ \alice -> do + withTestChat "alice" $ \alice -> do concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") testAsyncAcceptingOffline :: IO () testAsyncAcceptingOffline = withTmpFiles $ do - inv <- withNewTestChat 1 aliceProfile $ \alice -> do + inv <- withNewTestChat "alice" aliceProfile $ \alice -> do alice ##> "/c" getInvitation alice - withNewTestChat 2 bobProfile $ \bob -> do + withNewTestChat "bob" bobProfile $ \bob -> do bob ##> ("/c " <> inv) bob <## "confirmation sent!" - withTestChat 1 $ \alice -> - withTestChat 2 $ \bob -> + withTestChat "alice" $ \alice -> + withTestChat "bob" $ \bob -> concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") -testAsyncNeverTogetherOnline :: IO () -testAsyncNeverTogetherOnline = withTmpFiles $ do - inv <- withNewTestChat 1 aliceProfile $ \alice -> do +testFullAsync :: IO () +testFullAsync = withTmpFiles $ do + inv <- withNewTestChat "alice" aliceProfile $ \alice -> do alice ##> "/c" getInvitation alice - withNewTestChat 2 bobProfile $ \bob -> do + withNewTestChat "bob" bobProfile $ \bob -> do bob ##> ("/c " <> inv) bob <## "confirmation sent!" - withTestChat 1 $ \_ -> pure () - withTestChat 2 $ \_ -> pure () - withTestChat 1 $ \alice -> + withTestChat "alice" $ \_ -> pure () + withTestChat "bob" $ \_ -> pure () + withTestChat "alice" $ \alice -> alice <## "1 contacts connected (use /cs for the list)" - withTestChat 2 $ \_ -> pure () - withTestChat 1 $ \alice -> do + withTestChat "bob" $ \_ -> pure () + withTestChat "alice" $ \alice -> do alice <## "1 contacts connected (use /cs for the list)" alice <## "bob (Bob): contact is connected" - withTestChat 2 $ \bob -> do + withTestChat "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "alice (Alice): contact is connected" +testAsyncFileTransfer :: IO () +testAsyncFileTransfer = withTmpFiles $ do + withNewTestChat "alice" aliceProfile $ \alice -> + withNewTestChat "bob" bobProfile $ \bob -> + connectUsers alice bob + withTestChatContactConnected "alice" $ \alice -> do + alice ##> "/_send @2 file ./tests/fixtures/test.jpg text hi, sending a file" + alice <# "@bob hi, sending a file" + alice <# "/f @bob ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + withTestChatContactConnected "bob" $ \bob -> do + bob <# "alice> hi, sending a file" + bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [

/ | ] to receive it" + bob ##> "/fr 1 ./tests/tmp" + bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" + withTestChatContactConnected' "alice" + withTestChatContactConnected' "bob" + withTestChatContactConnected' "alice" + withTestChatContactConnected' "bob" + withTestChatContactConnected "alice" $ \alice -> do + alice <## "started sending file 1 (test.jpg) to bob" + alice <## "completed sending file 1 (test.jpg) to bob" + withTestChatContactConnected "bob" $ \bob -> do + bob <## "started receiving file 1 (test.jpg) from alice" + bob <## "completed receiving file 1 (test.jpg) from alice" + src <- B.readFile "./tests/fixtures/test.jpg" + dest <- B.readFile "./tests/tmp/test.jpg" + dest `shouldBe` src + +testAsyncGroupFileTransfer :: IO () +testAsyncGroupFileTransfer = withTmpFiles $ do + withNewTestChat "alice" aliceProfile $ \alice -> + withNewTestChat "bob" bobProfile $ \bob -> + withNewTestChat "cath" cathProfile $ \cath -> + createGroup3 "team" alice bob cath + withTestChatGroup3Connected "alice" $ \alice -> do + alice ##> "/_send #1 file ./tests/fixtures/test.jpg json {\"text\":\"\",\"type\":\"text\"}" + alice <# "/f #team ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + withTestChatGroup3Connected "bob" $ \bob -> do + bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + bob ##> "/fr 1 ./tests/tmp/" + bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" + withTestChatGroup3Connected "cath" $ \cath -> do + cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + cath <## "use /fr 1 [/ | ] to receive it" + cath ##> "/fr 1 ./tests/tmp/" + cath <## "saving file 1 from alice to ./tests/tmp/test_1.jpg" + withTestChatGroup3Connected' "alice" + withTestChatGroup3Connected' "bob" + withTestChatGroup3Connected' "cath" + withTestChatGroup3Connected' "alice" + withTestChatGroup3Connected' "bob" + withTestChatGroup3Connected' "cath" + withTestChatGroup3Connected' "alice" + withTestChatGroup3Connected "bob" $ \bob -> do + bob <## "started receiving file 1 (test.jpg) from alice" + withTestChatGroup3Connected "cath" $ \cath -> do + cath <## "started receiving file 1 (test.jpg) from alice" + withTestChatGroup3Connected "alice" $ \alice -> do + alice + <### [ "started sending file 1 (test.jpg) to bob", + "completed sending file 1 (test.jpg) to bob", + "started sending file 1 (test.jpg) to cath", + "completed sending file 1 (test.jpg) to cath" + ] + withTestChatGroup3Connected "bob" $ \bob -> do + bob <## "completed receiving file 1 (test.jpg) from alice" + withTestChatGroup3Connected "cath" $ \cath -> do + cath <## "completed receiving file 1 (test.jpg) from alice" + src <- B.readFile "./tests/fixtures/test.jpg" + dest <- B.readFile "./tests/tmp/test.jpg" + dest `shouldBe` src + dest2 <- B.readFile "./tests/tmp/test_1.jpg" + dest2 `shouldBe` src + +withTestChatContactConnected :: String -> (TestCC -> IO a) -> IO a +withTestChatContactConnected dbPrefix action = + withTestChat dbPrefix $ \cc -> do + cc <## "1 contacts connected (use /cs for the list)" + action cc + +withTestChatContactConnected' :: String -> IO () +withTestChatContactConnected' dbPrefix = withTestChatContactConnected dbPrefix $ \_ -> pure () + +withTestChatGroup3Connected :: String -> (TestCC -> IO a) -> IO a +withTestChatGroup3Connected dbPrefix action = do + withTestChat dbPrefix $ \cc -> do + cc <## "2 contacts connected (use /cs for the list)" + cc <## "#team: connected to server(s)" + action cc + +withTestChatGroup3Connected' :: String -> IO () +withTestChatGroup3Connected' dbPrefix = withTestChatGroup3Connected dbPrefix $ \_ -> pure () + startFileTransfer :: TestCC -> TestCC -> IO () startFileTransfer alice bob = do alice #> "/f @bob ./tests/fixtures/test.jpg" From a7554771a0175cfad331ee2e7659723d3c88cacc Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Wed, 27 Apr 2022 20:54:21 +0400 Subject: [PATCH 28/95] android: refactor compose (#579) --- .../java/chat/simplex/app/model/ChatModel.kt | 1 - .../chat/simplex/app/views/TerminalView.kt | 50 +-- .../chat/simplex/app/views/chat/ChatView.kt | 190 +++-------- .../app/views/chat/ComposeImageView.kt | 18 +- .../simplex/app/views/chat/ComposeView.kt | 307 +++++++++++++++--- .../simplex/app/views/chat/ContextItemView.kt | 64 ++-- .../simplex/app/views/chat/SendMsgView.kt | 119 ++----- .../app/views/chat/item/ChatItemView.kt | 21 +- .../Chat/ComposeMessage/ComposeView.swift | 6 +- .../Chat/ComposeMessage/SendMessageView.swift | 8 +- apps/ios/Shared/Views/TerminalView.swift | 4 +- 11 files changed, 431 insertions(+), 357 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 346f8ce8ad..b2d03c0c08 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -4,7 +4,6 @@ import android.net.Uri import androidx.compose.material.MaterialTheme import androidx.compose.runtime.* import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.TextDecoration diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt index 116aa4d299..93ed18039c 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.app.model.* import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.chat.ComposeState import chat.simplex.app.views.chat.SendMsgView import chat.simplex.app.views.helpers.* import com.google.accompanist.insets.ProvideWindowInsets @@ -25,32 +26,42 @@ import kotlinx.coroutines.launch @Composable fun TerminalView(chatModel: ChatModel, close: () -> Unit) { + val composeState = remember { mutableStateOf(ComposeState()) } BackHandler(onBack = close) - TerminalLayout(chatModel.terminalItems, close) { cmd -> - withApi { - // show "in progress" - chatModel.controller.sendCmd(CC.Console(cmd)) - // hide "in progress" - } - } + TerminalLayout( + chatModel.terminalItems, + composeState, + sendCommand = { + withApi { + // show "in progress" + chatModel.controller.sendCmd(CC.Console(composeState.value.message)) + // hide "in progress" + } + }, + close + ) } @Composable -fun TerminalLayout(terminalItems: List, close: () -> Unit, sendCommand: (String) -> Unit) { - var msg = remember { mutableStateOf("") } +fun TerminalLayout( + terminalItems: List, + composeState: MutableState, + sendCommand: () -> Unit, + close: () -> Unit +) { + val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) + val textStyle = remember { mutableStateOf(smallFont) } + + fun onMessageChange(s: String) { + composeState.value = composeState.value.copy(message = s) + } + ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { Scaffold( topBar = { CloseSheetBar(close) }, bottomBar = { Box(Modifier.padding(horizontal = 8.dp)) { - SendMsgView( - msg = msg, - linkPreview = remember { mutableStateOf(null) }, - cancelledLinks = remember { mutableSetOf() }, - parseMarkdown = { null }, - sendMessage = sendCommand, - sendEnabled = msg.value.isNotEmpty() - ) + SendMsgView(composeState, sendCommand, ::onMessageChange, textStyle) } }, modifier = Modifier.navigationBarsWithImePadding() @@ -108,8 +119,9 @@ fun PreviewTerminalLayout() { SimpleXTheme { TerminalLayout( terminalItems = TerminalItem.sampleData, - close = {}, - sendCommand = {} + composeState = remember { mutableStateOf(ComposeState()) }, + sendCommand = {}, + close = {} ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index 73be70f102..880c423ef8 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -1,6 +1,5 @@ package chat.simplex.app.views.chat -import android.content.Context import android.content.res.Configuration import android.graphics.Bitmap import android.util.Log @@ -38,24 +37,19 @@ import com.google.accompanist.insets.ProvideWindowInsets import com.google.accompanist.insets.navigationBarsWithImePadding import kotlinx.coroutines.* import kotlinx.datetime.Clock -import java.io.File -import java.io.FileOutputStream @Composable fun ChatView(chatModel: ChatModel) { val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value } val user = chatModel.currentUser.value + val composeState = remember { mutableStateOf(ComposeState()) } + val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) + val scope = rememberCoroutineScope() + val chosenImage = remember { mutableStateOf(null) } + if (chat == null || user == null) { chatModel.chatId.value = null } else { - val context = LocalContext.current - val quotedItem = remember { mutableStateOf(null) } - val editingItem = remember { mutableStateOf(null) } - val linkPreview = remember { mutableStateOf(null) } - val chosenImage = remember { mutableStateOf(null) } - val imagePreview = remember { mutableStateOf(null) } - var msg = remember { mutableStateOf("") } - BackHandler { chatModel.chatId.value = null } // TODO a more advanced version would mark as read only if in view LaunchedEffect(chat.chatItems) { @@ -73,7 +67,22 @@ fun ChatView(chatModel: ChatModel) { } } } - ChatLayout(user, chat, chatModel.chatItems, msg, quotedItem, editingItem, linkPreview, chosenImage, imagePreview, + ChatLayout( + user, + chat, + composeState, + composeView = { + ComposeView( + chatModel, + chat, + composeState, + chosenImage, + showAttachmentBottomSheet = { scope.launch { attachmentBottomSheetState.show() } }) + }, + chosenImage, + scope, + attachmentBottomSheetState, + chatModel.chatItems, back = { chatModel.chatId.value = null }, info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } }, openDirectChat = { contactId -> @@ -82,57 +91,6 @@ fun ChatView(chatModel: ChatModel) { } if (c != null) withApi { openChat(chatModel, c.chatInfo) } }, - sendMessage = { msg -> - withApi { - // show "in progress" - val cInfo = chat.chatInfo - val ei = editingItem.value - if (ei != null) { - val oldMsgContent = ei.content.msgContent - if (oldMsgContent != null) { - val updatedItem = chatModel.controller.apiUpdateChatItem( - type = cInfo.chatType, - id = cInfo.apiId, - itemId = ei.meta.itemId, - mc = updateMsgContent(oldMsgContent, msg) - ) - if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem) - } - } else { - var file: String? = null - val imagePreviewData = imagePreview.value - val chosenImageData = chosenImage.value - val linkPreviewData = linkPreview.value - val mc = when { - imagePreviewData != null && chosenImageData != null -> { - file = saveImage(context, chosenImageData) - MsgContent.MCImage(msg, imagePreviewData) - } - linkPreviewData != null -> { - MsgContent.MCLink(msg, linkPreviewData) - } - else -> { - MsgContent.MCText(msg) - } - } - val newItem = chatModel.controller.apiSendMessage( - type = cInfo.chatType, - id = cInfo.apiId, - file = file, - quotedItemId = quotedItem.value?.meta?.itemId, - mc = mc - ) - if (newItem != null) chatModel.addChatItem(cInfo, newItem.chatItem) - } - // hide "in progress" - editingItem.value = null - quotedItem.value = null - linkPreview.value = null - chosenImage.value = null - imagePreview.value = null - } - }, - resetMessage = { msg.value = "" }, deleteMessage = { itemId, mode -> withApi { val cInfo = chat.chatInfo @@ -144,55 +102,30 @@ fun ChatView(chatModel: ChatModel) { ) if (toItem != null) chatModel.removeChatItem(cInfo, toItem.chatItem) } - }, - parseMarkdown = { text -> runBlocking { chatModel.controller.apiParseMarkdown(text) } }, - onImageChange = { bitmap -> imagePreview.value = resizeImageToStrSize(bitmap, maxDataSize = 14000) } + } ) } } -fun updateMsgContent(msgContent: MsgContent, text: String): MsgContent { - return when (msgContent) { - is MsgContent.MCText -> MsgContent.MCText(text) - is MsgContent.MCLink -> MsgContent.MCLink(text, preview = msgContent.preview) - is MsgContent.MCImage -> MsgContent.MCImage(text, image = msgContent.image) - is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = text, json = msgContent.json) - } -} - -fun saveImage(context: Context, image: Bitmap): String { - val dataResized = resizeImageToDataSize(image, maxDataSize = MAX_IMAGE_SIZE) - val fileToSave = "image_${System.currentTimeMillis()}.jpg" - val file = File(getAppFilesDirectory(context) + "/" + fileToSave) - val output = FileOutputStream(file) - dataResized.writeTo(output) - output.flush() - output.close() - return fileToSave -} - @Composable fun ChatLayout( user: User, chat: Chat, - chatItems: List, - msg: MutableState, - quotedItem: MutableState, - editingItem: MutableState, - linkPreview: MutableState, + composeState: MutableState, + composeView: (@Composable () -> Unit), chosenImage: MutableState, - imagePreview: MutableState, + scope: CoroutineScope, + attachmentBottomSheetState: ModalBottomSheetState, + chatItems: List, back: () -> Unit, info: () -> Unit, openDirectChat: (Long) -> Unit, - sendMessage: (String) -> Unit, - resetMessage: () -> Unit, - deleteMessage: (Long, CIDeleteMode) -> Unit, - parseMarkdown: (String) -> List?, - onImageChange: (Bitmap) -> Unit + deleteMessage: (Long, CIDeleteMode) -> Unit ) { - val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) - val scope = rememberCoroutineScope() + fun onImageChange(bitmap: Bitmap) { + val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000) + composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview)) + } Surface( Modifier @@ -206,26 +139,21 @@ fun ChatLayout( sheetContent = { GetImageBottomSheet( chosenImage, - onImageChange = onImageChange, + ::onImageChange, hideBottomSheet = { - scope.launch { bottomSheetModalState.hide() } + scope.launch { attachmentBottomSheetState.hide() } }) }, - sheetState = bottomSheetModalState, + sheetState = attachmentBottomSheetState, sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) ) { Scaffold( topBar = { ChatInfoToolbar(chat, back, info) }, - bottomBar = { - ComposeView( - msg, quotedItem, editingItem, linkPreview, chosenImage, imagePreview, sendMessage, resetMessage, parseMarkdown, - showBottomSheet = { scope.launch { bottomSheetModalState.show() } } - ) - }, + bottomBar = composeView, modifier = Modifier.navigationBarsWithImePadding() ) { contentPadding -> Box(Modifier.padding(contentPadding)) { - ChatItemsList(user, chat, chatItems, msg, quotedItem, editingItem, openDirectChat, deleteMessage) + ChatItemsList(user, chat, composeState, chatItems, openDirectChat, deleteMessage) } } } @@ -299,10 +227,8 @@ val CIListStateSaver = run { fun ChatItemsList( user: User, chat: Chat, + composeState: MutableState, chatItems: List, - msg: MutableState, - quotedItem: MutableState, - editingItem: MutableState, openDirectChat: (Long) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit ) { @@ -342,11 +268,11 @@ fun ChatItemsList( } else { Spacer(Modifier.size(42.dp)) } - ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler, showMember = showMember, deleteMessage = deleteMessage) + ChatItemView(user, cItem, composeState, cxt, uriHandler, showMember = showMember, deleteMessage = deleteMessage) } } else { Box(Modifier.padding(start = 86.dp, end = 12.dp)) { - ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler, deleteMessage = deleteMessage) + ChatItemView(user, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage) } } } else { // direct message @@ -357,7 +283,7 @@ fun ChatItemsList( end = if (sent) 12.dp else 76.dp, ) ) { - ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler, deleteMessage = deleteMessage) + ChatItemView(user, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage) } } } @@ -415,21 +341,16 @@ fun PreviewChatLayout() { chatItems = chatItems, chatStats = Chat.ChatStats() ), - chatItems = chatItems, - msg = remember { mutableStateOf("") }, - quotedItem = remember { mutableStateOf(null) }, - editingItem = remember { mutableStateOf(null) }, - linkPreview = remember { mutableStateOf(null) }, + composeState = remember { mutableStateOf(ComposeState()) }, + composeView = {}, chosenImage = remember { mutableStateOf(null) }, - imagePreview = remember { mutableStateOf(null) }, + scope = rememberCoroutineScope(), + attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), + chatItems = chatItems, back = {}, info = {}, openDirectChat = {}, - sendMessage = {}, - resetMessage = {}, - deleteMessage = { _, _ -> }, - parseMarkdown = { null }, - onImageChange = {} + deleteMessage = { _, _ -> } ) } } @@ -463,21 +384,16 @@ fun PreviewGroupChatLayout() { chatItems = chatItems, chatStats = Chat.ChatStats() ), - chatItems = chatItems, - msg = remember { mutableStateOf("") }, - quotedItem = remember { mutableStateOf(null) }, - editingItem = remember { mutableStateOf(null) }, - linkPreview = remember { mutableStateOf(null) }, + composeState = remember { mutableStateOf(ComposeState()) }, + composeView = {}, chosenImage = remember { mutableStateOf(null) }, - imagePreview = remember { mutableStateOf(null) }, + scope = rememberCoroutineScope(), + attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), + chatItems = chatItems, back = {}, info = {}, openDirectChat = {}, - sendMessage = {}, - resetMessage = {}, - deleteMessage = { _, _ -> }, - parseMarkdown = { null }, - onImageChange = {} + deleteMessage = { _, _ -> } ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt index 01c60ecc8b..d139ba90bd 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt @@ -15,7 +15,7 @@ import chat.simplex.app.views.chat.item.SentColorLight import chat.simplex.app.views.helpers.base64ToBitmap @Composable -fun ComposeImageView(image: String, cancelImage: () -> Unit) { +fun ComposeImageView(image: String, cancelImage: () -> Unit, cancelEnabled: Boolean) { Row( Modifier .fillMaxWidth() @@ -33,13 +33,15 @@ fun ComposeImageView(image: String, cancelImage: () -> Unit) { .padding(end = 8.dp) ) Spacer(Modifier.weight(1f)) - IconButton(onClick = cancelImage, modifier = Modifier.padding(0.dp)) { - Icon( - Icons.Outlined.Close, - contentDescription = stringResource(R.string.icon_descr_cancel_image_preview), - tint = MaterialTheme.colors.primary, - modifier = Modifier.padding(10.dp) - ) + if (cancelEnabled) { + IconButton(onClick = cancelImage, modifier = Modifier.padding(0.dp)) { + Icon( + Icons.Outlined.Close, + contentDescription = stringResource(R.string.icon_descr_cancel_image_preview), + tint = MaterialTheme.colors.primary, + modifier = Modifier.padding(10.dp) + ) + } } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index d0dea0aad8..aae695d4b9 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -1,12 +1,12 @@ package chat.simplex.app.views.chat import ComposeImageView +import android.content.Context import android.graphics.Bitmap import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme +import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AttachFile import androidx.compose.runtime.* @@ -14,81 +14,312 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import chat.simplex.app.R import chat.simplex.app.model.* -import chat.simplex.app.views.helpers.ComposeLinkView +import chat.simplex.app.views.chat.item.* +import chat.simplex.app.views.helpers.* +import kotlinx.coroutines.* +import java.io.File +import java.io.FileOutputStream + +sealed class ComposePreview { + object NoPreview: ComposePreview() + class CLinkPreview(val linkPreview: LinkPreview): ComposePreview() + class ImagePreview(val image: String): ComposePreview() +} + +sealed class ComposeContextItem { + object NoContextItem: ComposeContextItem() + class QuotedItem(val chatItem: ChatItem): ComposeContextItem() + class EditingItem(val chatItem: ChatItem): ComposeContextItem() +} + +data class ComposeState( + val message: String = "", + val preview: ComposePreview = ComposePreview.NoPreview, + val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem, + val inProgress: Boolean = false +) { + constructor(editingItem: ChatItem): this( + editingItem.content.text, + chatItemPreview(editingItem), + ComposeContextItem.EditingItem(editingItem) + ) + + val editing: Boolean + get() = + when (contextItem) { + is ComposeContextItem.EditingItem -> true + else -> false + } + val sendEnabled: Boolean + get() = + when (preview) { + is ComposePreview.ImagePreview -> true + else -> message.isNotEmpty() + } + val linkPreviewAllowed: Boolean + get() = + when (preview) { + is ComposePreview.ImagePreview -> false + else -> true + } + val linkPreview: LinkPreview? + get() = + when (preview) { + is ComposePreview.CLinkPreview -> preview.linkPreview + else -> null + } +} + +fun chatItemPreview(chatItem: ChatItem): ComposePreview { + return when (val mc = chatItem.content.msgContent) { + is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview) + is MsgContent.MCImage -> ComposePreview.ImagePreview(image = mc.image) + else -> ComposePreview.NoPreview + } +} -// TODO ComposeState @Composable fun ComposeView( - msg: MutableState, - quotedItem: MutableState, - editingItem: MutableState, - linkPreview: MutableState, + chatModel: ChatModel, + chat: Chat, + composeState: MutableState, chosenImage: MutableState, - imagePreview: MutableState, - sendMessage: (String) -> Unit, - resetMessage: () -> Unit, - parseMarkdown: (String) -> List?, - showBottomSheet: () -> Unit + showAttachmentBottomSheet: () -> Unit ) { + val context = LocalContext.current + val linkUrl = remember { mutableStateOf(null) } + val prevLinkUrl = remember { mutableStateOf(null) } + val pendingLinkUrl = remember { mutableStateOf(null) } val cancelledLinks = remember { mutableSetOf() } + val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) + val textStyle = remember { mutableStateOf(smallFont) } - fun cancelPreview() { - val uri = linkPreview.value?.uri + fun isSimplexLink(link: String): Boolean = + link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true) + + fun parseMessage(msg: String): String? { + val parsedMsg = runBlocking { chatModel.controller.apiParseMarkdown(msg) } + val link = parsedMsg?.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) } + return link?.text + } + + fun loadLinkPreview(url: String, wait: Long? = null) { + if (pendingLinkUrl.value == url) { + withApi { + if (wait != null) delay(wait) + val lp = getLinkPreview(url) + if (lp != null && pendingLinkUrl.value == url) { + composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(lp)) + pendingLinkUrl.value = null + } + } + } + } + + fun showLinkPreview(s: String) { + prevLinkUrl.value = linkUrl.value + linkUrl.value = parseMessage(s) + val url = linkUrl.value + if (url != null) { + if (url != composeState.value.linkPreview?.uri && url != pendingLinkUrl.value) { + pendingLinkUrl.value = url + loadLinkPreview(url, wait = if (prevLinkUrl.value == url) null else 1500L) + } + } else { + composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) + } + } + + fun resetLinkPreview() { + linkUrl.value = null + prevLinkUrl.value = null + pendingLinkUrl.value = null + cancelledLinks.clear() + } + + fun checkLinkPreview(): MsgContent { + val cs = composeState.value + return when (val composePreview = cs.preview) { + is ComposePreview.CLinkPreview -> { + val url = parseMessage(cs.message) + val lp = composePreview.linkPreview + if (url == lp.uri) { + MsgContent.MCLink(cs.message, preview = lp) + } else { + MsgContent.MCText(cs.message) + } + } + else -> MsgContent.MCText(cs.message) + } + } + + fun updateMsgContent(msgContent: MsgContent): MsgContent { + val cs = composeState.value + return when (msgContent) { + is MsgContent.MCText -> checkLinkPreview() + is MsgContent.MCLink -> checkLinkPreview() + is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image) + is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json) + } + } + + fun saveImage(context: Context, image: Bitmap): String? { + return try { + val dataResized = resizeImageToDataSize(image, maxDataSize = MAX_IMAGE_SIZE) + val fileToSave = "image_${System.currentTimeMillis()}.jpg" + val file = File(getAppFilesDirectory(context) + "/" + fileToSave) + val output = FileOutputStream(file) + dataResized.writeTo(output) + output.flush() + output.close() + fileToSave + } catch (e: Exception) { + null + } + } + + fun sendMessage() { + withApi { + // show "in progress" + val cInfo = chat.chatInfo + val cs = composeState.value + when (val contextItem = cs.contextItem) { + is ComposeContextItem.EditingItem -> { + val ei = contextItem.chatItem + val oldMsgContent = ei.content.msgContent + if (oldMsgContent != null) { + val updatedItem = chatModel.controller.apiUpdateChatItem( + type = cInfo.chatType, + id = cInfo.apiId, + itemId = ei.meta.itemId, + mc = updateMsgContent(oldMsgContent) + ) + if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem) + } + } + else -> { + var mc: MsgContent? = null + var file: String? = null + when (val preview = cs.preview) { + ComposePreview.NoPreview -> mc = MsgContent.MCText(cs.message) + is ComposePreview.CLinkPreview -> mc = checkLinkPreview() + is ComposePreview.ImagePreview -> { + val chosenImageVal = chosenImage.value + if (chosenImageVal != null) { + file = saveImage(context, chosenImageVal) + if (file != null) { + mc = MsgContent.MCImage(cs.message, preview.image) + } + } + } + } + val quotedItemId: Long? = when (contextItem) { + is ComposeContextItem.QuotedItem -> contextItem.chatItem.id + else -> null + } + + if (mc != null) { + val aChatItem = chatModel.controller.apiSendMessage( + type = cInfo.chatType, + id = cInfo.apiId, + file = file, + quotedItemId = quotedItemId, + mc = mc + ) + if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem) + } + } + } + // hide "in progress" + composeState.value = ComposeState() + } + } + + fun onMessageChange(s: String) { + composeState.value = composeState.value.copy(message = s) + if (isShortEmoji(s)) { + textStyle.value = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont + } else { + textStyle.value = smallFont + if (composeState.value.linkPreviewAllowed) { + if (s.isNotEmpty()) showLinkPreview(s) + else resetLinkPreview() + } + } + } + + fun cancelLinkPreview() { + val uri = composeState.value.linkPreview?.uri if (uri != null) { cancelledLinks.add(uri) } - linkPreview.value = null + composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) } fun cancelImage() { chosenImage.value = null - imagePreview.value = null + composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) + } + + @Composable + fun previewView() { + when (val preview = composeState.value.preview) { + ComposePreview.NoPreview -> {} + is ComposePreview.CLinkPreview -> ComposeLinkView(preview.linkPreview, ::cancelLinkPreview) + is ComposePreview.ImagePreview -> ComposeImageView( + preview.image, + ::cancelImage, + cancelEnabled = !composeState.value.editing + ) + } + } + + @Composable + fun contextItemView() { + when (val contextItem = composeState.value.contextItem) { + ComposeContextItem.NoContextItem -> {} + is ComposeContextItem.QuotedItem -> ContextItemView(contextItem.chatItem) { composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) } + is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem) { composeState.value = ComposeState() } + } } Column { - val ip = imagePreview.value - if (ip != null) { - ComposeImageView(ip, ::cancelImage) - } else { - val lp = linkPreview.value - if (lp != null) ComposeLinkView(lp, ::cancelPreview) - } - when { - quotedItem.value != null -> { - ContextItemView(quotedItem) - } - editingItem.value != null -> { - ContextItemView(editingItem, editing = editingItem.value != null, resetMessage) - } - else -> {} - } + contextItemView() + previewView() Row( modifier = Modifier.padding(start = 4.dp, end = 8.dp), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(2.dp) ) { + val attachEnabled = !composeState.value.editing Box(Modifier.padding(bottom = 12.dp)) { Icon( Icons.Filled.AttachFile, contentDescription = stringResource(R.string.attach), - tint = if (editingItem.value == null) MaterialTheme.colors.primary else Color.Gray, + tint = if (attachEnabled) MaterialTheme.colors.primary else Color.Gray, modifier = Modifier .size(28.dp) .clip(CircleShape) .clickable { - if (editingItem.value == null) { - showBottomSheet() + if (attachEnabled) { + showAttachmentBottomSheet() } } ) } SendMsgView( - msg, linkPreview, cancelledLinks, parseMarkdown, sendMessage, - editing = editingItem.value != null, sendEnabled = msg.value.isNotEmpty() || imagePreview.value != null + composeState, + sendMessage = { + sendMessage() + resetLinkPreview() + }, + ::onMessageChange, + textStyle ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt index 67581d81bd..23227ebbc4 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt @@ -22,41 +22,32 @@ import kotlinx.datetime.Clock @Composable fun ContextItemView( - contextItem: MutableState, - editing: Boolean = false, - resetMessage: () -> Unit = {} + contextItem: ChatItem, + cancelContextItem: () -> Unit ) { - val cxtItem = contextItem.value - if (cxtItem != null) { - val sent = cxtItem.chatDir.sent - Row( + val sent = contextItem.chatDir.sent + Row( + Modifier + .padding(top = 8.dp) + .background(if (sent) SentColorLight else ReceivedColorLight), + verticalAlignment = Alignment.CenterVertically + ) { + Box( Modifier - .padding(top = 8.dp) - .background(if (sent) SentColorLight else ReceivedColorLight), - verticalAlignment = Alignment.CenterVertically + .padding(start = 16.dp) + .padding(vertical = 12.dp) + .fillMaxWidth() + .weight(1F) ) { - Box( - Modifier - .padding(start = 16.dp) - .padding(vertical = 12.dp) - .fillMaxWidth() - .weight(1F) - ) { - ContextItemText(cxtItem) - } - IconButton(onClick = { - contextItem.value = null - if (editing) { - resetMessage() - } - }) { - Icon( - Icons.Outlined.Close, - contentDescription = stringResource(R.string.cancel_verb), - tint = MaterialTheme.colors.primary, - modifier = Modifier.padding(10.dp) - ) - } + ContextItemText(contextItem) + } + IconButton(onClick = cancelContextItem) { + Icon( + Icons.Outlined.Close, + contentDescription = stringResource(R.string.cancel_verb), + tint = MaterialTheme.colors.primary, + modifier = Modifier.padding(10.dp) + ) } } } @@ -80,13 +71,8 @@ private fun ContextItemText(cxtItem: ChatItem) { fun PreviewContextItemView() { SimpleXTheme { ContextItemView( - contextItem = remember { - mutableStateOf( - ChatItem.getSampleData( - 1, CIDirection.DirectRcv(), Clock.System.now(), "hello" - ) - ) - } + contextItem = ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), "hello"), + cancelContextItem = {} ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index 32e8aa8b4e..03c21786e3 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -25,83 +26,19 @@ import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.views.chat.item.* -import chat.simplex.app.views.helpers.getLinkPreview -import chat.simplex.app.views.helpers.withApi -import kotlinx.coroutines.delay @Composable fun SendMsgView( - msg: MutableState, - linkPreview: MutableState, - cancelledLinks: MutableSet, - parseMarkdown: (String) -> List?, - sendMessage: (String) -> Unit, - editing: Boolean = false, - sendEnabled: Boolean = false + composeState: MutableState, + sendMessage: () -> Unit, + onMessageChange: (String) -> Unit, + textStyle: MutableState ) { - val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) - var textStyle by remember { mutableStateOf(smallFont) } - val linkUrl = remember { mutableStateOf(null) } - val prevLinkUrl = remember { mutableStateOf(null) } - val pendingLinkUrl = remember { mutableStateOf(null) } - - fun isSimplexLink(link: String): Boolean = - link.startsWith("https://simplex.chat",true) || link.startsWith("http://simplex.chat", true) - - fun parseMessage(msg: String): String? { - val parsedMsg = parseMarkdown(msg) - val link = parsedMsg?.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) } - return link?.text - } - - fun loadLinkPreview(url: String, wait: Long? = null) { - if (pendingLinkUrl.value == url) { - withApi { - if (wait != null) delay(wait) - val lp = getLinkPreview(url) - if (pendingLinkUrl.value == url) { - linkPreview.value = lp - pendingLinkUrl.value = null - } - } - } - } - - fun showLinkPreview(s: String) { - prevLinkUrl.value = linkUrl.value - linkUrl.value = parseMessage(s) - val url = linkUrl.value - if (url != null) { - if (url != linkPreview.value?.uri && url != pendingLinkUrl.value) { - pendingLinkUrl.value = url - loadLinkPreview(url, wait = if (prevLinkUrl.value == url) null else 1500L) - } - } else { - linkPreview.value = null - } - } - - fun resetLinkPreview() { - linkUrl.value = null - prevLinkUrl.value = null - pendingLinkUrl.value = null - cancelledLinks.clear() - } - + val cs = composeState.value BasicTextField( - value = msg.value, - onValueChange = { s -> - msg.value = s - if (isShortEmoji(s)) { - textStyle = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont - } else { - textStyle = smallFont - if (s.isNotEmpty()) showLinkPreview(s) - else resetLinkPreview() - } - }, - textStyle = textStyle, + value = cs.message, + onValueChange = onMessageChange, + textStyle = textStyle.value, maxLines = 16, keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.Sentences, @@ -127,9 +64,10 @@ fun SendMsgView( ) { innerTextField() } - val color = if (sendEnabled) MaterialTheme.colors.primary else Color.Gray + val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward + val color = if (cs.sendEnabled) MaterialTheme.colors.primary else Color.Gray Icon( - if (editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward, + icon, stringResource(R.string.icon_descr_send_message), tint = Color.White, modifier = Modifier @@ -138,11 +76,8 @@ fun SendMsgView( .clip(CircleShape) .background(color) .clickable { - if (sendEnabled) { - sendMessage(msg.value) - msg.value = "" - textStyle = smallFont - cancelledLinks.clear() + if (cs.sendEnabled) { + sendMessage() } } ) @@ -160,14 +95,14 @@ fun SendMsgView( ) @Composable fun PreviewSendMsgView() { + val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) + val textStyle = remember { mutableStateOf(smallFont) } SimpleXTheme { SendMsgView( - msg = remember { mutableStateOf("") }, - linkPreview = remember {mutableStateOf(null) }, - cancelledLinks = mutableSetOf(), - parseMarkdown = { null }, - sendMessage = { msg -> println(msg) }, - sendEnabled = true + composeState = remember { mutableStateOf(ComposeState()) }, + sendMessage = {}, + onMessageChange = { _ -> }, + textStyle = textStyle ) } } @@ -180,15 +115,15 @@ fun PreviewSendMsgView() { ) @Composable fun PreviewSendMsgViewEditing() { + val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) + val textStyle = remember { mutableStateOf(smallFont) } + val composeStateEditing = ComposeState(editingItem = ChatItem.getSampleData()) SimpleXTheme { SendMsgView( - msg = remember { mutableStateOf("") }, - linkPreview = remember {mutableStateOf(null) }, - cancelledLinks = mutableSetOf(), - sendMessage = { msg -> println(msg) }, - parseMarkdown = { null }, - editing = true, - sendEnabled = true + composeState = remember { mutableStateOf(composeStateEditing) }, + sendMessage = {}, + onMessageChange = { _ -> }, + textStyle = textStyle ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index 79c487c879..d005e1ea3f 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -20,6 +20,8 @@ import androidx.compose.ui.unit.dp import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.chat.ComposeContextItem +import chat.simplex.app.views.chat.ComposeState import chat.simplex.app.views.helpers.* import kotlinx.datetime.Clock @@ -27,9 +29,7 @@ import kotlinx.datetime.Clock fun ChatItemView( user: User, cItem: ChatItem, - msg: MutableState, - quotedItem: MutableState, - editingItem: MutableState, + composeState: MutableState, cxt: Context, uriHandler: UriHandler? = null, showMember: Boolean = false, @@ -61,8 +61,7 @@ fun ChatItemView( Modifier.width(220.dp) ) { ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = { - editingItem.value = null - quotedItem.value = cItem + composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) showMenu.value = false }) ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = { @@ -75,9 +74,7 @@ fun ChatItemView( }) if (cItem.chatDir.sent && cItem.meta.editable) { ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = { - quotedItem.value = null - editingItem.value = cItem - msg.value = cItem.content.text + composeState.value = ComposeState(editingItem = cItem) showMenu.value = false }) } @@ -148,9 +145,7 @@ fun PreviewChatItemView() { ChatItem.getSampleData( 1, CIDirection.DirectSnd(), Clock.System.now(), "hello" ), - msg = remember { mutableStateOf("") }, - quotedItem = remember { mutableStateOf(null) }, - editingItem = remember { mutableStateOf(null) }, + composeState = remember { mutableStateOf(ComposeState()) }, cxt = LocalContext.current, deleteMessage = { _, _ -> } ) @@ -164,9 +159,7 @@ fun PreviewChatItemViewDeletedContent() { ChatItemView( User.sampleData, ChatItem.getDeletedContentSampleData(), - msg = remember { mutableStateOf("") }, - quotedItem = remember { mutableStateOf(null) }, - editingItem = remember { mutableStateOf(null) }, + composeState = remember { mutableStateOf(ComposeState()) }, cxt = LocalContext.current, deleteMessage = { _, _ -> } ) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index a73ed23140..43ca22a3d6 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -135,8 +135,8 @@ struct ComposeView: View { .padding(.leading, 12) SendMessageView( composeState: $composeState, - sendMessage: { text in - sendMessage(text) + sendMessage: { + sendMessage() resetLinkPreview() }, keyboardVisible: $keyboardVisible @@ -209,7 +209,7 @@ struct ComposeView: View { } } - private func sendMessage(_ text: String) { + private func sendMessage() { logger.debug("ChatView sendMessage") Task { logger.debug("ChatView sendMessage: in Task") diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 3c4663f504..22510808ca 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -10,7 +10,7 @@ import SwiftUI struct SendMessageView: View { @Binding var composeState: ComposeState - var sendMessage: (String) -> Void + var sendMessage: () -> Void @Namespace var namespace @FocusState.Binding var keyboardVisible: Bool @State private var teHeight: CGFloat = 42 @@ -45,7 +45,7 @@ struct SendMessageView: View { .frame(width: 31, height: 31, alignment: .center) .padding([.bottom, .trailing], 3) } else { - Button(action: { sendMessage(composeState.message) }) { + Button(action: { sendMessage() }) { Image(systemName: composeState.editing() ? "checkmark.circle.fill" : "arrow.up.circle.fill") .resizable() .foregroundColor(.accentColor) @@ -90,7 +90,7 @@ struct SendMessageView_Previews: PreviewProvider { Spacer(minLength: 0) SendMessageView( composeState: $composeStateNew, - sendMessage: { print ($0) }, + sendMessage: {}, keyboardVisible: $keyboardVisible ) } @@ -99,7 +99,7 @@ struct SendMessageView_Previews: PreviewProvider { Spacer(minLength: 0) SendMessageView( composeState: $composeStateEditing, - sendMessage: { print ($0) }, + sendMessage: {}, keyboardVisible: $keyboardVisible ) } diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index 56cc0bb8c9..75198c0977 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -80,8 +80,8 @@ struct TerminalView: View { } } - func sendMessage(_ cmdStr: String) { - let cmd = ChatCommand.string(cmdStr) + func sendMessage() { + let cmd = ChatCommand.string(composeState.message) DispatchQueue.global().async { Task { composeState.inProgress = true From c48a516586dcdee3366a42ceebb54f70d6e7fc4e Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Thu, 28 Apr 2022 09:40:51 +0400 Subject: [PATCH 29/95] core: MCFile (#580) --- src/Simplex/Chat/Protocol.hs | 10 +++++++++- tests/ProtocolTests.hs | 7 +++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index d7cd674041..928e4dca98 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -148,18 +148,20 @@ cmToQuotedMsg = \case XMsgNew (MCQuote quotedMsg _) -> Just quotedMsg _ -> Nothing -data MsgContentTag = MCText_ | MCLink_ | MCImage_ | MCUnknown_ Text +data MsgContentTag = MCText_ | MCLink_ | MCImage_ | MCFile_ | MCUnknown_ Text instance StrEncoding MsgContentTag where strEncode = \case MCText_ -> "text" MCLink_ -> "link" MCImage_ -> "image" + MCFile_ -> "file" MCUnknown_ t -> encodeUtf8 t strDecode = \case "text" -> Right MCText_ "link" -> Right MCLink_ "image" -> Right MCImage_ + "file" -> Right MCFile_ t -> Right . MCUnknown_ $ safeDecodeUtf8 t strP = strDecode <$?> A.takeTill (== ' ') @@ -196,6 +198,7 @@ data MsgContent = MCText Text | MCLink {text :: Text, preview :: LinkPreview} | MCImage {text :: Text, image :: ImageData} + | MCFile Text | MCUnknown {tag :: Text, text :: Text, json :: J.Object} deriving (Eq, Show) @@ -204,6 +207,7 @@ msgContentText = \case MCText t -> t MCLink {text} -> text MCImage {text} -> text + MCFile t -> t MCUnknown {text} -> text msgContentTag :: MsgContent -> MsgContentTag @@ -211,6 +215,7 @@ msgContentTag = \case MCText _ -> MCText_ MCLink {} -> MCLink_ MCImage {} -> MCImage_ + MCFile {} -> MCFile_ MCUnknown {tag} -> MCUnknown_ tag data ExtMsgContent = ExtMsgContent MsgContent (Maybe FileInvitation) @@ -236,6 +241,7 @@ instance FromJSON MsgContent where text <- v .: "text" image <- v .: "image" pure MCImage {image, text} + MCFile_ -> MCFile <$> v .: "text" MCUnknown_ tag -> do text <- fromMaybe unknownMsgType <$> v .:? "text" pure MCUnknown {tag, text, json = v} @@ -261,11 +267,13 @@ instance ToJSON MsgContent where MCText t -> J.object ["type" .= MCText_, "text" .= t] MCLink {text, preview} -> J.object ["type" .= MCLink_, "text" .= text, "preview" .= preview] MCImage {text, image} -> J.object ["type" .= MCImage_, "text" .= text, "image" .= image] + MCFile t -> J.object ["type" .= MCFile_, "text" .= t] toEncoding = \case MCUnknown {json} -> JE.value $ J.Object json MCText t -> J.pairs $ "type" .= MCText_ <> "text" .= t MCLink {text, preview} -> J.pairs $ "type" .= MCLink_ <> "text" .= text <> "preview" .= preview MCImage {text, image} -> J.pairs $ "type" .= MCImage_ <> "text" .= text <> "image" .= image + MCFile t -> J.pairs $ "type" .= MCFile_ <> "text" .= t instance ToField MsgContent where toField = toField . safeDecodeUtf8 . LB.toStrict . J.encode diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index d90a416acc..29d76d9914 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -103,7 +103,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XMsgNew (MCSimple (ExtMsgContent (MCImage "here's an image" $ ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=") Nothing)) it "x.msg.new chat message " $ "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" - ##==## (ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (ExtMsgContent (MCText "hello") Nothing)))) + ##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (ExtMsgContent (MCText "hello") Nothing))) it "x.msg.new quote" $ "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" ##==## ChatMessage @@ -123,9 +123,12 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do it "x.msg.new forward" $ "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true}}" ##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") Nothing)) - it "x.msg.new simple with file" $ + it "x.msg.new simple text with file" $ "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = Nothing}))) + it "x.msg.new simple file with file" $ + "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"\",\"type\":\"file\"},\"file\":{\"fileSize\":12345,\"fileName\":\"file.txt\"}}}" + #==# XMsgNew (MCSimple (ExtMsgContent (MCFile "") (Just FileInvitation {fileName = "file.txt", fileSize = 12345, fileConnReq = Nothing}))) it "x.msg.new quote with file" $ "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" ##==## ChatMessage From d9572cef86a987f6dd285f62562e2eb074eb8c92 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 28 Apr 2022 07:26:43 +0100 Subject: [PATCH 30/95] terminal: command to show last N items in a chat (#582) --- src/Simplex/Chat.hs | 12 ++++++++++++ src/Simplex/Chat/Controller.hs | 2 ++ src/Simplex/Chat/Messages.hs | 8 ++++++++ src/Simplex/Chat/View.hs | 1 + 4 files changed, 23 insertions(+) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 22f43ec461..62a0debc12 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -560,6 +560,10 @@ processChatCommand = \case editedItemId <- withStore $ \st -> getGroupChatItemIdByText st user groupId (Just localDisplayName) (safeDecodeUtf8 editedMsg) let mc = MCText $ safeDecodeUtf8 msg processChatCommand $ APIUpdateChatItem CTGroup groupId editedItemId mc + LastMessages (Just c@(ChatName cType _)) count -> do + chatId <- getChatId c + CRLastMessages . aChatItems . chat <$> (processChatCommand . APIGetChat cType chatId $ CPLast count) + LastMessages Nothing _count -> pure $ chatCmdError "not implemented" -- old file protocol -- SendFile cName f -> withUser $ \User {userId} -> do -- contactId <- withStore $ \st -> getContactIdByName st userId cName @@ -655,6 +659,11 @@ processChatCommand = \case -- use function below to make commands "synchronous" procCmd :: m ChatResponse -> m ChatResponse procCmd = id + getChatId :: ChatName -> m Int64 + getChatId (ChatName cType name) = withUser $ \user@User {userId} -> case cType of + CTDirect -> withStore $ \st -> getContactIdByName st userId name + CTGroup -> withStore $ \st -> getGroupIdByName st user name + _ -> throwChatError $ CECommandError "not supported" connectViaContact :: UserId -> ConnectionRequestUri 'CMContact -> Profile -> m ChatResponse connectViaContact userId cReq profile = withChatLock $ do let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq @@ -1957,6 +1966,7 @@ chatCommandP = <|> ("\\@" <|> "\\ @") *> (DeleteMessage <$> displayName <* A.space <*> A.takeByteString) <|> ("!@" <|> "! @") *> (EditMessage <$> displayName <* A.space <*> (quotedMsg <|> pure "") <*> A.takeByteString) <|> "/feed " *> (SendMessageBroadcast <$> A.takeByteString) + <|> ("/tail" <|> "/t") *> (LastMessages <$> optional chatNameP <*> msgCountP) <|> ("/file #" <|> "/f #") *> (SendGroupFile <$> displayName <* A.space <*> filePath) <|> ("/file_v2 #" <|> "/f_v2 #") *> (SendGroupFileInv <$> displayName <* A.space <*> filePath) <|> ("/file @" <|> "/file " <|> "/f @" <|> "/f ") *> (SendFile <$> displayName <* A.space <*> filePath) @@ -2024,6 +2034,8 @@ chatCommandP = <|> (" admin" $> GRAdmin) <|> (" member" $> GRMember) <|> pure GRAdmin + chatNameP = A.space *> (ChatName <$> chatTypeP <*> displayName) + msgCountP = A.space *> A.decimal <|> pure 10 adminContactReq :: ConnReqContact adminContactReq = diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 7adf8e2182..e3ab2dce22 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -146,6 +146,7 @@ data ChatCommand | SendGroupMessageQuote {groupName :: GroupName, contactName_ :: Maybe ContactName, quotedMsg :: ByteString, message :: ByteString} | DeleteGroupMessage GroupName ByteString | EditGroupMessage {groupName :: ContactName, editedMsg :: ByteString, message :: ByteString} + | LastMessages (Maybe ChatName) Int | SendFile ContactName FilePath | SendFileInv ContactName FilePath | SendGroupFile GroupName FilePath @@ -166,6 +167,7 @@ data ChatResponse | CRChatRunning | CRApiChats {chats :: [AChat]} | CRApiChat {chat :: AChat} + | CRLastMessages {chatItems :: [AChatItem]} | CRApiParsedMarkdown {formattedText :: Maybe MarkdownList} | CRUserSMPServers {smpServers :: [SMPServer]} | CRNewChatItem {chatItem :: AChatItem} diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 33528b7cac..612032ca6c 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -41,6 +41,9 @@ import Simplex.Messaging.Util ((<$?>)) data ChatType = CTDirect | CTGroup | CTContactRequest | CTContactConnection deriving (Show, Generic) +data ChatName = ChatName ChatType Text + deriving (Show) + instance ToJSON ChatType where toJSON = J.genericToJSON . enumJSON $ dropPrefix "CT" toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CT" @@ -198,6 +201,11 @@ instance ToJSON AChatItem where data JSONAnyChatItem c d = JSONAnyChatItem {chatInfo :: ChatInfo c, chatItem :: ChatItem c d} deriving (Generic) +aChatItems :: AChat -> [AChatItem] +aChatItems (AChat ct Chat {chatInfo, chatItems}) = map aChatItem chatItems + where + aChatItem (CChatItem md ci) = AChatItem ct md chatInfo ci + instance MsgDirectionI d => ToJSON (JSONAnyChatItem c d) where toJSON = J.genericToJSON J.defaultOptions toEncoding = J.genericToEncoding J.defaultOptions diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 703bcf6b59..347c6f013c 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -50,6 +50,7 @@ responseToView testView = \case CRApiParsedMarkdown ft -> [plain . bshow $ J.encode ft] CRUserSMPServers smpServers -> viewSMPServers smpServers testView CRNewChatItem (AChatItem _ _ chat item) -> viewChatItem chat item + CRLastMessages chatItems -> concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item) chatItems CRChatItemStatusUpdated _ -> [] CRChatItemUpdated (AChatItem _ _ chat item) -> viewItemUpdate chat item CRChatItemDeleted (AChatItem _ _ chat deletedItem) (AChatItem _ _ _ toItem) -> viewItemDelete chat deletedItem toItem From 1df9a1ec2dd2636800495537e032173a393ed49f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 28 Apr 2022 08:34:21 +0100 Subject: [PATCH 31/95] core: refactor terminal commands (#583) --- src/Simplex/Chat.hs | 107 +++++++++++++---------------- src/Simplex/Chat/Controller.hs | 23 +++---- src/Simplex/Chat/Messages.hs | 3 + src/Simplex/Chat/Terminal/Input.hs | 2 - 4 files changed, 62 insertions(+), 73 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 62a0debc12..dfcb597d70 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -189,13 +189,13 @@ processChatCommand = \case atomically . writeTVar ff $ Just filesFolder' pure CRCmdOk APIGetChats withPCC -> CRApiChats <$> withUser (\user -> withStore $ \st -> getChatPreviews st user withPCC) - APIGetChat cType cId pagination -> withUser $ \user -> case cType of + APIGetChat (ChatRef cType cId) pagination -> withUser $ \user -> case cType of CTDirect -> CRApiChat . AChat SCTDirect <$> withStore (\st -> getDirectChat st user cId pagination) CTGroup -> CRApiChat . AChat SCTGroup <$> withStore (\st -> getGroupChat st user cId pagination) CTContactRequest -> pure $ chatCmdError "not implemented" CTContactConnection -> pure $ chatCmdError "not supported" APIGetChatItems _pagination -> pure $ chatCmdError "not implemented" - APISendMessage cType chatId file_ quotedItemId_ mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of + APISendMessage (ChatRef cType chatId) file_ quotedItemId_ mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of CTDirect -> do ct@Contact {localDisplayName = c} <- withStore $ \st -> getContact st userId chatId (fileInvitation_, ciFile_) <- unzipMaybe <$> setupSndFileTransfer ct @@ -277,9 +277,9 @@ processChatCommand = \case unzipMaybe :: Maybe (a, b) -> (Maybe a, Maybe b) unzipMaybe t = (fst <$> t, snd <$> t) -- TODO discontinue - APISendMessageQuote cType chatId quotedItemId mc -> - processChatCommand $ APISendMessage cType chatId Nothing (Just quotedItemId) mc - APIUpdateChatItem cType chatId itemId mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of + APISendMessageQuote chatId quotedItemId mc -> + processChatCommand $ APISendMessage chatId Nothing (Just quotedItemId) mc + APIUpdateChatItem (ChatRef cType chatId) itemId mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of CTDirect -> do (ct@Contact {contactId, localDisplayName = c}, ci) <- withStore $ \st -> (,) <$> getContact st userId chatId <*> getDirectChatItem st userId chatId itemId case ci of @@ -308,7 +308,7 @@ processChatCommand = \case CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate CTContactRequest -> pure $ chatCmdError "not supported" CTContactConnection -> pure $ chatCmdError "not supported" - APIDeleteChatItem cType chatId itemId mode -> withUser $ \user@User {userId} -> withChatLock $ case cType of + APIDeleteChatItem (ChatRef cType chatId) itemId mode -> withUser $ \user@User {userId} -> withChatLock $ case cType of CTDirect -> do (ct@Contact {localDisplayName = c}, CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}, file}) <- withStore $ \st -> (,) <$> getContact st userId chatId <*> getDirectChatItem st userId chatId itemId case (mode, msgDir, itemSharedMsgId) of @@ -348,12 +348,12 @@ processChatCommand = \case cancelFiles userId [(fileId, AFS msgDirection fileStatus)] withFilesFolder $ \filesFolder -> deleteFiles filesFolder [filePath] - APIChatRead cType chatId fromToIds -> withChatLock $ case cType of + APIChatRead (ChatRef cType chatId) fromToIds -> withChatLock $ case cType of CTDirect -> withStore (\st -> updateDirectChatItemsRead st chatId fromToIds) $> CRCmdOk CTGroup -> withStore (\st -> updateGroupChatItemsRead st chatId fromToIds) $> CRCmdOk CTContactRequest -> pure $ chatCmdError "not supported" CTContactConnection -> pure $ chatCmdError "not supported" - APIDeleteChat cType chatId -> withUser $ \User {userId} -> case cType of + APIDeleteChat (ChatRef cType chatId) -> withUser $ \User {userId} -> case cType of CTDirect -> do ct@Contact {localDisplayName} <- withStore $ \st -> getContact st userId chatId withStore (\st -> getContactGroupNames st userId ct) >>= \case @@ -417,7 +417,7 @@ processChatCommand = \case connectViaContact userId adminContactReq profile DeleteContact cName -> withUser $ \User {userId} -> do contactId <- withStore $ \st -> getContactIdByName st userId cName - processChatCommand $ APIDeleteChat CTDirect contactId + processChatCommand $ APIDeleteChat (ChatRef CTDirect contactId) ListContacts -> withUser $ \user -> CRContactsList <$> withStore (`getUserContacts` user) CreateMyAddress -> withUser $ \User {userId} -> withChatLock . procCmd $ do (connId, cReq) <- withAgent (`createConnection` SCMContact) @@ -440,10 +440,10 @@ processChatCommand = \case RejectContact cName -> withUser $ \User {userId} -> do connReqId <- withStore $ \st -> getContactRequestIdByName st userId cName processChatCommand $ APIRejectContact connReqId - SendMessage cName msg -> withUser $ \User {userId} -> do - contactId <- withStore $ \st -> getContactIdByName st userId cName + SendMessage chatName msg -> withUser $ \user -> do + chatRef <- getChatRef user chatName let mc = MCText $ safeDecodeUtf8 msg - processChatCommand $ APISendMessage CTDirect contactId Nothing Nothing mc + processChatCommand $ APISendMessage chatRef Nothing Nothing mc SendMessageBroadcast msg -> withUser $ \user -> do contacts <- withStore (`getUserContacts` user) withChatLock . procCmd $ do @@ -461,16 +461,16 @@ processChatCommand = \case contactId <- withStore $ \st -> getContactIdByName st userId cName quotedItemId <- withStore $ \st -> getDirectChatItemIdByText st userId contactId msgDir (safeDecodeUtf8 quotedMsg) let mc = MCText $ safeDecodeUtf8 msg - processChatCommand $ APISendMessage CTDirect contactId Nothing (Just quotedItemId) mc - DeleteMessage cName deletedMsg -> withUser $ \User {userId} -> do - contactId <- withStore $ \st -> getContactIdByName st userId cName - deletedItemId <- withStore $ \st -> getDirectChatItemIdByText st userId contactId SMDSnd (safeDecodeUtf8 deletedMsg) - processChatCommand $ APIDeleteChatItem CTDirect contactId deletedItemId CIDMBroadcast - EditMessage cName editedMsg msg -> withUser $ \User {userId} -> do - contactId <- withStore $ \st -> getContactIdByName st userId cName - editedItemId <- withStore $ \st -> getDirectChatItemIdByText st userId contactId SMDSnd (safeDecodeUtf8 editedMsg) + processChatCommand $ APISendMessage (ChatRef CTDirect contactId) Nothing (Just quotedItemId) mc + DeleteMessage chatName deletedMsg -> withUser $ \user -> do + chatRef <- getChatRef user chatName + deletedItemId <- getSentChatItemIdByText user chatRef deletedMsg + processChatCommand $ APIDeleteChatItem chatRef deletedItemId CIDMBroadcast + EditMessage chatName editedMsg msg -> withUser $ \user -> do + chatRef <- getChatRef user chatName + editedItemId <- getSentChatItemIdByText user chatRef editedMsg let mc = MCText $ safeDecodeUtf8 msg - processChatCommand $ APIUpdateChatItem CTDirect contactId editedItemId mc + processChatCommand $ APIUpdateChatItem chatRef editedItemId mc NewGroup gProfile -> withUser $ \user -> do gVar <- asks idsDrg CRGroupCreated <$> withStore (\st -> createNewGroup st gVar user gProfile) @@ -542,27 +542,14 @@ processChatCommand = \case pure $ CRGroupDeletedUser gInfo ListMembers gName -> CRGroupMembers <$> withUser (\user -> withStore (\st -> getGroupByName st user gName)) ListGroups -> CRGroupsList <$> withUser (\user -> withStore (`getUserGroupDetails` user)) - SendGroupMessage gName msg -> withUser $ \user -> do - groupId <- withStore $ \st -> getGroupIdByName st user gName - let mc = MCText $ safeDecodeUtf8 msg - processChatCommand $ APISendMessage CTGroup groupId Nothing Nothing mc SendGroupMessageQuote gName cName quotedMsg msg -> withUser $ \user -> do groupId <- withStore $ \st -> getGroupIdByName st user gName quotedItemId <- withStore $ \st -> getGroupChatItemIdByText st user groupId cName (safeDecodeUtf8 quotedMsg) let mc = MCText $ safeDecodeUtf8 msg - processChatCommand $ APISendMessage CTGroup groupId Nothing (Just quotedItemId) mc - DeleteGroupMessage gName deletedMsg -> withUser $ \user@User {localDisplayName} -> do - groupId <- withStore $ \st -> getGroupIdByName st user gName - deletedItemId <- withStore $ \st -> getGroupChatItemIdByText st user groupId (Just localDisplayName) (safeDecodeUtf8 deletedMsg) - processChatCommand $ APIDeleteChatItem CTGroup groupId deletedItemId CIDMBroadcast - EditGroupMessage gName editedMsg msg -> withUser $ \user@User {localDisplayName} -> do - groupId <- withStore $ \st -> getGroupIdByName st user gName - editedItemId <- withStore $ \st -> getGroupChatItemIdByText st user groupId (Just localDisplayName) (safeDecodeUtf8 editedMsg) - let mc = MCText $ safeDecodeUtf8 msg - processChatCommand $ APIUpdateChatItem CTGroup groupId editedItemId mc - LastMessages (Just c@(ChatName cType _)) count -> do - chatId <- getChatId c - CRLastMessages . aChatItems . chat <$> (processChatCommand . APIGetChat cType chatId $ CPLast count) + processChatCommand $ APISendMessage (ChatRef CTGroup groupId) Nothing (Just quotedItemId) mc + LastMessages (Just chatName) count -> withUser $ \user -> do + chatRef <- getChatRef user chatName + CRLastMessages . aChatItems . chat <$> (processChatCommand . APIGetChat chatRef $ CPLast count) LastMessages Nothing _count -> pure $ chatCmdError "not implemented" -- old file protocol -- SendFile cName f -> withUser $ \User {userId} -> do @@ -618,7 +605,7 @@ processChatCommand = \case -- new file protocol SendGroupFileInv gName f -> withUser $ \user -> do groupId <- withStore $ \st -> getGroupIdByName st user gName - processChatCommand $ APISendMessage CTGroup groupId (Just f) Nothing (MCText "") + processChatCommand $ APISendMessage (ChatRef CTGroup groupId) (Just f) Nothing (MCText "") ReceiveFile fileId filePath_ -> withUser $ \user@User {userId} -> withChatLock . procCmd $ do ft <- withStore $ \st -> getRcvFileTransfer st userId fileId @@ -659,10 +646,16 @@ processChatCommand = \case -- use function below to make commands "synchronous" procCmd :: m ChatResponse -> m ChatResponse procCmd = id - getChatId :: ChatName -> m Int64 - getChatId (ChatName cType name) = withUser $ \user@User {userId} -> case cType of - CTDirect -> withStore $ \st -> getContactIdByName st userId name - CTGroup -> withStore $ \st -> getGroupIdByName st user name + getChatRef :: User -> ChatName -> m ChatRef + getChatRef user@User {userId} (ChatName cType name) = + ChatRef cType <$> case cType of + CTDirect -> withStore $ \st -> getContactIdByName st userId name + CTGroup -> withStore $ \st -> getGroupIdByName st user name + _ -> throwChatError $ CECommandError "not supported" + getSentChatItemIdByText :: User -> ChatRef -> ByteString -> m Int64 + getSentChatItemIdByText user@User {userId, localDisplayName} (ChatRef cType cId) msg = case cType of + CTDirect -> withStore $ \st -> getDirectChatItemIdByText st userId cId SMDSnd (safeDecodeUtf8 msg) + CTGroup -> withStore $ \st -> getGroupChatItemIdByText st user cId (Just localDisplayName) (safeDecodeUtf8 msg) _ -> throwChatError $ CECommandError "not supported" connectViaContact :: UserId -> ConnectionRequestUri 'CMContact -> Profile -> m ChatResponse connectViaContact userId cReq profile = withChatLock $ do @@ -1919,14 +1912,14 @@ chatCommandP = <|> "/_start" $> StartChat <|> "/_files_folder " *> (SetFilesFolder <$> filePath) <|> "/_get chats" *> (APIGetChats <$> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False)) - <|> "/_get chat " *> (APIGetChat <$> chatTypeP <*> A.decimal <* A.space <*> chatPaginationP) + <|> "/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP) <|> "/_get items count=" *> (APIGetChatItems <$> A.decimal) - <|> "/_send " *> (APISendMessage <$> chatTypeP <*> A.decimal <*> optional filePathTagged <*> optional quotedItemIdTagged <* A.space <*> msgContentP) - <|> "/_send_quote " *> (APISendMessageQuote <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <* A.space <*> msgContentP) - <|> "/_update item " *> (APIUpdateChatItem <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <* A.space <*> msgContentP) - <|> "/_delete item " *> (APIDeleteChatItem <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <* A.space <*> ciDeleteMode) - <|> "/_read chat " *> (APIChatRead <$> chatTypeP <*> A.decimal <* A.space <*> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal))) - <|> "/_delete " *> (APIDeleteChat <$> chatTypeP <*> A.decimal) + <|> "/_send " *> (APISendMessage <$> chatRefP <*> optional filePathTagged <*> optional quotedItemIdTagged <* A.space <*> msgContentP) + <|> "/_send_quote " *> (APISendMessageQuote <$> chatRefP <* A.space <*> A.decimal <* A.space <*> msgContentP) + <|> "/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> msgContentP) + <|> "/_delete item " *> (APIDeleteChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> ciDeleteMode) + <|> "/_read chat " *> (APIChatRead <$> chatRefP <* A.space <*> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal))) + <|> "/_delete " *> (APIDeleteChat <$> chatRefP) <|> "/_accept " *> (APIAcceptContact <$> A.decimal) <|> "/_reject " *> (APIRejectContact <$> A.decimal) <|> "/_profile " *> (APIUpdateProfile <$> jsonP) @@ -1951,22 +1944,19 @@ chatCommandP = <|> ("/delete #" <|> "/d #") *> (DeleteGroup <$> displayName) <|> ("/members #" <|> "/members " <|> "/ms #" <|> "/ms ") *> (ListMembers <$> displayName) <|> ("/groups" <|> "/gs") $> ListGroups - <|> A.char '#' *> (SendGroupMessage <$> displayName <* A.space <*> A.takeByteString) <|> (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <*> pure Nothing <*> quotedMsg <*> A.takeByteString) <|> (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <* optional (A.char '@') <*> (Just <$> displayName) <* A.space <*> quotedMsg <*> A.takeByteString) - <|> ("\\#" <|> "\\ #") *> (DeleteGroupMessage <$> displayName <* A.space <*> A.takeByteString) - <|> ("!#" <|> "! #") *> (EditGroupMessage <$> displayName <* A.space <*> (quotedMsg <|> pure "") <*> A.takeByteString) <|> ("/contacts" <|> "/cs") $> ListContacts <|> ("/connect " <|> "/c ") *> (Connect <$> ((Just <$> strP) <|> A.takeByteString $> Nothing)) <|> ("/connect" <|> "/c") $> AddContact <|> ("/delete @" <|> "/delete " <|> "/d @" <|> "/d ") *> (DeleteContact <$> displayName) - <|> A.char '@' *> (SendMessage <$> displayName <* A.space <*> A.takeByteString) + <|> (SendMessage <$> chatNameP <* A.space <*> A.takeByteString) <|> (">@" <|> "> @") *> sendMsgQuote (AMsgDirection SMDRcv) <|> (">>@" <|> ">> @") *> sendMsgQuote (AMsgDirection SMDSnd) - <|> ("\\@" <|> "\\ @") *> (DeleteMessage <$> displayName <* A.space <*> A.takeByteString) - <|> ("!@" <|> "! @") *> (EditMessage <$> displayName <* A.space <*> (quotedMsg <|> pure "") <*> A.takeByteString) + <|> ("\\ " <|> "\\") *> (DeleteMessage <$> chatNameP <* A.space <*> A.takeByteString) + <|> ("! " <|> "!") *> (EditMessage <$> chatNameP <* A.space <*> (quotedMsg <|> pure "") <*> A.takeByteString) <|> "/feed " *> (SendMessageBroadcast <$> A.takeByteString) - <|> ("/tail" <|> "/t") *> (LastMessages <$> optional chatNameP <*> msgCountP) + <|> ("/tail" <|> "/t") *> (LastMessages <$> optional (A.space *> chatNameP) <*> msgCountP) <|> ("/file #" <|> "/f #") *> (SendGroupFile <$> displayName <* A.space <*> filePath) <|> ("/file_v2 #" <|> "/f_v2 #") *> (SendGroupFileInv <$> displayName <* A.space <*> filePath) <|> ("/file @" <|> "/file " <|> "/f @" <|> "/f ") *> (SendFile <$> displayName <* A.space <*> filePath) @@ -2034,7 +2024,8 @@ chatCommandP = <|> (" admin" $> GRAdmin) <|> (" member" $> GRMember) <|> pure GRAdmin - chatNameP = A.space *> (ChatName <$> chatTypeP <*> displayName) + chatNameP = ChatName <$> chatTypeP <*> displayName + chatRefP = ChatRef <$> chatTypeP <*> A.decimal msgCountP = A.space *> A.decimal <|> pure 10 adminContactReq :: ConnReqContact diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index e3ab2dce22..ed3b0fa741 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -97,14 +97,14 @@ data ChatCommand | StartChat | SetFilesFolder FilePath | APIGetChats {pendingConnections :: Bool} - | APIGetChat ChatType Int64 ChatPagination + | APIGetChat ChatRef ChatPagination | APIGetChatItems Int - | APISendMessage ChatType Int64 (Maybe FilePath) (Maybe ChatItemId) MsgContent - | APISendMessageQuote ChatType Int64 ChatItemId MsgContent -- TODO discontinue - | APIUpdateChatItem ChatType Int64 ChatItemId MsgContent - | APIDeleteChatItem ChatType Int64 ChatItemId CIDeleteMode - | APIChatRead ChatType Int64 (ChatItemId, ChatItemId) - | APIDeleteChat ChatType Int64 + | APISendMessage ChatRef (Maybe FilePath) (Maybe ChatItemId) MsgContent + | APISendMessageQuote ChatRef ChatItemId MsgContent -- TODO discontinue + | APIUpdateChatItem ChatRef ChatItemId MsgContent + | APIDeleteChatItem ChatRef ChatItemId CIDeleteMode + | APIChatRead ChatRef (ChatItemId, ChatItemId) + | APIDeleteChat ChatRef | APIAcceptContact Int64 | APIRejectContact Int64 | APIUpdateProfile Profile @@ -128,11 +128,11 @@ data ChatCommand | AddressAutoAccept Bool | AcceptContact ContactName | RejectContact ContactName - | SendMessage ContactName ByteString + | SendMessage ChatName ByteString | SendMessageQuote {contactName :: ContactName, msgDir :: AMsgDirection, quotedMsg :: ByteString, message :: ByteString} | SendMessageBroadcast ByteString - | DeleteMessage ContactName ByteString - | EditMessage {contactName :: ContactName, editedMsg :: ByteString, message :: ByteString} + | DeleteMessage ChatName ByteString + | EditMessage {chatName :: ChatName, editedMsg :: ByteString, message :: ByteString} | NewGroup GroupProfile | AddMember GroupName ContactName GroupMemberRole | JoinGroup GroupName @@ -142,10 +142,7 @@ data ChatCommand | DeleteGroup GroupName | ListMembers GroupName | ListGroups - | SendGroupMessage GroupName ByteString | SendGroupMessageQuote {groupName :: GroupName, contactName_ :: Maybe ContactName, quotedMsg :: ByteString, message :: ByteString} - | DeleteGroupMessage GroupName ByteString - | EditGroupMessage {groupName :: ContactName, editedMsg :: ByteString, message :: ByteString} | LastMessages (Maybe ChatName) Int | SendFile ContactName FilePath | SendFileInv ContactName FilePath diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 612032ca6c..108e208768 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -44,6 +44,9 @@ data ChatType = CTDirect | CTGroup | CTContactRequest | CTContactConnection data ChatName = ChatName ChatType Text deriving (Show) +data ChatRef = ChatRef ChatType Int64 + deriving (Show) + instance ToJSON ChatType where toJSON = J.genericToJSON . enumJSON $ dropPrefix "CT" toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CT" diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index 5e8f4bcd74..19de70c604 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -45,7 +45,6 @@ runInputLoop ct cc = forever $ do echo s = printToTerminal ct [plain s] isMessage = \case Right SendMessage {} -> True - Right SendGroupMessage {} -> True Right SendFile {} -> True Right SendFileInv {} -> True Right SendGroupFile {} -> True @@ -139,7 +138,6 @@ updateTermState ac tw (key, ms) ts@TerminalState {inputString = s, inputPosition Left _ -> inp Right cmd -> case cmd of SendMessage {} -> "! " <> inp - SendGroupMessage {} -> "! " <> inp SendMessageQuote {contactName, message} -> T.unpack $ "! @" <> contactName <> " " <> safeDecodeUtf8 message SendGroupMessageQuote {groupName, message} -> T.unpack $ "! #" <> groupName <> " " <> safeDecodeUtf8 message _ -> inp From 6c72db58f55c441e25dce745b420199796047708 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Fri, 29 Apr 2022 15:56:56 +0400 Subject: [PATCH 32/95] core: return AChatItem in FileAccepted and FileStart events (#585) --- src/Simplex/Chat.hs | 22 +++++++++++-------- src/Simplex/Chat/Controller.hs | 4 ++-- src/Simplex/Chat/Messages.hs | 3 +++ src/Simplex/Chat/Store.hs | 39 +++++++++++++++++++--------------- src/Simplex/Chat/View.hs | 14 +++++++++--- 5 files changed, 51 insertions(+), 31 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index dfcb597d70..3d8ad8da35 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -609,9 +609,10 @@ processChatCommand = \case ReceiveFile fileId filePath_ -> withUser $ \user@User {userId} -> withChatLock . procCmd $ do ft <- withStore $ \st -> getRcvFileTransfer st userId fileId - (CRRcvFileAccepted ft <$> acceptFileReceive user ft filePath_) `catchError` processError ft + (CRRcvFileAccepted <$> acceptFileReceive user ft filePath_) `catchError` processError ft where processError ft = \case + -- TODO AChatItem in Cancelled events ChatErrorAgent (SMP SMP.AUTH) -> pure $ CRRcvFileAcceptedSndCancelled ft ChatErrorAgent (CONN DUPLICATE) -> pure $ CRRcvFileAcceptedSndCancelled ft e -> throwError e @@ -710,6 +711,7 @@ processChatCommand = \case case status of AFS _ CIFSSndStored -> cancelById fileId AFS _ CIFSRcvInvitation -> cancelById fileId + AFS _ CIFSRcvAccepted -> cancelById fileId AFS _ CIFSRcvTransfer -> cancelById fileId _ -> pure () where @@ -742,7 +744,7 @@ toFSFilePath :: ChatMonad m => FilePath -> m FilePath toFSFilePath f = maybe f (<> "/" <> f) <$> (readTVarIO =<< asks filesFolder) -acceptFileReceive :: forall m. ChatMonad m => User -> RcvFileTransfer -> Maybe FilePath -> m FilePath +acceptFileReceive :: forall m. ChatMonad m => User -> RcvFileTransfer -> Maybe FilePath -> m AChatItem acceptFileReceive user@User {userId} RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName = fName, fileConnReq}, fileStatus, senderDisplayName, grpMemberId} filePath_ = do unless (fileStatus == RFSNew) . throwChatError $ CEFileAlreadyReceiving fName case fileConnReq of @@ -751,8 +753,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, fileInvitation = F tryError (withAgent $ \a -> joinConnection a connReq . directMessage $ XFileAcpt fName) >>= \case Right agentConnId -> do filePath <- getRcvFilePath filePath_ fName - withStore $ \st -> acceptRcvFileTransfer st userId fileId agentConnId filePath - pure filePath + withStore $ \st -> acceptRcvFileTransfer st user fileId agentConnId filePath Left e -> throwError e -- new file protocol Nothing -> @@ -767,14 +768,14 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, fileInvitation = F acceptFileV2 $ \sharedMsgId fileInvConnReq -> sendDirectMessage conn (XFileAcptInv sharedMsgId fileInvConnReq fName) (GroupId groupId) _ -> throwChatError $ CEFileInternal "member connection not active" -- should not happen where - acceptFileV2 :: (SharedMsgId -> ConnReqInvitation -> m SndMessage) -> m FilePath + acceptFileV2 :: (SharedMsgId -> ConnReqInvitation -> m SndMessage) -> m AChatItem acceptFileV2 sendXFileAcptInv = do sharedMsgId <- withStore $ \st -> getSharedMsgIdByFileId st userId fileId (agentConnId, fileInvConnReq) <- withAgent (`createConnection` SCMInvitation) filePath <- getRcvFilePath filePath_ fName - withStore $ \st -> acceptRcvFileTransfer st userId fileId agentConnId filePath + ci <- withStore $ \st -> acceptRcvFileTransfer st user fileId agentConnId filePath void $ sendXFileAcptInv sharedMsgId fileInvConnReq - pure filePath + pure ci where getRcvFilePath :: Maybe FilePath -> String -> m FilePath getRcvFilePath fPath_ fn = case fPath_ of @@ -1176,8 +1177,11 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage XOk -> allowAgentConnection conn confId XOk _ -> pure () CON -> do - withStore $ \st -> updateRcvFileStatus st ft FSConnected - toView $ CRRcvFileStart ft + ci <- withStore $ \st -> do + updateRcvFileStatus st ft FSConnected + updateCIFileStatus st userId fileId CIFSRcvTransfer + getChatItemByFileId st user fileId + toView $ CRRcvFileStart ci MSG meta@MsgMeta {recipient = (msgId, _), integrity} msgBody -> withAckMessage agentConnId meta $ do parseFileChunk msgBody >>= \case FileChunkCancel -> do diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index ed3b0fa741..a3365f8ead 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -205,9 +205,9 @@ data ChatResponse | CRContactRequestAlreadyAccepted {contact :: Contact} | CRLeftMemberUser {groupInfo :: GroupInfo} | CRGroupDeletedUser {groupInfo :: GroupInfo} - | CRRcvFileAccepted {fileTransfer :: RcvFileTransfer, filePath :: FilePath} + | CRRcvFileAccepted {chatItem :: AChatItem} | CRRcvFileAcceptedSndCancelled {rcvFileTransfer :: RcvFileTransfer} - | CRRcvFileStart {rcvFileTransfer :: RcvFileTransfer} + | CRRcvFileStart {chatItem :: AChatItem} | CRRcvFileComplete {chatItem :: AChatItem} | CRRcvFileCancelled {rcvFileTransfer :: RcvFileTransfer} | CRRcvFileSndCancelled {rcvFileTransfer :: RcvFileTransfer} diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 108e208768..a54e779fb2 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -295,6 +295,7 @@ data CIFileStatus (d :: MsgDirection) where CIFSSndStored :: CIFileStatus 'MDSnd CIFSSndCancelled :: CIFileStatus 'MDSnd CIFSRcvInvitation :: CIFileStatus 'MDRcv + CIFSRcvAccepted :: CIFileStatus 'MDRcv CIFSRcvTransfer :: CIFileStatus 'MDRcv CIFSRcvComplete :: CIFileStatus 'MDRcv CIFSRcvCancelled :: CIFileStatus 'MDRcv @@ -318,6 +319,7 @@ instance MsgDirectionI d => StrEncoding (CIFileStatus d) where CIFSSndStored -> "snd_stored" CIFSSndCancelled -> "snd_cancelled" CIFSRcvInvitation -> "rcv_invitation" + CIFSRcvAccepted -> "rcv_accepted" CIFSRcvTransfer -> "rcv_transfer" CIFSRcvComplete -> "rcv_complete" CIFSRcvCancelled -> "rcv_cancelled" @@ -330,6 +332,7 @@ instance StrEncoding ACIFileStatus where "snd_stored" -> pure $ AFS SMDSnd CIFSSndStored "snd_cancelled" -> pure $ AFS SMDSnd CIFSSndCancelled "rcv_invitation" -> pure $ AFS SMDRcv CIFSRcvInvitation + "rcv_accepted" -> pure $ AFS SMDRcv CIFSRcvAccepted "rcv_transfer" -> pure $ AFS SMDRcv CIFSRcvTransfer "rcv_complete" -> pure $ AFS SMDRcv CIFSRcvComplete "rcv_cancelled" -> pure $ AFS SMDRcv CIFSRcvCancelled diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 03760fe3d9..9f4e89fe86 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -2093,14 +2093,14 @@ getRcvFileTransfer_ db userId fileId = cancelled = fromMaybe False cancelled_ rcvFileTransfer _ = Left $ SERcvFileNotFound fileId -acceptRcvFileTransfer :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> ConnId -> FilePath -> m () -acceptRcvFileTransfer st userId fileId agentConnId filePath = - liftIO . withTransaction st $ \db -> do +acceptRcvFileTransfer :: StoreMonad m => SQLiteStore -> User -> Int64 -> ConnId -> FilePath -> m AChatItem +acceptRcvFileTransfer st user@User {userId} fileId agentConnId filePath = + liftIOEither . withTransaction st $ \db -> do currentTs <- getCurrentTime DB.execute db "UPDATE files SET file_path = ?, ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ?" - (filePath, CIFSRcvTransfer, currentTs, userId, fileId) + (filePath, CIFSRcvAccepted, currentTs, userId, fileId) DB.execute db "UPDATE rcv_files SET file_status = ?, updated_at = ? WHERE file_id = ?" @@ -2109,6 +2109,7 @@ acceptRcvFileTransfer st userId fileId agentConnId filePath = db "INSERT INTO connections (agent_conn_id, conn_status, conn_type, rcv_file_id, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" (agentConnId, ConnJoined, ConnRcvFile, fileId, userId, currentTs, currentTs) + getChatItemByFileId_ db user fileId updateRcvFileStatus :: MonadUnliftIO m => SQLiteStore -> RcvFileTransfer -> FileStatus -> m () updateRcvFileStatus st RcvFileTransfer {fileId} status = @@ -3480,19 +3481,23 @@ getGroupChatItemIdByText st User {userId, localDisplayName = userName} groupId c (userId, groupId, cName, quotedMsg <> "%") getChatItemByFileId :: StoreMonad m => SQLiteStore -> User -> Int64 -> m AChatItem -getChatItemByFileId st user@User {userId} fileId = do - liftIOEither . withTransaction st $ \db -> runExceptT $ do - r <- ExceptT $ getChatItemIdByFileId_ db userId fileId - case r of - (itemId, Just contactId, Nothing) -> do - ct <- ExceptT $ getContact_ db userId contactId - (CChatItem msgDir ci) <- ExceptT $ getDirectChatItem_ db userId contactId itemId - pure $ AChatItem SCTDirect msgDir (DirectChat ct) ci - (itemId, Nothing, Just groupId) -> do - gInfo <- ExceptT $ getGroupInfo_ db user groupId - (CChatItem msgDir ci) <- ExceptT $ getGroupChatItem_ db user groupId itemId - pure $ AChatItem SCTGroup msgDir (GroupChat gInfo) ci - _ -> throwError $ SEChatItemNotFoundByFileId fileId +getChatItemByFileId st user fileId = + liftIOEither . withTransaction st $ \db -> + getChatItemByFileId_ db user fileId + +getChatItemByFileId_ :: DB.Connection -> User -> Int64 -> IO (Either StoreError AChatItem) +getChatItemByFileId_ db user@User {userId} fileId = runExceptT $ do + r <- ExceptT $ getChatItemIdByFileId_ db userId fileId + case r of + (itemId, Just contactId, Nothing) -> do + ct <- ExceptT $ getContact_ db userId contactId + (CChatItem msgDir ci) <- ExceptT $ getDirectChatItem_ db userId contactId itemId + pure $ AChatItem SCTDirect msgDir (DirectChat ct) ci + (itemId, Nothing, Just groupId) -> do + gInfo <- ExceptT $ getGroupInfo_ db user groupId + (CChatItem msgDir ci) <- ExceptT $ getGroupChatItem_ db user groupId itemId + pure $ AChatItem SCTGroup msgDir (GroupChat gInfo) ci + _ -> throwError $ SEChatItemNotFoundByFileId fileId getChatItemIdByFileId_ :: DB.Connection -> UserId -> Int64 -> IO (Either StoreError (ChatItemId, Maybe Int64, Maybe Int64)) getChatItemIdByFileId_ db userId fileId = diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 347c6f013c..fe489e2c72 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -92,8 +92,7 @@ responseToView testView = \case CRUserDeletedMember g m -> [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group"] CRLeftMemberUser g -> [ttyGroup' g <> ": you left the group"] <> groupPreserved g CRGroupDeletedUser g -> [ttyGroup' g <> ": you deleted the group"] - CRRcvFileAccepted RcvFileTransfer {fileId, senderDisplayName = c} filePath -> - ["saving file " <> sShow fileId <> " from " <> ttyContact c <> " to " <> plain filePath] + CRRcvFileAccepted ci -> savingFile' ci CRRcvFileAcceptedSndCancelled ft -> viewRcvFileSndCancelled ft CRSndGroupFileCancelled ftm fts -> viewSndGroupFileCancelled ftm fts CRRcvFileCancelled ft -> receivingFile_ "cancelled" ft @@ -101,7 +100,7 @@ responseToView testView = \case CRContactUpdated c c' -> viewContactUpdated c c' CRContactsMerged intoCt mergedCt -> viewContactsMerged intoCt mergedCt CRReceivedContactRequest UserContactRequest {localDisplayName = c, profile} -> viewReceivedContactRequest c profile - CRRcvFileStart ft -> receivingFile_ "started" ft + CRRcvFileStart ci -> receivingFile_' "started" ci CRRcvFileComplete ci -> receivingFile_' "completed" ci CRRcvFileSndCancelled ft -> viewRcvFileSndCancelled ft CRSndFileStart ft -> sendingFile_ "started" ft @@ -556,6 +555,15 @@ humanReadableSize size mB = kB * 1024 gB = mB * 1024 +savingFile' :: AChatItem -> [StyledString] +savingFile' (AChatItem _ _ (DirectChat Contact {localDisplayName = c}) ChatItem {file = Just CIFile {fileId, filePath = Just filePath}, chatDir = CIDirectRcv}) = + ["saving file " <> sShow fileId <> " from " <> ttyContact c <> " to " <> plain filePath] +savingFile' (AChatItem _ _ _ ChatItem {file = Just CIFile {fileId, filePath = Just filePath}, chatDir = CIGroupRcv GroupMember {localDisplayName = m}}) = + ["saving file " <> sShow fileId <> " from " <> ttyContact m <> " to " <> plain filePath] +savingFile' (AChatItem _ _ _ ChatItem {file = Just CIFile {fileId, filePath = Just filePath}}) = + ["saving file " <> sShow fileId <> " to " <> plain filePath] +savingFile' _ = ["saving file"] -- shouldn't happen + receivingFile_' :: StyledString -> AChatItem -> [StyledString] receivingFile_' status (AChatItem _ _ (DirectChat Contact {localDisplayName = c}) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIDirectRcv}) = [status <> " receiving " <> fileTransferStr fileId fileName <> " from " <> ttyContact c] From 099f25c63f44dd6fad03b05fb799cbb3b225cee0 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 29 Apr 2022 17:03:12 +0100 Subject: [PATCH 33/95] ios: update version 1.7 (40) --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 9a6c0b7d2a..dff3f05f88 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -700,7 +700,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 39; + CURRENT_PROJECT_VERSION = 40; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -724,7 +724,7 @@ ); "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; - MARKETING_VERSION = 1.6; + MARKETING_VERSION = 1.7; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -744,7 +744,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 39; + CURRENT_PROJECT_VERSION = 40; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -768,7 +768,7 @@ ); "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; - MARKETING_VERSION = 1.6; + MARKETING_VERSION = 1.7; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; From 305052ecaf95858630f6667dcaf34026e98622b0 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 30 Apr 2022 12:47:50 +0100 Subject: [PATCH 34/95] core: api to resubscribe connections (#586) * core: api to resubscribe connections * update simplexmq --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 16 +++++++++++----- src/Simplex/Chat/Controller.hs | 1 + stack.yaml | 2 +- tests/ChatClient.hs | 4 ++-- 6 files changed, 17 insertions(+), 10 deletions(-) diff --git a/cabal.project b/cabal.project index dba79c9c94..fa47eee7b2 100644 --- a/cabal.project +++ b/cabal.project @@ -3,7 +3,7 @@ packages: . source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: f8ec838912579159ca0ebd956a559fc20bf1d7ea + tag: 1064e9c315bb4b9c09ba62141f86e21690876d76 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index f13c239d0e..523616273e 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."f8ec838912579159ca0ebd956a559fc20bf1d7ea" = "0kywhk2q92h3j24pp9lxycs062myl8ch7wdwlyd6v82lcm14w8pd"; + "https://github.com/simplex-chat/simplexmq.git"."1064e9c315bb4b9c09ba62141f86e21690876d76" = "10acs17k6rp6xx58rsfwz0xgq6yhfkiy2rxj86pyxan7867dnawr"; "https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp"; "https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; "https://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 3d8ad8da35..bc322ec2cd 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -183,6 +183,7 @@ processChatCommand = \case asks agentAsync >>= readTVarIO >>= \case Just _ -> pure CRChatRunning _ -> startChatController user $> CRChatStarted + ResubscribeAllConnections -> withUser (subscribeUserConnections resubscribeConnection) $> CRCmdOk SetFilesFolder filesFolder' -> withUser $ \_ -> do createDirectoryIfMissing True filesFolder' ff <- asks filesFolder @@ -824,15 +825,19 @@ agentSubscriber :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m agentSubscriber user = do q <- asks $ subQ . smpAgent l <- asks chatLock - subscribeUserConnections user + subscribeUserConnections subscribeConnection user forever $ do (_, connId, msg) <- atomically $ readTBQueue q u <- readTVarIO =<< asks currentUser withLock l . void . runExceptT $ processAgentMessage u connId msg `catchError` (toView . CRChatError) -subscribeUserConnections :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m () -subscribeUserConnections user@User {userId} = do +subscribeUserConnections :: + (MonadUnliftIO m, MonadReader ChatController m) => + (forall m'. ChatMonad m' => AgentClient -> ConnId -> ExceptT AgentErrorType m' ()) -> + User -> + m () +subscribeUserConnections agentSubscribe user@User {userId} = do n <- asks $ subscriptionConcurrency . config ce <- asks $ subscriptionEvents . config void . runExceptT $ do @@ -902,10 +907,10 @@ subscribeUserConnections user@User {userId} = do cs <- withStore (`getUserContactLinkConnections` userId) (subscribeConns n cs >> toView CRUserContactLinkSubscribed) `catchError` (toView . CRUserContactLinkSubError) - subscribe cId = withAgent (`subscribeConnection` cId) + subscribe cId = withAgent (`agentSubscribe` cId) subscribeConns n conns = withAgent $ \a -> - pooledForConcurrentlyN_ n conns $ \c -> subscribeConnection a (aConnId c) + pooledForConcurrentlyN_ n conns $ \c -> agentSubscribe a (aConnId c) processAgentMessage :: forall m. ChatMonad m => Maybe User -> ConnId -> ACommand 'Agent -> m () processAgentMessage Nothing _ _ = throwChatError CENoActiveUser @@ -1914,6 +1919,7 @@ chatCommandP = ("/user " <|> "/u ") *> (CreateActiveUser <$> userProfile) <|> ("/user" <|> "/u") $> ShowActiveUser <|> "/_start" $> StartChat + <|> "/_resubscribe all" $> ResubscribeAllConnections <|> "/_files_folder " *> (SetFilesFolder <$> filePath) <|> "/_get chats" *> (APIGetChats <$> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False)) <|> "/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index a3365f8ead..774d357aa8 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -95,6 +95,7 @@ data ChatCommand = ShowActiveUser | CreateActiveUser Profile | StartChat + | ResubscribeAllConnections | SetFilesFolder FilePath | APIGetChats {pendingConnections :: Bool} | APIGetChat ChatRef ChatPagination diff --git a/stack.yaml b/stack.yaml index 1ed52e5f44..cfa84cd45c 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: f8ec838912579159ca0ebd956a559fc20bf1d7ea + commit: 1064e9c315bb4b9c09ba62141f86e21690876d76 # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 - github: simplex-chat/aeson commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7 diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 9716632e9c..d4a419623e 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -209,8 +209,8 @@ serverCfg = msgIdBytes = 6, storeLogFile = Nothing, allowNewQueues = True, - messageTTL = Just $ 7 * 86400, -- 7 days - expireMessagesInterval = Just 21600_000000, -- microseconds, 6 hours + messageExpiration = Just defaultMessageExpiration, + inactiveClientExpiration = Just defaultInactiveClientExpiration, caCertificateFile = "tests/fixtures/tls/ca.crt", privateKeyFile = "tests/fixtures/tls/server.key", certificateFile = "tests/fixtures/tls/server.crt" From dd592c7db32a332cdd604075b305a7d59af4dba2 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Sat, 30 Apr 2022 19:18:46 +0400 Subject: [PATCH 35/95] core: discontinue old file protocol (send); use MCFile when sending and receiving files (#587) --- src/Simplex/Chat.hs | 68 ++----------- src/Simplex/Chat/Controller.hs | 5 +- src/Simplex/Chat/Terminal/Input.hs | 3 - tests/ChatTests.hs | 147 ----------------------------- 4 files changed, 8 insertions(+), 215 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index bc322ec2cd..e938b679b4 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -552,61 +552,9 @@ processChatCommand = \case chatRef <- getChatRef user chatName CRLastMessages . aChatItems . chat <$> (processChatCommand . APIGetChat chatRef $ CPLast count) LastMessages Nothing _count -> pure $ chatCmdError "not implemented" - -- old file protocol - -- SendFile cName f -> withUser $ \User {userId} -> do - -- contactId <- withStore $ \st -> getContactIdByName st userId cName - -- processChatCommand $ APISendMessage CTDirect contactId (Just f) Nothing (MCText "") - -- TODO replace with code above when switching from XFile - SendFile cName f -> withUser $ \user@User {userId} -> withChatLock $ do - (fileSize, chSize) <- checkSndFile f - contact <- withStore $ \st -> getContactByName st userId cName - (agentConnId, fileConnReq) <- withAgent (`createConnection` SCMInvitation) - let fileName = takeFileName f - fileInv = FileInvitation {fileName = takeFileName f, fileSize, fileConnReq = Just fileConnReq} - fileId <- withStore $ \st -> - createSndFileTransfer st userId contact f fileInv agentConnId chSize - msg <- sendDirectContactMessage contact (XFile fileInv) - let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just f, fileStatus = CIFSSndStored} - ci <- saveSndChatItem user (CDDirectSnd contact) msg (CISndMsgContent $ MCText "") (Just ciFile) Nothing - withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci - setActive $ ActiveC cName - pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat contact) ci - -- new file protocol (not used for direct files) - SendFileInv cName f -> withUser $ \user@User {userId} -> withChatLock $ do - ct <- withStore $ \st -> getContactByName st userId cName - (fileSize, chSize) <- checkSndFile f - let fileName = takeFileName f - fileInvitation = FileInvitation {fileName, fileSize, fileConnReq = Nothing} - fileId <- withStore $ \st -> createSndFileTransferV2 st userId ct f fileInvitation chSize - let mc = MCText "" - ciFile = Just $ CIFile {fileId, fileName, fileSize, filePath = Just f, fileStatus = CIFSSndStored} - msg <- sendDirectContactMessage ct (XMsgNew (MCSimple (ExtMsgContent mc (Just fileInvitation)))) - ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) ciFile Nothing - setActive $ ActiveC cName - pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci - -- old file protocol - -- TODO discontinue - SendGroupFile gName f -> withUser $ \user@User {userId} -> withChatLock $ do - Group gInfo@GroupInfo {groupId, membership} members <- withStore $ \st -> getGroupByName st user gName - unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved - (fileSize, chSize) <- checkSndFile f - let fileName = takeFileName f - ms <- forM (filter memberActive members) $ \m -> do - (connId, fileConnReq) <- withAgent (`createConnection` SCMInvitation) - pure (m, connId, FileInvitation {fileName, fileSize, fileConnReq = Just fileConnReq}) - fileId <- withStore $ \st -> createSndGroupFileTransfer st userId gInfo ms f fileSize chSize - forM_ ms $ \(m, _, fileInvitation) -> - traverse (\conn -> sendDirectMessage conn (XFile fileInvitation) (GroupId groupId)) $ memberConn m - setActive $ ActiveG gName - -- this is a hack as we have multiple direct messages instead of one per group - let msg = SndMessage {msgId = 0, sharedMsgId = SharedMsgId "", msgBody = ""} - ciFile = Just $ CIFile {fileId, fileName, fileSize, filePath = Just f, fileStatus = CIFSSndStored} - ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndMsgContent $ MCText "") ciFile Nothing - pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci - -- new file protocol - SendGroupFileInv gName f -> withUser $ \user -> do - groupId <- withStore $ \st -> getGroupIdByName st user gName - processChatCommand $ APISendMessage (ChatRef CTGroup groupId) (Just f) Nothing (MCText "") + SendFile chatName f -> withUser $ \user -> do + chatRef <- getChatRef user chatName + processChatCommand $ APISendMessage chatRef (Just f) Nothing (MCFile "") ReceiveFile fileId filePath_ -> withUser $ \user@User {userId} -> withChatLock . procCmd $ do ft <- withStore $ \st -> getRcvFileTransfer st userId fileId @@ -1389,7 +1337,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage chSize <- asks $ fileChunkSize . config RcvFileTransfer {fileId} <- withStore $ \st -> createRcvFileTransfer st userId ct fInv chSize let ciFile = Just $ CIFile {fileId, fileName, fileSize, filePath = Nothing, fileStatus = CIFSRcvInvitation} - ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvMsgContent $ MCText "") ciFile + ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvMsgContent $ MCFile "") ciFile toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci checkIntegrity msgMeta $ toView . CRMsgIntegrityError showToast (c <> "> ") "wants to send a file" @@ -1401,7 +1349,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage chSize <- asks $ fileChunkSize . config RcvFileTransfer {fileId} <- withStore $ \st -> createRcvGroupFileTransfer st userId m fInv chSize let ciFile = Just $ CIFile {fileId, fileName, fileSize, filePath = Nothing, fileStatus = CIFSRcvInvitation} - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvMsgContent $ MCText "") ciFile + ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvMsgContent $ MCFile "") ciFile groupMsgToView gInfo ci msgMeta let g = groupName' gInfo showToast ("#" <> g <> " " <> c <> "> ") "wants to send a file" @@ -1967,10 +1915,7 @@ chatCommandP = <|> ("! " <|> "!") *> (EditMessage <$> chatNameP <* A.space <*> (quotedMsg <|> pure "") <*> A.takeByteString) <|> "/feed " *> (SendMessageBroadcast <$> A.takeByteString) <|> ("/tail" <|> "/t") *> (LastMessages <$> optional (A.space *> chatNameP) <*> msgCountP) - <|> ("/file #" <|> "/f #") *> (SendGroupFile <$> displayName <* A.space <*> filePath) - <|> ("/file_v2 #" <|> "/f_v2 #") *> (SendGroupFileInv <$> displayName <* A.space <*> filePath) - <|> ("/file @" <|> "/file " <|> "/f @" <|> "/f ") *> (SendFile <$> displayName <* A.space <*> filePath) - <|> ("/file_v2 @" <|> "/file_v2 " <|> "/f_v2 @" <|> "/f_v2 ") *> (SendFileInv <$> displayName <* A.space <*> filePath) + <|> ("/file " <|> "/f ") *> (SendFile <$> chatNameP' <* A.space <*> filePath) <|> ("/freceive " <|> "/fr ") *> (ReceiveFile <$> A.decimal <*> optional (A.space *> filePath)) <|> ("/fcancel " <|> "/fc ") *> (CancelFile <$> A.decimal) <|> ("/fstatus " <|> "/fs ") *> (FileStatus <$> A.decimal) @@ -2035,6 +1980,7 @@ chatCommandP = <|> (" member" $> GRMember) <|> pure GRAdmin chatNameP = ChatName <$> chatTypeP <*> displayName + chatNameP' = ChatName <$> (chatTypeP <|> pure CTDirect) <*> displayName chatRefP = ChatRef <$> chatTypeP <*> A.decimal msgCountP = A.space *> A.decimal <|> pure 10 diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 774d357aa8..4c0c667c02 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -145,10 +145,7 @@ data ChatCommand | ListGroups | SendGroupMessageQuote {groupName :: GroupName, contactName_ :: Maybe ContactName, quotedMsg :: ByteString, message :: ByteString} | LastMessages (Maybe ChatName) Int - | SendFile ContactName FilePath - | SendFileInv ContactName FilePath - | SendGroupFile GroupName FilePath - | SendGroupFileInv GroupName FilePath + | SendFile ChatName FilePath | ReceiveFile FileTransferId (Maybe FilePath) | CancelFile FileTransferId | FileStatus FileTransferId diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index 19de70c604..e186cf69b6 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -46,9 +46,6 @@ runInputLoop ct cc = forever $ do isMessage = \case Right SendMessage {} -> True Right SendFile {} -> True - Right SendFileInv {} -> True - Right SendGroupFile {} -> True - Right SendGroupFileInv {} -> True Right SendMessageQuote {} -> True Right SendGroupMessageQuote {} -> True Right SendMessageBroadcast {} -> True diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index d88a7c626d..a11e82067e 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -57,12 +57,6 @@ chatTests = do it "sender cancelled file transfer" testFileSndCancel it "recipient cancelled file transfer" testFileRcvCancel it "send and receive file to group" testGroupFileTransfer - describe "sending and receiving files v2" $ do - it "send and receive file" testFileTransferV2 - it "send and receive a small file" testSmallFileTransferV2 - it "sender cancelled file transfer" testFileSndCancelV2 - it "recipient cancelled file transfer" testFileRcvCancelV2 - it "send and receive file to group" testGroupFileTransferV2 describe "messages with files" $ do it "send and receive message with file" testMessageWithFile it "send and receive image" testSendImage @@ -1064,134 +1058,6 @@ testGroupFileTransfer = cath <## "use /fr 1 [/ | ] to receive it" ] alice ##> "/fs 1" - getTermLine alice >>= (`shouldStartWith` "sending file 1 (test.jpg) not accepted") - bob ##> "/fr 1 ./tests/tmp/" - bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" - concurrentlyN_ - [ do - alice <## "started sending file 1 (test.jpg) to bob" - alice <## "completed sending file 1 (test.jpg) to bob" - alice ##> "/fs 1" - alice <## "sending file 1 (test.jpg):" - alice <### [" complete: bob", " not accepted: cath"], - do - bob <## "started receiving file 1 (test.jpg) from alice" - bob <## "completed receiving file 1 (test.jpg) from alice" - ] - cath ##> "/fr 1 ./tests/tmp/" - cath <## "saving file 1 from alice to ./tests/tmp/test_1.jpg" - concurrentlyN_ - [ do - alice <## "started sending file 1 (test.jpg) to cath" - alice <## "completed sending file 1 (test.jpg) to cath" - alice ##> "/fs 1" - getTermLine alice >>= (`shouldStartWith` "sending file 1 (test.jpg) complete"), - do - cath <## "started receiving file 1 (test.jpg) from alice" - cath <## "completed receiving file 1 (test.jpg) from alice" - ] - -testFileTransferV2 :: IO () -testFileTransferV2 = - testChat2 aliceProfile bobProfile $ - \alice bob -> do - connectUsers alice bob - startFileTransferV2 alice bob - concurrentlyN_ - [ do - bob #> "@alice receiving here..." - bob <## "completed receiving file 1 (test.jpg) from alice", - do - alice <# "bob> receiving here..." - alice <## "completed sending file 1 (test.jpg) to bob" - ] - src <- B.readFile "./tests/fixtures/test.jpg" - dest <- B.readFile "./tests/tmp/test.jpg" - dest `shouldBe` src - -testSmallFileTransferV2 :: IO () -testSmallFileTransferV2 = - testChat2 aliceProfile bobProfile $ - \alice bob -> do - connectUsers alice bob - alice `send` "/f_v2 @bob ./tests/fixtures/test.txt" - alice <# "/f @bob ./tests/fixtures/test.txt" - alice <## "use /fc 1 to cancel sending" - bob <# "alice> sends file test.txt (11 bytes / 11 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 ./tests/tmp" - bob <## "saving file 1 from alice to ./tests/tmp/test.txt" - concurrentlyN_ - [ do - bob <## "started receiving file 1 (test.txt) from alice" - bob <## "completed receiving file 1 (test.txt) from alice", - do - alice <## "started sending file 1 (test.txt) to bob" - alice <## "completed sending file 1 (test.txt) to bob" - ] - src <- B.readFile "./tests/fixtures/test.txt" - dest <- B.readFile "./tests/tmp/test.txt" - dest `shouldBe` src - -testFileSndCancelV2 :: IO () -testFileSndCancelV2 = - testChat2 aliceProfile bobProfile $ - \alice bob -> do - connectUsers alice bob - startFileTransferV2 alice bob - alice ##> "/fc 1" - concurrentlyN_ - [ do - alice <## "cancelled sending file 1 (test.jpg) to bob" - alice ##> "/fs 1" - alice <## "sending file 1 (test.jpg) cancelled: bob" - alice <## "file transfer cancelled", - do - bob <## "alice cancelled sending file 1 (test.jpg)" - bob ##> "/fs 1" - bob <## "receiving file 1 (test.jpg) cancelled, received part path: ./tests/tmp/test.jpg" - ] - checkPartialTransfer - -testFileRcvCancelV2 :: IO () -testFileRcvCancelV2 = - testChat2 aliceProfile bobProfile $ - \alice bob -> do - connectUsers alice bob - startFileTransferV2 alice bob - bob ##> "/fs 1" - getTermLine bob >>= (`shouldStartWith` "receiving file 1 (test.jpg) progress") - waitFileExists "./tests/tmp/test.jpg" - bob ##> "/fc 1" - concurrentlyN_ - [ do - bob <## "cancelled receiving file 1 (test.jpg) from alice" - bob ##> "/fs 1" - bob <## "receiving file 1 (test.jpg) cancelled, received part path: ./tests/tmp/test.jpg", - do - alice <## "bob cancelled receiving file 1 (test.jpg)" - alice ##> "/fs 1" - alice <## "sending file 1 (test.jpg) cancelled: bob" - ] - checkPartialTransfer - -testGroupFileTransferV2 :: IO () -testGroupFileTransferV2 = - testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - createGroup3 "team" alice bob cath - alice `send` "/f_v2 #team ./tests/fixtures/test.jpg" - alice <# "/f #team ./tests/fixtures/test.jpg" - alice <## "use /fc 1 to cancel sending" - concurrentlyN_ - [ do - bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - bob <## "use /fr 1 [/ | ] to receive it", - do - cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - cath <## "use /fr 1 [/ | ] to receive it" - ] - alice ##> "/fs 1" getTermLine alice >>= (`shouldStartWith` "sending file 1 (test.jpg): no file transfers") bob ##> "/fr 1 ./tests/tmp/" bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" @@ -1905,19 +1771,6 @@ startFileTransfer alice bob = do (bob <## "started receiving file 1 (test.jpg) from alice") (alice <## "started sending file 1 (test.jpg) to bob") -startFileTransferV2 :: TestCC -> TestCC -> IO () -startFileTransferV2 alice bob = do - alice `send` "/f_v2 @bob ./tests/fixtures/test.jpg" - alice <# "/f @bob ./tests/fixtures/test.jpg" - alice <## "use /fc 1 to cancel sending" - bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 ./tests/tmp" - bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" - concurrently_ - (bob <## "started receiving file 1 (test.jpg) from alice") - (alice <## "started sending file 1 (test.jpg) to bob") - checkPartialTransfer :: IO () checkPartialTransfer = do src <- B.readFile "./tests/fixtures/test.jpg" From 2b1ab01efe591f760d092ff5f531d0c136382207 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 30 Apr 2022 21:23:14 +0100 Subject: [PATCH 36/95] terminal: /tail all messages (for all chats) (#589) * terminal: /tail all messages (for all chats) * tests --- src/Simplex/Chat.hs | 3 +- src/Simplex/Chat/Store.hs | 68 +++++++++++++++++++++++++++++++-------- tests/ChatTests.hs | 14 ++++++++ 3 files changed, 71 insertions(+), 14 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index e938b679b4..49963a22de 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -551,7 +551,8 @@ processChatCommand = \case LastMessages (Just chatName) count -> withUser $ \user -> do chatRef <- getChatRef user chatName CRLastMessages . aChatItems . chat <$> (processChatCommand . APIGetChat chatRef $ CPLast count) - LastMessages Nothing _count -> pure $ chatCmdError "not implemented" + LastMessages Nothing count -> withUser $ \user -> withStore $ \st -> + CRLastMessages <$> getAllChatItems st user (CPLast count) SendFile chatName f -> withUser $ \user -> do chatRef <- getChatRef user chatName processChatCommand $ APISendMessage chatRef (Just f) Nothing (MCFile "") diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 9f4e89fe86..d1835490ed 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -131,6 +131,7 @@ module Simplex.Chat.Store getChatPreviews, getDirectChat, getGroupChat, + getAllChatItems, getChatItemIdByAgentMsgId, getDirectChatItem, getDirectChatItemBySharedMsgId, @@ -3112,6 +3113,31 @@ getGroupInfo_ db User {userId, userContactId} groupId = |] (groupId, userId, userContactId) +getAllChatItems :: StoreMonad m => SQLiteStore -> User -> ChatPagination -> m [AChatItem] +getAllChatItems st user pagination = + liftIOEither . withTransaction st $ \db -> runExceptT $ do + case pagination of + CPLast count -> getAllChatItemsLast_ db user count + CPAfter _afterId _count -> throwError $ SEInternalError "not implemented" + CPBefore _beforeId _count -> throwError $ SEInternalError "not implemented" + +getAllChatItemsLast_ :: DB.Connection -> User -> Int -> ExceptT StoreError IO [AChatItem] +getAllChatItemsLast_ db user@User {userId} count = do + itemRefs <- + liftIO $ + reverse . rights . map toChatItemRef + <$> DB.query + db + [sql| + SELECT chat_item_id, contact_id, group_id + FROM chat_items + WHERE user_id = ? + ORDER BY item_ts DESC, chat_item_id DESC + LIMIT ? + |] + (userId, count) + mapM (uncurry $ getAChatItem_ db user) itemRefs + getGroupIdByName :: StoreMonad m => SQLiteStore -> User -> GroupName -> m Int64 getGroupIdByName st user gName = liftIOEither . withTransaction st $ \db -> getGroupIdByName_ db user gName @@ -3487,21 +3513,24 @@ getChatItemByFileId st user fileId = getChatItemByFileId_ :: DB.Connection -> User -> Int64 -> IO (Either StoreError AChatItem) getChatItemByFileId_ db user@User {userId} fileId = runExceptT $ do - r <- ExceptT $ getChatItemIdByFileId_ db userId fileId - case r of - (itemId, Just contactId, Nothing) -> do - ct <- ExceptT $ getContact_ db userId contactId - (CChatItem msgDir ci) <- ExceptT $ getDirectChatItem_ db userId contactId itemId - pure $ AChatItem SCTDirect msgDir (DirectChat ct) ci - (itemId, Nothing, Just groupId) -> do - gInfo <- ExceptT $ getGroupInfo_ db user groupId - (CChatItem msgDir ci) <- ExceptT $ getGroupChatItem_ db user groupId itemId - pure $ AChatItem SCTGroup msgDir (GroupChat gInfo) ci - _ -> throwError $ SEChatItemNotFoundByFileId fileId + (itemId, chatRef) <- ExceptT $ getChatItemIdByFileId_ db userId fileId + getAChatItem_ db user itemId chatRef -getChatItemIdByFileId_ :: DB.Connection -> UserId -> Int64 -> IO (Either StoreError (ChatItemId, Maybe Int64, Maybe Int64)) +getAChatItem_ :: DB.Connection -> User -> ChatItemId -> ChatRef -> ExceptT StoreError IO AChatItem +getAChatItem_ db user@User {userId} itemId = \case + ChatRef CTDirect contactId -> do + ct <- ExceptT $ getContact_ db userId contactId + (CChatItem msgDir ci) <- ExceptT $ getDirectChatItem_ db userId contactId itemId + pure $ AChatItem SCTDirect msgDir (DirectChat ct) ci + ChatRef CTGroup groupId -> do + gInfo <- ExceptT $ getGroupInfo_ db user groupId + (CChatItem msgDir ci) <- ExceptT $ getGroupChatItem_ db user groupId itemId + pure $ AChatItem SCTGroup msgDir (GroupChat gInfo) ci + _ -> throwError $ SEChatItemNotFound itemId + +getChatItemIdByFileId_ :: DB.Connection -> UserId -> Int64 -> IO (Either StoreError (ChatItemId, ChatRef)) getChatItemIdByFileId_ db userId fileId = - firstRow id (SEChatItemNotFoundByFileId fileId) $ + firstRow' toChatItemRef (SEChatItemNotFoundByFileId fileId) $ DB.query db [sql| @@ -3513,6 +3542,12 @@ getChatItemIdByFileId_ db userId fileId = |] (userId, fileId) +toChatItemRef :: (ChatItemId, Maybe Int64, Maybe Int64) -> Either StoreError (ChatItemId, ChatRef) +toChatItemRef = \case + (itemId, Just contactId, Nothing) -> Right (itemId, ChatRef CTDirect contactId) + (itemId, Nothing, Just groupId) -> Right (itemId, ChatRef CTGroup groupId) + (itemId, _, _) -> Left $ SEBadChatItem itemId + updateDirectChatItemsRead :: (StoreMonad m) => SQLiteStore -> Int64 -> (ChatItemId, ChatItemId) -> m () updateDirectChatItemsRead st contactId (fromItemId, toItemId) = do currentTs <- liftIO getCurrentTime @@ -3722,6 +3757,13 @@ createWithRandomBytes size gVar create = tryCreate 3 randomBytes :: TVar ChaChaDRG -> Int -> IO ByteString randomBytes gVar n = B64.encode <$> (atomically . stateTVar gVar $ randomBytesGenerate n) +listToEither :: e -> [a] -> Either e a +listToEither _ (x : _) = Right x +listToEither e _ = Left e + +firstRow' :: (a -> Either e b) -> e -> IO [a] -> IO (Either e b) +firstRow' f e a = (f <=< listToEither e) <$> a + -- These error type constructors must be added to mobile apps data StoreError = SEDuplicateName diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index a11e82067e..f37411b166 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -469,6 +469,20 @@ testGroup2 = bob <##> cath dan <##> cath dan <##> alice + -- show last messages + alice ##> "/t #club 3" + alice <# "#club cath> hey" + alice <# "#club dan> how is it going?" + alice <# "#club hello" + alice ##> "/t @dan 2" + alice <# "dan> hi" + alice <# "@dan hey" + alice ##> "/t 5" + alice <# "#club cath> hey" + alice <# "#club dan> how is it going?" + alice <# "dan> hi" + alice <# "#club hello" + alice <# "@dan hey" -- remove member cath ##> "/rm club dan" concurrentlyN_ From 42959cc35031d6482fea7e6bc64d601a07ae11fb Mon Sep 17 00:00:00 2001 From: IanRDavies Date: Sun, 1 May 2022 09:30:41 +0100 Subject: [PATCH 37/95] Video calls RFC (#542) * draft RFC for video calls, own rolled WebRTC approach in comments * formatting typo * update * update rfc (WIP) Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- docs/rfcs/2022-04-20-video-calls.md | 57 +++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 docs/rfcs/2022-04-20-video-calls.md diff --git a/docs/rfcs/2022-04-20-video-calls.md b/docs/rfcs/2022-04-20-video-calls.md new file mode 100644 index 0000000000..38c2ad6186 --- /dev/null +++ b/docs/rfcs/2022-04-20-video-calls.md @@ -0,0 +1,57 @@ +# Adding Audio/Video Call Functionality to SimpleX Apps + +To extend the functionality of the SimpleX mobile apps in pursuit of supporting all kinds of communication, we seek to add the ability for already connected users to call each other with audio and optionally video. + +## Desired Functionality +- [ ] The content (audio/video data) of the calls is encrypted +- [ ] The setting up of the call session is secure and encrypted (via the SimpleX protocol) +- [ ] Contacts can audio call each other +- [ ] Contacts can video call each other +- [ ] When on a call users can mute/unmute their mic +- [ ] When on a video call users can show/hide their video feed +- [ ] Users will be notified of other calls when already engaged in a call +- [ ] Incoming calls trigger a notification which offers the chance to accept or reject the call. Accepting the call opens the app to a call page. +- [ ] (TBC) Calls will be entered into chat history as immutable messages with styling differing from typical messages + +## Proposed Implementation + +The calls themselves should be handled by [WebRTC](https://www.html5rocks.com/en/tutorials/webrtc/infrastructure). This requires some initial messaging to set up the details of the session (routing, codecs, message priorities) and then the data of the call is passed peer-to-peer through the WebRTC channel resulting from the session instantiation. In order to secure the communications, the initial communication to set up the session will be handled through the existing SimpleX communication channel between users. The content sent through the WebRTC session will also be encrypted using keys (exchanged through SimpleX). Full details of the workflow for setting up WebRTC calls can be found [here](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Signaling_and_video_calling). + +To take advantage of existing development and infrastructure, we propose to build this functionality using webviews in our apps which will have web pages using JavaScript APIs to handle WebRTC elements. + + +### Setting Up the Session + +In essence, we can use SimpleX to [handle the signalling](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Signaling_and_video_calling) with [ICE](https://developer.mozilla.org/en-US/docs/Glossary/ICE) agents performing negotiation at either end in the SimpleX mobile app. This requires the sharing of [Session Description Protocol](https://developer.mozilla.org/en-US/docs/Glossary/SDP) information which can be serialised as JSON. These can be passed as a new message type in the SimpleX API. + +There are a few key features required to set up a call session. +1. Generation of Offers +2. Negotiation of Offers +3. Instantiating the call over the network +4. Showing the users the video streams + +These elements can be included in a new 'call' message type which includes the information alongside the nature of the call (i.e. audio only or video). + +User state etc can be updated as calls are connected/disconnected. + +### Initial Prototype +To get off the ground, we will develop an initial prototype (not for app store release). This prototype will simply set up a video call with no changes to the SimpleX API (as we will simply pass messages as JSON through other channels). + +This prototype will demonstrate how to set up calls using WebRTC and demonstrate how to pass information to and from webviews in app. + +The workflow will be as follows +1. +2. +3. + +## Queries +**Do we need to set up and destroy virtual IP addresses for additional security?** + +For initial implementation it is sufficient to warn users that they may be exposing their IP to the recipient. + +**Is it beneficial to have an additional layer of encryption for the media content (under the principle of zero trust or otherwise)?** + +Yes. We can implement a 'frame encryption' method in the SimpleX API which given a key and some content returns the encrypted content. Similarly, we will have a decryption call. Keys can be call specific and formed using typical DH key exchange. + +**Who runs the STUN/TURN servers?** +For the initial prototype we can use publicly available servers. For a full release implementation, SimpleX will need to run its own routing servers to support ICE and possibly STUN/TURN. Open source implementations for these elements exist. \ No newline at end of file From 606eefa45d34923b02e9962d10de3350be432743 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 1 May 2022 14:05:01 +0100 Subject: [PATCH 38/95] mobile: show contact connection pending status as ellipsis, error as gray circle (no green circles) (#588) * mobile: show contact connection pending status as ellipsis, error as gray circle (no green circles) * android: show contact status --- .../app/views/chatlist/ChatPreviewView.kt | 37 ++++++++++++++++--- .../Views/ChatList/ChatPreviewView.swift | 26 ++++++++++--- 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt index 3144f92710..d99a51f05e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt @@ -4,8 +4,10 @@ import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Circle +import androidx.compose.material.icons.filled.MoreHoriz import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -17,8 +19,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.app.R -import chat.simplex.app.model.Chat -import chat.simplex.app.model.getTimestampText +import chat.simplex.app.model.* import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chat.item.MarkdownText @@ -29,7 +30,12 @@ import chat.simplex.app.views.helpers.badgeLayout fun ChatPreviewView(chat: Chat) { Row { val cInfo = chat.chatInfo - ChatInfoImage(cInfo, size = 72.dp) + Box(contentAlignment = Alignment.BottomStart) { + ChatInfoImage(cInfo, size = 72.dp) + if (cInfo is ChatInfo.Direct) { + ChatStatusImage(chat) + } + } Column( modifier = Modifier .padding(horizontal = 8.dp) @@ -86,6 +92,27 @@ fun ChatPreviewView(chat: Chat) { } } +@Composable +fun ChatStatusImage(chat: Chat) { + val s = chat.serverInfo.networkStatus + val descr = s.statusString + if (s is Chat.NetworkStatus.Error) { + Icon( + Icons.Filled.Circle, + contentDescription = descr, + tint = HighOrLowlight, + modifier = Modifier.padding(start = 6.dp).padding(bottom = 4.dp).size(6.dp) + ) + } else if (!(s is Chat.NetworkStatus.Connected)) { + Icon( + Icons.Filled.MoreHoriz, + contentDescription = descr, + tint = HighOrLowlight, + modifier = Modifier.width(19.dp).padding(start = 6.dp) + ) + } +} + @Preview(showBackground = true) @Preview( uiMode = Configuration.UI_MODE_NIGHT_YES, diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 5088d63557..5b94a4b7d2 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -20,12 +20,8 @@ struct ChatPreviewView: View { ZStack(alignment: .bottomLeading) { ChatInfoImage(chat: chat) .frame(width: 63, height: 63) - if case .direct = chat.chatInfo, - chat.serverInfo.networkStatus == .connected { - Image(systemName: "circle.fill") - .resizable() - .foregroundColor(colorScheme == .dark ? darkGreen : .green) - .frame(width: 5, height: 5) + if case .direct = chat.chatInfo { + chatStatusImage() .padding([.bottom, .leading], 1) } } @@ -91,6 +87,24 @@ struct ChatPreviewView: View { default: return Text("") } } + + @ViewBuilder private func chatStatusImage() -> some View { + switch chat.serverInfo.networkStatus { + case .connected: EmptyView() + case .error: + Image(systemName: "circle.fill") + .resizable() + .scaledToFit() + .frame(maxHeight: 5) + .foregroundColor(.secondary) + default: + Image(systemName: "ellipsis") + .resizable() + .scaledToFit() + .frame(maxWidth: 10) + .foregroundColor(.secondary) + } + } } struct ChatPreviewView_Previews: PreviewProvider { From 26d3e71c4e4ea01bce7312a47d124b76b71f6c33 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 1 May 2022 14:07:18 +0100 Subject: [PATCH 39/95] fix test for /t --- tests/ChatTests.hs | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index f37411b166..87ff663ea3 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -478,11 +478,13 @@ testGroup2 = alice <# "dan> hi" alice <# "@dan hey" alice ##> "/t 5" - alice <# "#club cath> hey" - alice <# "#club dan> how is it going?" - alice <# "dan> hi" - alice <# "#club hello" - alice <# "@dan hey" + alice -- these strings are expected in any order because of sorting by time + <##? [ "#club cath> hey", + "#club dan> how is it going?", + "#club hello", + "dan> hi", + "@dan hey" + ] -- remove member cath ##> "/rm club dan" concurrentlyN_ @@ -1919,14 +1921,20 @@ send TestCC {chatController = cc} cmd = atomically $ writeTBQueue (inputQ cc) cm (<##) :: TestCC -> String -> Expectation cc <## line = getTermLine cc `shouldReturn` line -(<###) :: TestCC -> [String] -> Expectation -_ <### [] = pure () -cc <### ls = do - line <- getTermLine cc +getInAnyOrder :: (String -> String) -> TestCC -> [String] -> Expectation +getInAnyOrder _ _ [] = pure () +getInAnyOrder f cc ls = do + line <- f <$> getTermLine cc if line `elem` ls - then cc <### filter (/= line) ls + then getInAnyOrder f cc $ filter (/= line) ls else error $ "unexpected output: " <> line +(<###) :: TestCC -> [String] -> Expectation +(<###) = getInAnyOrder id + +(<##?) :: TestCC -> [String] -> Expectation +(<##?) = getInAnyOrder dropTime + (<#) :: TestCC -> String -> Expectation cc <# line = (dropTime <$> getTermLine cc) `shouldReturn` line From 94a4f33a1faa2cd54bf0cbd7eb23008784d2db6b Mon Sep 17 00:00:00 2001 From: IanRDavies Date: Sun, 1 May 2022 14:08:20 +0100 Subject: [PATCH 40/95] id/video calls prototype (#570) * initial UI framework * limited javascrtipt interaction * run some js * try to resolve permissions issues * some initial RTC javascript * approaching a workable js file * js fixes * tidy up js * add some ui to web call * fixes * ready to test * typo * refactor for readability * tidy up before adding encryption * add transform to video streams * tidy a little, audio encoding works, video fails * minor changes * use variables consistently * e2ee video calls git push * include IV in outgoing message, decrypt fails when trying to read back * add different prefix retention for differing frame types * e2ee video calls with iv passed in band * enforce use of VP8 encoding * allow plaintext chunk only for video frames * tidy up kotlin. Android <> browser tested * minor ios changes * capture js logs in xcode * typo * linting Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/android/app/build.gradle | 1 + apps/android/app/src/main/AndroidManifest.xml | 3 + .../android/app/src/main/assets/www/call.html | 13 + apps/android/app/src/main/assets/www/call.js | 299 ++++++++++++++++++ .../android/app/src/main/assets/www/style.css | 24 ++ .../chat/simplex/app/views/call/CallView.kt | 165 ++++++++++ .../app/views/usersettings/SettingsView.kt | 18 +- apps/ios/Shared/Views/Call/CallView.swift | 93 ++++++ .../Views/UserSettings/SettingsView.swift | 7 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 22 +- 10 files changed, 635 insertions(+), 10 deletions(-) create mode 100644 apps/android/app/src/main/assets/www/call.html create mode 100644 apps/android/app/src/main/assets/www/call.js create mode 100644 apps/android/app/src/main/assets/www/style.css create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt create mode 100644 apps/ios/Shared/Views/Call/CallView.swift diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index 9d9b1154ba..21395df3e2 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -81,6 +81,7 @@ dependencies { implementation "androidx.compose.material:material-icons-extended:$compose_version" implementation "androidx.navigation:navigation-compose:2.4.1" implementation "com.google.accompanist:accompanist-insets:0.23.0" + implementation 'androidx.webkit:webkit:1.4.0' def work_version = "2.7.1" implementation "androidx.work:work-runtime-ktx:$work_version" diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index b7e2e930af..3c2b3d9451 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,9 @@ + + + diff --git a/apps/android/app/src/main/assets/www/call.html b/apps/android/app/src/main/assets/www/call.html new file mode 100644 index 0000000000..57774ce58a --- /dev/null +++ b/apps/android/app/src/main/assets/www/call.html @@ -0,0 +1,13 @@ + + + + + + + + + +
+ +
+ diff --git a/apps/android/app/src/main/assets/www/call.js b/apps/android/app/src/main/assets/www/call.js new file mode 100644 index 0000000000..6cabf95eb6 --- /dev/null +++ b/apps/android/app/src/main/assets/www/call.js @@ -0,0 +1,299 @@ +// Inspired by +// https://github.com/webrtc/samples/blob/gh-pages/src/content/insertable-streams/endtoend-encryption + +let incomingVideo = document.getElementById("incoming-video-stream") +let outgoingVideo = document.getElementById("outgoing-video-stream") +incomingVideo.style.opacity = 0 +outgoingVideo.style.opacity = 0 +incomingVideo.onplaying = () => { + incomingVideo.style.opacity = 1 +} +outgoingVideo.onplaying = () => { + outgoingVideo.style.opacity = 1 +} + +// STUN servers +const peerConnectionConfig = { + iceServers: [{urls: ["stun:stun.l.google.com:19302"]}], + iceCandidatePoolSize: 10, + encodedInsertableStreams: true, +} +let keyGenConfig = { + name: "AES-GCM", + length: 256, + tagLength: 128, +} +let keyUsages = ["encrypt", "decrypt"] + +// Hardcode a key for development +let keyData = {alg: "A256GCM", ext: true, k: "JCMDWkhxLmPDhua0BUdhgv6Ac6hOtB9frSxJlnkTAK8", key_ops: keyUsages, kty: "oct"} + +let pc +let key +let IV_LENGTH = 12 +const initialPlainTextRequired = { + key: 10, + delta: 3, + undefined: 1, +} + +// let encryptKeyRepresentation +let candidates = [] +run() + +async function run() { + pc = new RTCPeerConnection(peerConnectionConfig) + + pc.onicecandidate = (event) => { + // add candidate to maintained list to be sent all at once + if (event.candidate) { + candidates.push(event.candidate) + } + } + pc.onicegatheringstatechange = (_) => { + if (pc.iceGatheringState == "complete") { + // Give command for other caller to use + console.log(JSON.stringify({action: "processIceCandidates", content: candidates})) + } + } + let remoteStream = new MediaStream() + key = await crypto.subtle.importKey("jwk", keyData, keyGenConfig, true, keyUsages) + let localStream = await getLocalVideoStream() + setUpVideos(pc, localStream, remoteStream) +} + +async function processCommand(data) { + switch (data.action) { + case "initiateCall": + console.log("initiating call") + let result = await makeOffer(pc) + // Give command for callee to use + console.log( + JSON.stringify({ + action: "processAndAnswerOffer", + content: result, + }) + ) + return result + case "processAndAnswerOffer": + await processOffer(data.content) + let answer = await answerOffer(pc) + // Give command for callee to use + console.log( + JSON.stringify({ + action: "processOffer", + content: answer, + }) + ) + return answer + case "processOffer": + await processOffer(data.content) + break + case "processIceCandidates": + processIceCandidates(data.content) + break + default: + console.log("JS: Unknown Command") + } +} + +async function makeOffer(pc) { + // For initiating a call. Send offer to callee + let offerDescription = await pc.createOffer() + await pc.setLocalDescription(offerDescription) + let offer = { + sdp: offerDescription.sdp, + type: offerDescription.type, + } + return offer +} + +async function answerOffer(pc) { + let answerDescription = await pc.createAnswer() + await pc.setLocalDescription(answerDescription) + let answer = { + sdp: answerDescription.sdp, + type: answerDescription.type, + } + return answer +} + +function processIceCandidates(iceCandidates) { + iceCandidates.forEach((candidate) => processIceCandidate(candidate)) +} + +function processIceCandidate(iceCandidate) { + let candidate = new RTCIceCandidate(iceCandidate) + pc.addIceCandidate(candidate) +} + +async function processOffer(offer) { + // Negotiating initial connection + if (!pc.currentRemoteDescription) { + let remoteSessionDescription = new RTCSessionDescription(offer) + await pc.setRemoteDescription(remoteSessionDescription) + } +} + +function setUpVideos(pc, localStream, remoteStream) { + localStream.getTracks().forEach((track) => { + pc.addTrack(track, localStream) + }) + pc.getSenders().forEach(setupSenderTransform) + // Pull tracks from remote stream as they arrive add them to remoteStream video + pc.ontrack = (event) => { + setupReceiverTransform(event.receiver) + event.streams[0].getTracks().forEach((track) => { + remoteStream.addTrack(track) + }) + } + // We assume VP8 encoding in the decode/encode stages to get the initial + // bytes to pass as plaintext so we enforce that here. + // VP8 is supported by all supports of webrtc. + // Use of VP8 by default may also reduce depacketisation issues. + // We do not encrypt the first couple of bytes of the payload so that the + // video elements can work by determining video keyframes and the opus mode + // being used. This appears to be necessary for any video feed at all. + // For VP8 this is the content described in + // https://tools.ietf.org/html/rfc6386#section-9.1 + // which is 10 bytes for key frames and 3 bytes for delta frames. + // For opus (where encodedFrame.type is not set) this is the TOC byte from + // https://tools.ietf.org/html/rfc6716#section-3.1 + + const {codecs} = RTCRtpSender.getCapabilities("video") + const selectedCodecIndex = codecs.findIndex((c) => c.mimeType === "video/VP8") + const selectedCodec = codecs[selectedCodecIndex] + codecs.splice(selectedCodecIndex, 1) + codecs.unshift(selectedCodec) + const transceiver = pc.getTransceivers().find((t) => t.sender && t.sender.track.kind === "video") + transceiver.setCodecPreferences(codecs) + + outgoingVideo.srcObject = localStream + incomingVideo.srcObject = remoteStream +} + +async function getLocalVideoStream() { + return await navigator.mediaDevices.getUserMedia({ + audio: true, + video: { + frameRate: 24, + width: { + min: 480, + ideal: 720, + max: 1280, + }, + aspectRatio: 1.33, + }, + }) +} + +function endCall() { + pc.close() +} + +function toggleVideo(b) { + if (b == "true") { + localStream.getVideoTracks()[0].enabled = true + } else { + localStream.getVideoTracks()[0].enabled = false + } +} + +function f() { + console.log("Debug Function") + return "Debugging Return" +} + +/* Stream Transforms */ +function setupSenderTransform(sender) { + const senderStreams = sender.createEncodedStreams() + const transformStream = new TransformStream({ + transform: encodeFunction, + }) + senderStreams.readable.pipeThrough(transformStream).pipeTo(senderStreams.writable) +} + +function setupReceiverTransform(receiver) { + const receiverStreams = receiver.createEncodedStreams() + const transformStream = new TransformStream({ + transform: decodeFunction, + }) + receiverStreams.readable.pipeThrough(transformStream).pipeTo(receiverStreams.writable) +} + +/* Cryptography */ +function encodeFunction(frame, controller) { + // frame is an RTCEncodedAudioFrame + // frame.data is ArrayBuffer + let data = new Uint8Array(frame.data) + let n = frame instanceof RTCEncodedVideoFrame ? initialPlainTextRequired[frame.type] : 0 + let iv = randomIV() + let initial = data.subarray(0, n) + let plaintext = data.subarray(n, data.byteLength) + crypto.subtle + .encrypt({name: "AES-GCM", iv: iv.buffer}, key, plaintext) + .then((c) => { + frame.data = concatN(initial, new Uint8Array(c), iv).buffer + controller.enqueue(frame) + }) + .catch((e) => { + console.log("encrypt error") + endCall() + throw e + }) +} +function decodeFunction(frame, controller) { + let data = new Uint8Array(frame.data) + let n = frame instanceof RTCEncodedVideoFrame ? initialPlainTextRequired[frame.type] : 0 + let initial = data.subarray(0, n) + let ciphertext = data.subarray(n, data.byteLength - IV_LENGTH) + let iv = data.subarray(data.byteLength - IV_LENGTH, data.byteLength) + crypto.subtle + .decrypt({name: "AES-GCM", iv: iv}, key, ciphertext) + .then((p) => { + frame.data = concatN(initial, new Uint8Array(p)).buffer + controller.enqueue(frame) + }) + .catch((e) => { + console.log("decrypt error") + endCall() + throw e + }) +} + +function randomIV() { + return crypto.getRandomValues(new Uint8Array(IV_LENGTH)) +} +async function loadKey(keyData) { + key = await crypto.subtle.importKey("jwk", keyData, keyGenConfig, false, keyUsages) +} + +function concatN(...bs) { + const a = new Uint8Array(bs.reduce((size, b) => size + b.byteLength, 0)) + bs.reduce((offset, b) => { + a.set(b, offset) + return offset + b.byteLength + }, 0) + return a +} + +async function generateKey() { + crypto.subtle + .generateKey(keyGenConfig, true, keyUsages) + .then((k) => { + encryptKey = k + return crypto.subtle.exportKey("jwk", encryptKey) + }) + .then((r) => { + encryptKeyRepresentation = r + console.log( + JSON.stringify({ + action: "processDecryptionKey", + content: { + key: encryptKeyRepresentation, + iv: encryptIv, + }, + }) + ) + }) +} diff --git a/apps/android/app/src/main/assets/www/style.css b/apps/android/app/src/main/assets/www/style.css new file mode 100644 index 0000000000..89987105b4 --- /dev/null +++ b/apps/android/app/src/main/assets/www/style.css @@ -0,0 +1,24 @@ +video::-webkit-media-controls { + display: none; +} +html, body { + padding: 0; + margin: 0; +} +#incoming-video-stream { + position: absolute; + width: 100%; + height: 100%; + object-fit: cover; +} + +#outgoing-video-stream { + position: absolute; + width: 30%; + max-width: 30%; + object-fit: cover; + margin: 16px; + border-radius: 16px; + bottom: 0; + right: 0; +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt new file mode 100644 index 0000000000..62c027194d --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt @@ -0,0 +1,165 @@ +package chat.simplex.app.views.call + +import android.Manifest +import android.annotation.SuppressLint +import android.content.ClipData +import android.content.ClipboardManager +import android.util.Log +import android.view.ViewGroup +import android.webkit.* +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.webkit.WebViewAssetLoader +import androidx.webkit.WebViewClientCompat +import chat.simplex.app.TAG +import chat.simplex.app.views.helpers.TextEditor +import com.google.accompanist.permissions.rememberMultiplePermissionsState + +//@SuppressLint("JavascriptInterface") +@Composable +fun VideoCallView(close: () -> Unit) { + BackHandler(onBack = close) + lateinit var wv: WebView + val context = LocalContext.current + val clipboard = ContextCompat.getSystemService(context, ClipboardManager::class.java) + val permissionsState = rememberMultiplePermissionsState( + permissions = listOf( + Manifest.permission.CAMERA, + Manifest.permission.RECORD_AUDIO, + Manifest.permission.MODIFY_AUDIO_SETTINGS, + Manifest.permission.INTERNET + ) + ) + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME || event == Lifecycle.Event.ON_START) { + permissionsState.launchMultiplePermissionRequest() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + wv.evaluateJavascript("endCall()", null) + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + val localContext = LocalContext.current + val iceCandidateCommand = remember { mutableStateOf("") } + val commandToShow = remember { mutableStateOf("processCommand({action: \"initiateCall\"})") } + val assetLoader = WebViewAssetLoader.Builder() + .addPathHandler("/assets/www/", WebViewAssetLoader.AssetsPathHandler(localContext)) + .build() + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .background(MaterialTheme.colors.background) + .fillMaxSize() + ) { + if (permissionsState.allPermissionsGranted) { + Box( + Modifier + .fillMaxWidth() + .aspectRatio(ratio = 1F) + ) { + AndroidView( + factory = { AndroidViewContext -> + WebView(AndroidViewContext).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + this.webChromeClient = object: WebChromeClient() { + override fun onPermissionRequest(request: PermissionRequest) { + if (request.origin.toString().startsWith("file:/")) { + request.grant(request.resources) + } else { + Log.d(TAG, "Permission request from webview denied.") + request.deny() + } + } + + override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { + val rtnValue = super.onConsoleMessage(consoleMessage) + val msg = consoleMessage?.message() as String + if (msg.startsWith("{\"action\":\"processIceCandidates\"")) { + iceCandidateCommand.value = "processCommand($msg)" + } else if (msg.startsWith("{")) { + commandToShow.value = "processCommand($msg)" + } + return rtnValue + } + } + this.webViewClient = LocalContentWebViewClient(assetLoader) + this.clearHistory() + this.clearCache(true) +// this.addJavascriptInterface(JavascriptInterface(), "Android") + val webViewSettings = this.settings + webViewSettings.allowFileAccess = true + webViewSettings.allowContentAccess = true + webViewSettings.javaScriptEnabled = true + webViewSettings.mediaPlaybackRequiresUserGesture = false + webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE + this.loadUrl("file:android_asset/www/call.html") + } + } + ) { + wv = it + } + } + } else { + Text("NEED PERMISSIONS") + } + + TextEditor(Modifier.height(180.dp), text = commandToShow) + + Row( + Modifier + .fillMaxWidth() + .padding(bottom = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Button( onClick = { + val clip: ClipData = ClipData.newPlainText("js command", commandToShow.value) + clipboard?.setPrimaryClip(clip) + }) {Text("Copy")} + Button( onClick = { + println("sending: ${commandToShow.value}") + wv.evaluateJavascript(commandToShow.value, null) + commandToShow.value = "" + }) {Text("Send")} + Button( onClick = { + commandToShow.value = iceCandidateCommand.value + }) {Text("ICE")} + Button( onClick = { + commandToShow.value = "" + }) {Text("Clear")} + Button( onClick = { + wv.evaluateJavascript("endCall()", null) + }) {Text("End Call")} + } + } +} + +private class LocalContentWebViewClient(private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() { + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest + ): WebResourceResponse? { + return assetLoader.shouldInterceptRequest(request.url) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt index cd3cd897f0..5eb906cea8 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt @@ -24,6 +24,7 @@ import chat.simplex.app.model.Profile import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.TerminalView +import chat.simplex.app.views.call.VideoCallView import chat.simplex.app.views.helpers.* @Composable @@ -39,7 +40,8 @@ fun SettingsView(chatModel: ChatModel) { }, showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } }, showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } }, - showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } } + showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } }, + showVideoChatPrototype = { ModalManager.shared.showCustomModal { close -> VideoCallView(close) } }, ) } } @@ -54,7 +56,8 @@ fun SettingsLayout( setRunServiceInBackground: (Boolean) -> Unit, showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit), - showTerminal: () -> Unit + showTerminal: () -> Unit, + showVideoChatPrototype: () -> Unit ) { val uriHandler = LocalUriHandler.current Surface( @@ -159,9 +162,9 @@ fun SettingsLayout( Spacer(Modifier.padding(horizontal = 4.dp)) Text( stringResource(R.string.private_notifications), Modifier - .padding(end = 24.dp) - .fillMaxWidth() - .weight(1F)) + .padding(end = 24.dp) + .fillMaxWidth() + .weight(1F)) Switch( checked = runServiceInBackground.value, onCheckedChange = { setRunServiceInBackground(it) }, @@ -191,7 +194,7 @@ fun SettingsLayout( Text(annotatedStringResource(R.string.install_simplex_chat_for_terminal)) } Divider(Modifier.padding(horizontal = 8.dp)) - SettingsSectionView() { + SettingsSectionView(showVideoChatPrototype) { Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") } } @@ -227,7 +230,8 @@ fun PreviewSettingsLayout() { setRunServiceInBackground = {}, showModal = {{}}, showCustomModal = {{}}, - showTerminal = {} + showTerminal = {}, + showVideoChatPrototype = {} ) } } diff --git a/apps/ios/Shared/Views/Call/CallView.swift b/apps/ios/Shared/Views/Call/CallView.swift new file mode 100644 index 0000000000..383558be8a --- /dev/null +++ b/apps/ios/Shared/Views/Call/CallView.swift @@ -0,0 +1,93 @@ +// +// CallView.swift +// SimpleX (iOS) +// +// Created by Ian Davies on 29/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import WebKit + +struct WebView: UIViewRepresentable { + class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { + var webView: WKWebView! + + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + webView.allowsBackForwardNavigationGestures = false + self.webView = webView + } + + // receive message from wkwebview + func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + print(message.body) +// let date = Date() +// DispatchQueue.main.asyncAfter(deadline: .now() + 1) { +// self.messageToWebview(msg: "hello, I got your messsage: \(message.body) at \(date)") +// } + } + + func messageToWebview(msg: String) { + self.webView?.evaluateJavaScript("webkit.messageHandlers.bridge.onMessage('\(msg)')") + } + } + + func makeCoordinator() -> Coordinator { + return Coordinator() + } + + func makeUIView(context: Context) -> WKWebView { + let coordinator = makeCoordinator() + let userContentController = WKUserContentController() + userContentController.add(coordinator, name: "bridge") + + let configuration = WKWebViewConfiguration() + configuration.userContentController = userContentController + configuration.mediaTypesRequiringUserActionForPlayback = [] + configuration.allowsInlineMediaPlayback = true + + // Enable us to capture calls to console.log in the xcode logs + // Print actually happens on line 29 + let source = "console.log = (msg) => webkit.messageHandlers.logHandler.postMessage(msg)" + let script = WKUserScript(source: source, injectionTime: .atDocumentStart, forMainFrameOnly: false) + configuration.userContentController.addUserScript(script) + configuration.userContentController.add(coordinator, name: "logHandler") + + let _wkwebview = WKWebView(frame: .zero, configuration: configuration) + _wkwebview.navigationDelegate = coordinator + + return _wkwebview + } + + func updateUIView(_ webView: WKWebView, context: Context) { + guard let path: String = Bundle.main.path(forResource: "call", ofType: "html", inDirectory: "www") else { + print("page not found") + return + } + let localHTMLUrl = URL(fileURLWithPath: path, isDirectory: false) + webView.loadFileURL(localHTMLUrl, allowingReadAccessTo: localHTMLUrl) + } +} + + + +struct CallView: View { + var body: some View { + VStack { + WebView() + } + } +} + + + + +struct CallView_Previews: PreviewProvider { + static var previews: some View { + CallView() + } +} diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index c2d329d102..3e5bdf0d5b 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -143,7 +143,12 @@ struct SettingsView: View { notificationsToggle(token) } } - Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))") + NavigationLink { + CallView() + .frame(maxHeight: .infinity, alignment: .top) + } label: { + Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))") + } } } .navigationTitle("Your settings") diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index dff3f05f88..df7e3810a7 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 3C714777281C081000CB4D4B /* CallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C714776281C081000CB4D4B /* CallView.swift */; }; + 3C71477A281C0F6800CB4D4B /* www in Resources */ = {isa = PBXBuildFile; fileRef = 3C714779281C0F6800CB4D4B /* www */; }; 3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */; }; 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; }; 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; }; @@ -86,6 +88,8 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 3C714776281C081000CB4D4B /* CallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallView.swift; sourceTree = ""; }; + 3C714779281C0F6800CB4D4B /* www */ = {isa = PBXFileReference; lastKnownFileType = folder; name = www; path = ../android/app/src/main/assets/www; sourceTree = ""; }; 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteToConnectView.swift; sourceTree = ""; }; 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = ""; }; 3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = ""; }; @@ -185,9 +189,18 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 3C714775281C080100CB4D4B /* Call */ = { + isa = PBXGroup; + children = ( + 3C714776281C081000CB4D4B /* CallView.swift */, + ); + path = Call; + sourceTree = ""; + }; 5C2E260D27A30E2400F70299 /* Views */ = { isa = PBXGroup; children = ( + 3C714775281C080100CB4D4B /* Call */, 5C971E1F27AEBF7000C8A3CE /* Helpers */, 5C5F4AC227A5E9AF00B51EF1 /* Chat */, 5CB9250B27A942F300ACCCDD /* ChatList */, @@ -266,6 +279,7 @@ 5CA059BD279559F40002BEB4 = { isa = PBXGroup; children = ( + 3C714779281C0F6800CB4D4B /* www */, 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */, 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */, 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */, @@ -463,6 +477,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 3C71477A281C0F6800CB4D4B /* www in Resources */, 5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */, 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */, 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */, @@ -531,6 +546,7 @@ 5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */, 5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */, 5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */, + 3C714777281C081000CB4D4B /* CallView.swift in Sources */, 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */, 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */, 5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */, @@ -706,7 +722,8 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "SimpleX--iOS--Info.plist"; - INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other app users"; + INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect other users and for video calls."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "SimpleX needs microphone access for audio and video calls."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -750,7 +767,8 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "SimpleX--iOS--Info.plist"; - INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other app users"; + INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect other users and for video calls."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "SimpleX needs microphone access for audio and video calls."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; From 63f531259d819280347df7ebe2bcac0ca4894e30 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 2 May 2022 10:57:35 +0100 Subject: [PATCH 41/95] fix test for /t --- tests/ChatTests.hs | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 87ff663ea3..05e0ea7b6c 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -470,18 +470,24 @@ testGroup2 = dan <##> cath dan <##> alice -- show last messages - alice ##> "/t #club 3" - alice <# "#club cath> hey" - alice <# "#club dan> how is it going?" - alice <# "#club hello" + alice ##> "/t #club 4" + alice -- these strings are expected in any order because of sorting by time and rounding of time for sent + <##? [ "#club hello", + "#club bob> hi there", + "#club cath> hey", + "#club dan> how is it going?" + ] alice ##> "/t @dan 2" - alice <# "dan> hi" - alice <# "@dan hey" - alice ##> "/t 5" - alice -- these strings are expected in any order because of sorting by time - <##? [ "#club cath> hey", + alice + <##? [ "dan> hi", + "@dan hey" + ] + alice ##> "/t 6" + alice + <##? [ "#club hello", + "#club bob> hi there", + "#club cath> hey", "#club dan> how is it going?", - "#club hello", "dan> hi", "@dan hey" ] From f78ec3584f3af647d8580da910e455ec368f3961 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 2 May 2022 15:48:39 +0100 Subject: [PATCH 42/95] webrtc calls in webview to typescript (#592) * mobile: webrtc calls in webview (typescript WIP) * typsecript compiles * fix error messages * TS works in chrome * include ICE candidates into offer/answer, report connection state changes to host, end call on disconnection * refactor, readme for .js file --- .../android/app/src/main/assets/www/README.md | 1 + .../android/app/src/main/assets/www/call.html | 4 +- apps/android/app/src/main/assets/www/call.js | 698 +++++++++++------- .../android/app/src/main/assets/www/style.css | 4 +- .../chat/simplex/app/views/call/CallView.kt | 2 +- packages/simplex-chat-webrtc/.gitignore | 3 + packages/simplex-chat-webrtc/.prettierrc.json | 5 + packages/simplex-chat-webrtc/package.json | 24 + packages/simplex-chat-webrtc/src/call.html | 13 + packages/simplex-chat-webrtc/src/call.ts | 584 +++++++++++++++ packages/simplex-chat-webrtc/src/style.css | 24 + packages/simplex-chat-webrtc/tsconfig.json | 21 + 12 files changed, 1107 insertions(+), 276 deletions(-) create mode 100644 apps/android/app/src/main/assets/www/README.md create mode 100644 packages/simplex-chat-webrtc/.gitignore create mode 100644 packages/simplex-chat-webrtc/.prettierrc.json create mode 100644 packages/simplex-chat-webrtc/package.json create mode 100644 packages/simplex-chat-webrtc/src/call.html create mode 100644 packages/simplex-chat-webrtc/src/call.ts create mode 100644 packages/simplex-chat-webrtc/src/style.css create mode 100644 packages/simplex-chat-webrtc/tsconfig.json diff --git a/apps/android/app/src/main/assets/www/README.md b/apps/android/app/src/main/assets/www/README.md new file mode 100644 index 0000000000..272433c4c9 --- /dev/null +++ b/apps/android/app/src/main/assets/www/README.md @@ -0,0 +1 @@ +Do NOT edit call.js here, it is compiled from call.ts in packages/simplex-chat-webrtc diff --git a/apps/android/app/src/main/assets/www/call.html b/apps/android/app/src/main/assets/www/call.html index 57774ce58a..ebe63e1a3b 100644 --- a/apps/android/app/src/main/assets/www/call.html +++ b/apps/android/app/src/main/assets/www/call.html @@ -4,8 +4,8 @@ - - + +
diff --git a/apps/android/app/src/main/assets/www/call.js b/apps/android/app/src/main/assets/www/call.js index 6cabf95eb6..4e7f21a2c8 100644 --- a/apps/android/app/src/main/assets/www/call.js +++ b/apps/android/app/src/main/assets/www/call.js @@ -1,299 +1,455 @@ +"use strict"; // Inspired by // https://github.com/webrtc/samples/blob/gh-pages/src/content/insertable-streams/endtoend-encryption - -let incomingVideo = document.getElementById("incoming-video-stream") -let outgoingVideo = document.getElementById("outgoing-video-stream") -incomingVideo.style.opacity = 0 -outgoingVideo.style.opacity = 0 -incomingVideo.onplaying = () => { - incomingVideo.style.opacity = 1 -} -outgoingVideo.onplaying = () => { - outgoingVideo.style.opacity = 1 -} - +var CallMediaType; +(function (CallMediaType) { + CallMediaType["Audio"] = "audio"; + CallMediaType["Video"] = "video"; +})(CallMediaType || (CallMediaType = {})); // STUN servers const peerConnectionConfig = { - iceServers: [{urls: ["stun:stun.l.google.com:19302"]}], - iceCandidatePoolSize: 10, - encodedInsertableStreams: true, -} -let keyGenConfig = { - name: "AES-GCM", - length: 256, - tagLength: 128, -} -let keyUsages = ["encrypt", "decrypt"] - -// Hardcode a key for development -let keyData = {alg: "A256GCM", ext: true, k: "JCMDWkhxLmPDhua0BUdhgv6Ac6hOtB9frSxJlnkTAK8", key_ops: keyUsages, kty: "oct"} - -let pc -let key -let IV_LENGTH = 12 + iceServers: [{ urls: ["stun:stun.l.google.com:19302"] }], + iceCandidatePoolSize: 10, + encodedInsertableStreams: true, +}; +const keyAlgorithm = { + name: "AES-GCM", + length: 256 +}; +const keyUsages = ["encrypt", "decrypt"]; +let pc; +const IV_LENGTH = 12; const initialPlainTextRequired = { - key: 10, - delta: 3, - undefined: 1, -} - -// let encryptKeyRepresentation -let candidates = [] -run() - -async function run() { - pc = new RTCPeerConnection(peerConnectionConfig) - - pc.onicecandidate = (event) => { - // add candidate to maintained list to be sent all at once - if (event.candidate) { - candidates.push(event.candidate) + key: 10, + delta: 3, + undefined: 1, +}; +const defaultCallConfig = { + iceCandidates: { + delay: 2000, + extrasInterval: 2000, + extrasTimeout: 8000 } - } - pc.onicegatheringstatechange = (_) => { - if (pc.iceGatheringState == "complete") { - // Give command for other caller to use - console.log(JSON.stringify({action: "processIceCandidates", content: candidates})) +}; +async function initializeCall(config, mediaType, aesKey) { + const conn = new RTCPeerConnection(peerConnectionConfig); + const remoteStream = new MediaStream(); + const localStream = await navigator.mediaDevices.getUserMedia(callMediaContraints(mediaType)); + await setUpMediaStreams(conn, localStream, remoteStream, aesKey); + conn.addEventListener("connectionstatechange", connectionStateChange); + const iceCandidates = new Promise((resolve, _) => { + let candidates = []; + let resolved = false; + let extrasInterval; + let extrasTimeout; + const delay = setTimeout(() => { + if (!resolved) { + resolveIceCandidates(); + extrasInterval = setInterval(() => { + sendIceCandidates(); + }, config.iceCandidates.extrasInterval); + extrasTimeout = setTimeout(() => { + clearInterval(extrasInterval); + sendIceCandidates(); + }, config.iceCandidates.extrasTimeout); + } + }, config.iceCandidates.delay); + conn.onicecandidate = ({ candidate: c }) => c && candidates.push(c); + conn.onicegatheringstatechange = () => { + if (conn.iceGatheringState == "complete") { + if (resolved) { + if (extrasInterval) + clearInterval(extrasInterval); + if (extrasTimeout) + clearTimeout(extrasTimeout); + sendIceCandidates(); + } + else { + resolveIceCandidates(); + } + } + }; + function resolveIceCandidates() { + if (delay) + clearTimeout(delay); + resolved = true; + const iceCandidates = candidates.slice(); + candidates = []; + resolve(iceCandidates); + } + function sendIceCandidates() { + if (candidates.length === 0) + return; + const iceCandidates = candidates.slice(); + candidates = []; + sendMessageToNative({ type: "ice", iceCandidates }); + } + }); + return { connection: conn, iceCandidates }; + function connectionStateChange() { + sendMessageToNative({ + type: "connection", + state: { + connectionState: conn.connectionState, + iceConnectionState: conn.iceConnectionState, + iceGatheringState: conn.iceGatheringState, + signalingState: conn.signalingState, + } + }); + if (conn.connectionState == "disconnected" || conn.connectionState == "failed") { + conn.removeEventListener("connectionstatechange", connectionStateChange); + sendMessageToNative({ type: "ended" }); + conn.close(); + pc = undefined; + resetVideoElements(); + } } - } - let remoteStream = new MediaStream() - key = await crypto.subtle.importKey("jwk", keyData, keyGenConfig, true, keyUsages) - let localStream = await getLocalVideoStream() - setUpVideos(pc, localStream, remoteStream) } - -async function processCommand(data) { - switch (data.action) { - case "initiateCall": - console.log("initiating call") - let result = await makeOffer(pc) - // Give command for callee to use - console.log( - JSON.stringify({ - action: "processAndAnswerOffer", - content: result, - }) - ) - return result - case "processAndAnswerOffer": - await processOffer(data.content) - let answer = await answerOffer(pc) - // Give command for callee to use - console.log( - JSON.stringify({ - action: "processOffer", - content: answer, - }) - ) - return answer - case "processOffer": - await processOffer(data.content) - break - case "processIceCandidates": - processIceCandidates(data.content) - break - default: - console.log("JS: Unknown Command") - } +// TODO remove WCallCommand from parameter type +function sendMessageToNative(msg) { + console.log(JSON.stringify(msg)); } - -async function makeOffer(pc) { - // For initiating a call. Send offer to callee - let offerDescription = await pc.createOffer() - await pc.setLocalDescription(offerDescription) - let offer = { - sdp: offerDescription.sdp, - type: offerDescription.type, - } - return offer +// TODO remove WCallCommand from result type +async function processCommand(command) { + let resp; + switch (command.type) { + case "capabilities": + const encryption = supportsInsertableStreams(); + resp = { type: "capabilities", capabilities: { encryption } }; + break; + case "start": + console.log("starting call"); + if (pc) { + resp = { type: "error", message: "start: call already started" }; + } + else if (!supportsInsertableStreams() && command.aesKey) { + resp = { type: "error", message: "start: encryption is not supported" }; + } + else { + try { + const { media, aesKey } = command; + const call = await initializeCall(defaultCallConfig, media, aesKey); + const { connection, iceCandidates } = call; + pc = connection; + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + // for debugging, returning the command for callee to use + resp = { type: "accept", offer, iceCandidates: await iceCandidates, media, aesKey }; + // resp = {type: "offer", offer, iceCandidates: await iceCandidates} + } + catch (e) { + resp = { type: "error", message: e.message }; + } + } + break; + case "accept": + if (pc) { + resp = { type: "error", message: "accept: call already started" }; + } + else if (!supportsInsertableStreams() && command.aesKey) { + resp = { type: "error", message: "accept: encryption is not supported" }; + } + else { + try { + const call = await initializeCall(defaultCallConfig, command.media, command.aesKey); + const { connection, iceCandidates } = call; + pc = connection; + await pc.setRemoteDescription(new RTCSessionDescription(command.offer)); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + addIceCandidates(pc, command.iceCandidates); + // same as command for caller to use + resp = { type: "answer", answer, iceCandidates: await iceCandidates }; + } + catch (e) { + resp = { type: "error", message: e.message }; + } + } + break; + case "answer": + if (!pc) { + resp = { type: "error", message: "answer: call not started" }; + } + else if (!pc.localDescription) { + resp = { type: "error", message: "answer: local description is not set" }; + } + else if (pc.currentRemoteDescription) { + resp = { type: "error", message: "answer: remote description already set" }; + } + else { + await pc.setRemoteDescription(new RTCSessionDescription(command.answer)); + addIceCandidates(pc, command.iceCandidates); + resp = { type: "ok" }; + } + break; + case "ice": + if (pc) { + addIceCandidates(pc, command.iceCandidates); + resp = { type: "ok" }; + } + else { + resp = { type: "error", message: "ice: call not started" }; + } + break; + case "end": + if (pc) { + pc.close(); + pc = undefined; + resetVideoElements(); + resp = { type: "ok" }; + } + else { + resp = { type: "error", message: "end: call not started" }; + } + break; + default: + resp = { type: "error", message: "unknown command" }; + break; + } + sendMessageToNative(resp); + return resp; } - -async function answerOffer(pc) { - let answerDescription = await pc.createAnswer() - await pc.setLocalDescription(answerDescription) - let answer = { - sdp: answerDescription.sdp, - type: answerDescription.type, - } - return answer +function addIceCandidates(conn, iceCandidates) { + for (const c of iceCandidates) { + conn.addIceCandidate(new RTCIceCandidate(c)); + } } - -function processIceCandidates(iceCandidates) { - iceCandidates.forEach((candidate) => processIceCandidate(candidate)) +async function setUpMediaStreams(pc, localStream, remoteStream, aesKey) { + var _a; + const videos = getVideoElements(); + if (!videos) + throw Error("no video elements"); + let key; + if (aesKey) { + const keyData = decodeBase64(encodeAscii(aesKey)); + if (keyData) + key = await crypto.subtle.importKey("raw", keyData, keyAlgorithm, false, keyUsages); + } + for (const track of localStream.getTracks()) { + pc.addTrack(track, localStream); + } + if (key) { + console.log("set up encryption for sending"); + for (const sender of pc.getSenders()) { + setupPeerTransform(sender, encodeFunction(key)); + } + } + // Pull tracks from remote stream as they arrive add them to remoteStream video + pc.ontrack = (event) => { + if (key) { + console.log("set up decryption for receiving"); + setupPeerTransform(event.receiver, decodeFunction(key)); + } + for (const track of event.streams[0].getTracks()) { + remoteStream.addTrack(track); + } + }; + // We assume VP8 encoding in the decode/encode stages to get the initial + // bytes to pass as plaintext so we enforce that here. + // VP8 is supported by all supports of webrtc. + // Use of VP8 by default may also reduce depacketisation issues. + // We do not encrypt the first couple of bytes of the payload so that the + // video elements can work by determining video keyframes and the opus mode + // being used. This appears to be necessary for any video feed at all. + // For VP8 this is the content described in + // https://tools.ietf.org/html/rfc6386#section-9.1 + // which is 10 bytes for key frames and 3 bytes for delta frames. + // For opus (where encodedFrame.type is not set) this is the TOC byte from + // https://tools.ietf.org/html/rfc6716#section-3.1 + const capabilities = RTCRtpSender.getCapabilities("video"); + if (capabilities) { + const { codecs } = capabilities; + const selectedCodecIndex = codecs.findIndex((c) => c.mimeType === "video/VP8"); + const selectedCodec = codecs[selectedCodecIndex]; + codecs.splice(selectedCodecIndex, 1); + codecs.unshift(selectedCodec); + for (const t of pc.getTransceivers()) { + if (((_a = t.sender.track) === null || _a === void 0 ? void 0 : _a.kind) === "video") { + t.setCodecPreferences(codecs); + } + } + } + // setupVideoElement(videos.local) + // setupVideoElement(videos.remote) + videos.local.srcObject = localStream; + videos.remote.srcObject = remoteStream; } - -function processIceCandidate(iceCandidate) { - let candidate = new RTCIceCandidate(iceCandidate) - pc.addIceCandidate(candidate) +function callMediaContraints(mediaType) { + switch (mediaType) { + case CallMediaType.Audio: + return { audio: true, video: false }; + case CallMediaType.Video: + return { + audio: true, + video: { + frameRate: 24, + width: { + min: 480, + ideal: 720, + max: 1280, + }, + aspectRatio: 1.33, + }, + }; + } } - -async function processOffer(offer) { - // Negotiating initial connection - if (!pc.currentRemoteDescription) { - let remoteSessionDescription = new RTCSessionDescription(offer) - await pc.setRemoteDescription(remoteSessionDescription) - } +function supportsInsertableStreams() { + return ("createEncodedStreams" in RTCRtpSender.prototype) + && ("createEncodedStreams" in RTCRtpReceiver.prototype); } - -function setUpVideos(pc, localStream, remoteStream) { - localStream.getTracks().forEach((track) => { - pc.addTrack(track, localStream) - }) - pc.getSenders().forEach(setupSenderTransform) - // Pull tracks from remote stream as they arrive add them to remoteStream video - pc.ontrack = (event) => { - setupReceiverTransform(event.receiver) - event.streams[0].getTracks().forEach((track) => { - remoteStream.addTrack(track) - }) - } - // We assume VP8 encoding in the decode/encode stages to get the initial - // bytes to pass as plaintext so we enforce that here. - // VP8 is supported by all supports of webrtc. - // Use of VP8 by default may also reduce depacketisation issues. - // We do not encrypt the first couple of bytes of the payload so that the - // video elements can work by determining video keyframes and the opus mode - // being used. This appears to be necessary for any video feed at all. - // For VP8 this is the content described in - // https://tools.ietf.org/html/rfc6386#section-9.1 - // which is 10 bytes for key frames and 3 bytes for delta frames. - // For opus (where encodedFrame.type is not set) this is the TOC byte from - // https://tools.ietf.org/html/rfc6716#section-3.1 - - const {codecs} = RTCRtpSender.getCapabilities("video") - const selectedCodecIndex = codecs.findIndex((c) => c.mimeType === "video/VP8") - const selectedCodec = codecs[selectedCodecIndex] - codecs.splice(selectedCodecIndex, 1) - codecs.unshift(selectedCodec) - const transceiver = pc.getTransceivers().find((t) => t.sender && t.sender.track.kind === "video") - transceiver.setCodecPreferences(codecs) - - outgoingVideo.srcObject = localStream - incomingVideo.srcObject = remoteStream +function resetVideoElements() { + const videos = getVideoElements(); + if (!videos) + return; + videos.local.srcObject = null; + videos.remote.srcObject = null; } - -async function getLocalVideoStream() { - return await navigator.mediaDevices.getUserMedia({ - audio: true, - video: { - frameRate: 24, - width: { - min: 480, - ideal: 720, - max: 1280, - }, - aspectRatio: 1.33, - }, - }) +function getVideoElements() { + const local = document.getElementById("local-video-stream"); + const remote = document.getElementById("remote-video-stream"); + if (!(local && remote && local instanceof HTMLMediaElement && remote instanceof HTMLMediaElement)) + return; + return { local, remote }; } - -function endCall() { - pc.close() -} - -function toggleVideo(b) { - if (b == "true") { - localStream.getVideoTracks()[0].enabled = true - } else { - localStream.getVideoTracks()[0].enabled = false - } -} - +// function setupVideoElement(video: HTMLElement) { +// // TODO use display: none +// video.style.opacity = "0" +// video.onplaying = () => { +// video.style.opacity = "1" +// } +// } +// what does it do? +// function toggleVideo(b) { +// if (b == "true") { +// localStream.getVideoTracks()[0].enabled = true +// } else { +// localStream.getVideoTracks()[0].enabled = false +// } +// } function f() { - console.log("Debug Function") - return "Debugging Return" + console.log("Debug Function"); + return "Debugging Return"; } - /* Stream Transforms */ -function setupSenderTransform(sender) { - const senderStreams = sender.createEncodedStreams() - const transformStream = new TransformStream({ - transform: encodeFunction, - }) - senderStreams.readable.pipeThrough(transformStream).pipeTo(senderStreams.writable) +function setupPeerTransform(peer, transform) { + const streams = peer.createEncodedStreams(); + streams.readable.pipeThrough(new TransformStream({ transform })).pipeTo(streams.writable); } - -function setupReceiverTransform(receiver) { - const receiverStreams = receiver.createEncodedStreams() - const transformStream = new TransformStream({ - transform: decodeFunction, - }) - receiverStreams.readable.pipeThrough(transformStream).pipeTo(receiverStreams.writable) -} - /* Cryptography */ -function encodeFunction(frame, controller) { - // frame is an RTCEncodedAudioFrame - // frame.data is ArrayBuffer - let data = new Uint8Array(frame.data) - let n = frame instanceof RTCEncodedVideoFrame ? initialPlainTextRequired[frame.type] : 0 - let iv = randomIV() - let initial = data.subarray(0, n) - let plaintext = data.subarray(n, data.byteLength) - crypto.subtle - .encrypt({name: "AES-GCM", iv: iv.buffer}, key, plaintext) - .then((c) => { - frame.data = concatN(initial, new Uint8Array(c), iv).buffer - controller.enqueue(frame) - }) - .catch((e) => { - console.log("encrypt error") - endCall() - throw e - }) +function encodeFunction(key) { + return async (frame, controller) => { + const data = new Uint8Array(frame.data); + const n = frame instanceof RTCEncodedVideoFrame ? initialPlainTextRequired[frame.type] : 0; + const iv = randomIV(); + const initial = data.subarray(0, n); + const plaintext = data.subarray(n, data.byteLength); + try { + const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv.buffer }, key, plaintext); + frame.data = concatN(initial, new Uint8Array(ciphertext), iv).buffer; + controller.enqueue(frame); + } + catch (e) { + console.log(`encryption error ${e}`); + throw e; + } + }; } -function decodeFunction(frame, controller) { - let data = new Uint8Array(frame.data) - let n = frame instanceof RTCEncodedVideoFrame ? initialPlainTextRequired[frame.type] : 0 - let initial = data.subarray(0, n) - let ciphertext = data.subarray(n, data.byteLength - IV_LENGTH) - let iv = data.subarray(data.byteLength - IV_LENGTH, data.byteLength) - crypto.subtle - .decrypt({name: "AES-GCM", iv: iv}, key, ciphertext) - .then((p) => { - frame.data = concatN(initial, new Uint8Array(p)).buffer - controller.enqueue(frame) - }) - .catch((e) => { - console.log("decrypt error") - endCall() - throw e - }) +function decodeFunction(key) { + return async (frame, controller) => { + const data = new Uint8Array(frame.data); + const n = frame instanceof RTCEncodedVideoFrame ? initialPlainTextRequired[frame.type] : 0; + const initial = data.subarray(0, n); + const ciphertext = data.subarray(n, data.byteLength - IV_LENGTH); + const iv = data.subarray(data.byteLength - IV_LENGTH, data.byteLength); + try { + const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext); + frame.data = concatN(initial, new Uint8Array(plaintext)).buffer; + controller.enqueue(frame); + } + catch (e) { + console.log(`decryption error ${e}`); + throw e; + } + }; +} +class RTCEncodedVideoFrame { + constructor(type, data) { + this.type = type; + this.data = data; + } } - function randomIV() { - return crypto.getRandomValues(new Uint8Array(IV_LENGTH)) + return crypto.getRandomValues(new Uint8Array(IV_LENGTH)); } -async function loadKey(keyData) { - key = await crypto.subtle.importKey("jwk", keyData, keyGenConfig, false, keyUsages) -} - +const char_equal = "=".charCodeAt(0); function concatN(...bs) { - const a = new Uint8Array(bs.reduce((size, b) => size + b.byteLength, 0)) - bs.reduce((offset, b) => { - a.set(b, offset) - return offset + b.byteLength - }, 0) - return a + const a = new Uint8Array(bs.reduce((size, b) => size + b.byteLength, 0)); + bs.reduce((offset, b) => { + a.set(b, offset); + return offset + b.byteLength; + }, 0); + return a; } - -async function generateKey() { - crypto.subtle - .generateKey(keyGenConfig, true, keyUsages) - .then((k) => { - encryptKey = k - return crypto.subtle.exportKey("jwk", encryptKey) - }) - .then((r) => { - encryptKeyRepresentation = r - console.log( - JSON.stringify({ - action: "processDecryptionKey", - content: { - key: encryptKeyRepresentation, - iv: encryptIv, - }, - }) - ) - }) +function encodeAscii(s) { + const a = new Uint8Array(s.length); + let i = s.length; + while (i--) + a[i] = s.charCodeAt(i); + return a; } +function decodeAscii(a) { + let s = ""; + for (let i = 0; i < a.length; i++) + s += String.fromCharCode(a[i]); + return s; +} +const base64chars = new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map((c) => c.charCodeAt(0))); +const base64lookup = new Array(256); +base64chars.forEach((c, i) => (base64lookup[c] = i)); +function encodeBase64(a) { + const len = a.length; + const b64len = Math.ceil(len / 3) * 4; + const b64 = new Uint8Array(b64len); + let j = 0; + for (let i = 0; i < len; i += 3) { + b64[j++] = base64chars[a[i] >> 2]; + b64[j++] = base64chars[((a[i] & 3) << 4) | (a[i + 1] >> 4)]; + b64[j++] = base64chars[((a[i + 1] & 15) << 2) | (a[i + 2] >> 6)]; + b64[j++] = base64chars[a[i + 2] & 63]; + } + if (len % 3) + b64[b64len - 1] = char_equal; + if (len % 3 === 1) + b64[b64len - 2] = char_equal; + return b64; +} +function decodeBase64(b64) { + let len = b64.length; + if (len % 4) + return; + let bLen = (len * 3) / 4; + if (b64[len - 1] === char_equal) { + len--; + bLen--; + if (b64[len - 1] === char_equal) { + len--; + bLen--; + } + } + const bytes = new Uint8Array(bLen); + let i = 0; + let pos = 0; + while (i < len) { + const enc1 = base64lookup[b64[i++]]; + const enc2 = i < len ? base64lookup[b64[i++]] : 0; + const enc3 = i < len ? base64lookup[b64[i++]] : 0; + const enc4 = i < len ? base64lookup[b64[i++]] : 0; + if (enc1 === undefined || enc2 === undefined || enc3 === undefined || enc4 === undefined) + return; + bytes[pos++] = (enc1 << 2) | (enc2 >> 4); + bytes[pos++] = ((enc2 & 15) << 4) | (enc3 >> 2); + bytes[pos++] = ((enc3 & 3) << 6) | (enc4 & 63); + } + return bytes; +} +//# sourceMappingURL=call.js.map \ No newline at end of file diff --git a/apps/android/app/src/main/assets/www/style.css b/apps/android/app/src/main/assets/www/style.css index 89987105b4..6debc26285 100644 --- a/apps/android/app/src/main/assets/www/style.css +++ b/apps/android/app/src/main/assets/www/style.css @@ -5,14 +5,14 @@ html, body { padding: 0; margin: 0; } -#incoming-video-stream { +#remote-video-stream { position: absolute; width: 100%; height: 100%; object-fit: cover; } -#outgoing-video-stream { +#local-video-stream { position: absolute; width: 30%; max-width: 30%; diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt index 62c027194d..8895c44e40 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt @@ -58,7 +58,7 @@ fun VideoCallView(close: () -> Unit) { } val localContext = LocalContext.current val iceCandidateCommand = remember { mutableStateOf("") } - val commandToShow = remember { mutableStateOf("processCommand({action: \"initiateCall\"})") } + val commandToShow = remember { mutableStateOf("processCommand({type: 'start', media: 'video', aesKey: 'FwW+t6UbnwHoapYOfN4mUBUuqR7UtvYWxW16iBqM29U='})") } val assetLoader = WebViewAssetLoader.Builder() .addPathHandler("/assets/www/", WebViewAssetLoader.AssetsPathHandler(localContext)) .build() diff --git a/packages/simplex-chat-webrtc/.gitignore b/packages/simplex-chat-webrtc/.gitignore new file mode 100644 index 0000000000..87807d97c7 --- /dev/null +++ b/packages/simplex-chat-webrtc/.gitignore @@ -0,0 +1,3 @@ +node_modules +package-lock.json +dist diff --git a/packages/simplex-chat-webrtc/.prettierrc.json b/packages/simplex-chat-webrtc/.prettierrc.json new file mode 100644 index 0000000000..cadbab25e3 --- /dev/null +++ b/packages/simplex-chat-webrtc/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "bracketSpacing": false, + "semi": false, + "printWidth": 140 +} diff --git a/packages/simplex-chat-webrtc/package.json b/packages/simplex-chat-webrtc/package.json new file mode 100644 index 0000000000..b6f8315595 --- /dev/null +++ b/packages/simplex-chat-webrtc/package.json @@ -0,0 +1,24 @@ +{ + "name": "simplex-chat-webrtc", + "version": "0.0.1", + "description": "WebRTC call in browser and webview", + "main": "dist/call.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "SimpleX", + "WebRTC" + ], + "author": "", + "license": "AGPL-3.0-or-later", + "devDependencies": { + "husky": "^7.0.4", + "lint-staged": "^12.4.1", + "prettier": "^2.6.2", + "typescript": "^4.6.4" + }, + "lint-staged": { + "**/*": "prettier --write --ignore-unknown" + } +} diff --git a/packages/simplex-chat-webrtc/src/call.html b/packages/simplex-chat-webrtc/src/call.html new file mode 100644 index 0000000000..ebe63e1a3b --- /dev/null +++ b/packages/simplex-chat-webrtc/src/call.html @@ -0,0 +1,13 @@ + + + + + + + + + +
+ +
+ diff --git a/packages/simplex-chat-webrtc/src/call.ts b/packages/simplex-chat-webrtc/src/call.ts new file mode 100644 index 0000000000..fddab1ebc3 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/call.ts @@ -0,0 +1,584 @@ +// Inspired by +// https://github.com/webrtc/samples/blob/gh-pages/src/content/insertable-streams/endtoend-encryption + +// type WCallMessage = WCallCommand | WCallResponse + +type WCallCommand = WCCapabilities | WCStartCall | WCAcceptOffer | WCEndCall | WCallCommandResponse + +type WCallResponse = WRCapabilities | WRConnection | WRCallEnded | WROk | WRError | WCallCommandResponse + +type WCallCommandResponse = WCallOffer | WCallAnswer | WCallIceCandidates + +type WCallMessageTag = "capabilities" | "connection" | "start" | "offer" | "accept" | "answer" | "ice" | "end" | "ended" | "ok" | "error" + +enum CallMediaType { + Audio = "audio", + Video = "video", +} + +interface IWebCallMessage { + type: WCallMessageTag +} + +interface WCCapabilities extends IWebCallMessage { + type: "capabilities" +} + +interface WRConnection extends IWebCallMessage { + type: "connection", + state: { + connectionState: string + iceConnectionState: string + iceGatheringState: string + signalingState: string + } +} + +interface WCStartCall extends IWebCallMessage { + type: "start" + media: CallMediaType + aesKey?: string +} + +interface WCEndCall extends IWebCallMessage { + type: "end" +} + +interface WCAcceptOffer extends IWebCallMessage { + type: "accept" + offer: RTCSessionDescriptionInit + iceCandidates: RTCIceCandidateInit[] + media: CallMediaType + aesKey?: string +} + +interface WCallOffer extends IWebCallMessage { + type: "offer" + offer: RTCSessionDescriptionInit + iceCandidates: RTCIceCandidateInit[] +} + +interface WCallAnswer extends IWebCallMessage { + type: "answer" + answer: RTCSessionDescriptionInit + iceCandidates: RTCIceCandidateInit[] +} + +interface WCallIceCandidates extends IWebCallMessage { + type: "ice" + iceCandidates: RTCIceCandidateInit[] +} + +interface WRCapabilities { + type: "capabilities" + capabilities: CallCapabilities +} + +interface CallCapabilities { + encryption: boolean +} + +interface WRCallEnded extends IWebCallMessage { + type: "ended" +} + +interface WROk extends IWebCallMessage { + type: "ok" +} + +interface WRError extends IWebCallMessage { + type: "error" + message: string +} + +type RTCRtpSenderWithEncryption = RTCRtpSender & { + createEncodedStreams: () => TransformStream +} + +type RTCRtpReceiverWithEncryption = RTCRtpReceiver & { + createEncodedStreams: () => TransformStream +} + +type RTCConfigurationWithEncryption = RTCConfiguration & { + encodedInsertableStreams: boolean +} + +// STUN servers +const peerConnectionConfig: RTCConfigurationWithEncryption = { + iceServers: [{urls: ["stun:stun.l.google.com:19302"]}], + iceCandidatePoolSize: 10, + encodedInsertableStreams: true, +} + +const keyAlgorithm: AesKeyAlgorithm = { + name: "AES-GCM", + length: 256 +} + +const keyUsages: KeyUsage[] = ["encrypt", "decrypt"] + +let pc: RTCPeerConnection | undefined + +const IV_LENGTH = 12 + +const initialPlainTextRequired = { + key: 10, + delta: 3, + undefined: 1, +} + +interface Call { + connection: RTCPeerConnection + iceCandidates: Promise +} + +interface CallConfig { + iceCandidates: { + delay: number + extrasInterval: number + extrasTimeout: number + } +} + +const defaultCallConfig: CallConfig = { + iceCandidates: { + delay: 2000, + extrasInterval: 2000, + extrasTimeout: 8000 + } +} + +async function initializeCall(config: CallConfig, mediaType: CallMediaType, aesKey?: string): Promise { + const conn = new RTCPeerConnection(peerConnectionConfig) + const remoteStream = new MediaStream() + const localStream = await navigator.mediaDevices.getUserMedia(callMediaContraints(mediaType)) + await setUpMediaStreams(conn, localStream, remoteStream, aesKey) + conn.addEventListener("connectionstatechange", connectionStateChange) + const iceCandidates = new Promise((resolve, _) => { + let candidates: RTCIceCandidate[] = [] + let resolved = false + let extrasInterval: number | undefined + let extrasTimeout: number | undefined + const delay = setTimeout(() => { + if (!resolved) { + resolveIceCandidates() + extrasInterval = setInterval(() => { + sendIceCandidates() + }, config.iceCandidates.extrasInterval) + extrasTimeout = setTimeout(() => { + clearInterval(extrasInterval) + sendIceCandidates() + }, config.iceCandidates.extrasTimeout) + } + }, config.iceCandidates.delay) + + conn.onicecandidate = ({candidate: c}) => c && candidates.push(c) + conn.onicegatheringstatechange = () => { + if (conn.iceGatheringState == "complete") { + if (resolved) { + if (extrasInterval) clearInterval(extrasInterval) + if (extrasTimeout) clearTimeout(extrasTimeout) + sendIceCandidates() + } else { + resolveIceCandidates() + } + } + } + + function resolveIceCandidates() { + if (delay) clearTimeout(delay) + resolved = true + const iceCandidates = candidates.slice() + candidates = [] + resolve(iceCandidates) + } + + function sendIceCandidates() { + if (candidates.length === 0) return + const iceCandidates = candidates.slice() + candidates = [] + sendMessageToNative({type: "ice", iceCandidates}) + } + }) + + return {connection: conn, iceCandidates} + + function connectionStateChange() { + sendMessageToNative({ + type: "connection", + state: { + connectionState: conn.connectionState, + iceConnectionState: conn.iceConnectionState, + iceGatheringState: conn.iceGatheringState, + signalingState: conn.signalingState, + } + }) + if (conn.connectionState == "disconnected" || conn.connectionState == "failed") { + conn.removeEventListener("connectionstatechange", connectionStateChange) + sendMessageToNative({type: "ended"}) + conn.close() + pc = undefined + resetVideoElements() + } + } +} + +// TODO remove WCallCommand from parameter type +function sendMessageToNative(msg: WCallResponse | WCallCommand) { + console.log(JSON.stringify(msg)) +} + +// TODO remove WCallCommand from result type +async function processCommand(command: WCallCommand): Promise { + let resp: WCallResponse | WCallCommand + switch (command.type) { + case "capabilities": + const encryption = supportsInsertableStreams() + resp = {type: "capabilities", capabilities: {encryption}} + break + case "start": + console.log("starting call") + if (pc) { + resp = {type: "error", message: "start: call already started"} + } else if (!supportsInsertableStreams() && command.aesKey) { + resp = {type: "error", message: "start: encryption is not supported"} + } else { + try { + const {media, aesKey} = command + const call = await initializeCall(defaultCallConfig, media, aesKey) + const {connection, iceCandidates} = call + pc = connection + const offer = await pc.createOffer() + await pc.setLocalDescription(offer) + // for debugging, returning the command for callee to use + resp = {type: "accept", offer, iceCandidates: await iceCandidates, media, aesKey} + // resp = {type: "offer", offer, iceCandidates: await iceCandidates} + } catch (e) { + resp = {type: "error", message: (e as Error).message} + } + } + break + case "accept": + if (pc) { + resp = {type: "error", message: "accept: call already started"} + } else if (!supportsInsertableStreams() && command.aesKey) { + resp = {type: "error", message: "accept: encryption is not supported"} + } else { + try { + const call = await initializeCall(defaultCallConfig, command.media, command.aesKey) + const {connection, iceCandidates} = call + pc = connection + await pc.setRemoteDescription(new RTCSessionDescription(command.offer)) + const answer = await pc.createAnswer() + await pc.setLocalDescription(answer) + addIceCandidates(pc, command.iceCandidates) + // same as command for caller to use + resp = {type: "answer", answer, iceCandidates: await iceCandidates} + } catch (e) { + resp = {type: "error", message: (e as Error).message} + } + } + break + case "answer": + if (!pc) { + resp = {type: "error", message: "answer: call not started"} + } else if (!pc.localDescription) { + resp = {type: "error", message: "answer: local description is not set"} + } else if (pc.currentRemoteDescription) { + resp = {type: "error", message: "answer: remote description already set"} + } else { + await pc.setRemoteDescription(new RTCSessionDescription(command.answer)) + addIceCandidates(pc, command.iceCandidates) + resp = {type: "ok"} + } + break + case "ice": + if (pc) { + addIceCandidates(pc, command.iceCandidates) + resp = {type: "ok"} + } else { + resp = {type: "error", message: "ice: call not started"} + } + break + case "end": + if (pc) { + pc.close() + pc = undefined + resetVideoElements() + resp = {type: "ok"} + } else { + resp = {type: "error", message: "end: call not started"} + } + break + default: + resp = {type: "error", message: "unknown command"} + break + } + sendMessageToNative(resp) + return resp +} + +function addIceCandidates(conn: RTCPeerConnection, iceCandidates: RTCIceCandidateInit[]) { + for (const c of iceCandidates) { + conn.addIceCandidate(new RTCIceCandidate(c)) + } +} + +async function setUpMediaStreams(pc: RTCPeerConnection, localStream: MediaStream, remoteStream: MediaStream, aesKey?: string): Promise { + const videos = getVideoElements() + if (!videos) throw Error("no video elements") + + let key: CryptoKey | undefined + if (aesKey) { + const keyData = decodeBase64(encodeAscii(aesKey)) + if (keyData) key = await crypto.subtle.importKey("raw", keyData, keyAlgorithm, false, keyUsages) + } + for (const track of localStream.getTracks()) { + pc.addTrack(track, localStream) + } + if (key) { + console.log("set up encryption for sending") + for (const sender of pc.getSenders() as RTCRtpSenderWithEncryption[]) { + setupPeerTransform(sender, encodeFunction(key)) + } + } + // Pull tracks from remote stream as they arrive add them to remoteStream video + pc.ontrack = (event) => { + if (key) { + console.log("set up decryption for receiving") + setupPeerTransform(event.receiver as RTCRtpReceiverWithEncryption, decodeFunction(key)) + } + for (const track of event.streams[0].getTracks()) { + remoteStream.addTrack(track) + } + } + // We assume VP8 encoding in the decode/encode stages to get the initial + // bytes to pass as plaintext so we enforce that here. + // VP8 is supported by all supports of webrtc. + // Use of VP8 by default may also reduce depacketisation issues. + // We do not encrypt the first couple of bytes of the payload so that the + // video elements can work by determining video keyframes and the opus mode + // being used. This appears to be necessary for any video feed at all. + // For VP8 this is the content described in + // https://tools.ietf.org/html/rfc6386#section-9.1 + // which is 10 bytes for key frames and 3 bytes for delta frames. + // For opus (where encodedFrame.type is not set) this is the TOC byte from + // https://tools.ietf.org/html/rfc6716#section-3.1 + + const capabilities = RTCRtpSender.getCapabilities("video") + if (capabilities) { + const {codecs} = capabilities + const selectedCodecIndex = codecs.findIndex((c) => c.mimeType === "video/VP8") + const selectedCodec = codecs[selectedCodecIndex] + codecs.splice(selectedCodecIndex, 1) + codecs.unshift(selectedCodec) + for (const t of pc.getTransceivers()) { + if (t.sender.track?.kind === "video") { + t.setCodecPreferences(codecs) + } + } + } + // setupVideoElement(videos.local) + // setupVideoElement(videos.remote) + videos.local.srcObject = localStream + videos.remote.srcObject = remoteStream +} + +function callMediaContraints(mediaType: CallMediaType): MediaStreamConstraints { + switch (mediaType) { + case CallMediaType.Audio: + return {audio: true, video: false} + case CallMediaType.Video: + return { + audio: true, + video: { + frameRate: 24, + width: { + min: 480, + ideal: 720, + max: 1280, + }, + aspectRatio: 1.33, + }, + } + } +} + +function supportsInsertableStreams(): boolean { + return ("createEncodedStreams" in RTCRtpSender.prototype) + && ("createEncodedStreams" in RTCRtpReceiver.prototype) +} + +interface VideoElements { + local: HTMLMediaElement + remote: HTMLMediaElement +} + +function resetVideoElements() { + const videos = getVideoElements() + if (!videos) return + videos.local.srcObject = null + videos.remote.srcObject = null +} + +function getVideoElements(): VideoElements | undefined { + const local = document.getElementById("local-video-stream") + const remote = document.getElementById("remote-video-stream") + if (!(local && remote && local instanceof HTMLMediaElement && remote instanceof HTMLMediaElement)) return + return {local, remote} +} + +// function setupVideoElement(video: HTMLElement) { +// // TODO use display: none +// video.style.opacity = "0" +// video.onplaying = () => { +// video.style.opacity = "1" +// } +// } + +// what does it do? +// function toggleVideo(b) { +// if (b == "true") { +// localStream.getVideoTracks()[0].enabled = true +// } else { +// localStream.getVideoTracks()[0].enabled = false +// } +// } + +function f() { + console.log("Debug Function") + return "Debugging Return" +} + +/* Stream Transforms */ +function setupPeerTransform(peer: RTCRtpSenderWithEncryption | RTCRtpReceiverWithEncryption, transform: (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => void) { + const streams = peer.createEncodedStreams() + streams.readable.pipeThrough(new TransformStream({transform})).pipeTo(streams.writable) +} + +/* Cryptography */ +function encodeFunction(key: CryptoKey): (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => void { + return async (frame, controller) => { + const data = new Uint8Array(frame.data) + const n = frame instanceof RTCEncodedVideoFrame ? initialPlainTextRequired[frame.type] : 0 + const iv = randomIV() + const initial = data.subarray(0, n) + const plaintext = data.subarray(n, data.byteLength) + try { + const ciphertext = await crypto.subtle.encrypt({name: "AES-GCM", iv: iv.buffer}, key, plaintext) + frame.data = concatN(initial, new Uint8Array(ciphertext), iv).buffer + controller.enqueue(frame) + } catch (e) { + console.log(`encryption error ${e}`) + throw e + } + } +} + +function decodeFunction(key: CryptoKey): (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => Promise { + return async (frame, controller) => { + const data = new Uint8Array(frame.data) + const n = frame instanceof RTCEncodedVideoFrame ? initialPlainTextRequired[frame.type] : 0 + const initial = data.subarray(0, n) + const ciphertext = data.subarray(n, data.byteLength - IV_LENGTH) + const iv = data.subarray(data.byteLength - IV_LENGTH, data.byteLength) + try { + const plaintext = await crypto.subtle.decrypt({name: "AES-GCM", iv}, key, ciphertext) + frame.data = concatN(initial, new Uint8Array(plaintext)).buffer + controller.enqueue(frame) + } catch (e) { + console.log(`decryption error ${e}`) + throw e + } + } +} + +class RTCEncodedVideoFrame { + constructor(public type: "key" | "delta", public data: ArrayBuffer) {} +} + +function randomIV() { + return crypto.getRandomValues(new Uint8Array(IV_LENGTH)) +} + +const char_equal = "=".charCodeAt(0) + +function concatN(...bs: Uint8Array[]): Uint8Array { + const a = new Uint8Array(bs.reduce((size, b) => size + b.byteLength, 0)) + bs.reduce((offset, b: Uint8Array) => { + a.set(b, offset) + return offset + b.byteLength + }, 0) + return a +} + +function encodeAscii(s: string): Uint8Array { + const a = new Uint8Array(s.length) + let i = s.length + while (i--) a[i] = s.charCodeAt(i) + return a +} + +function decodeAscii(a: Uint8Array): string { + let s = "" + for (let i = 0; i < a.length; i++) s += String.fromCharCode(a[i]) + return s +} + +const base64chars = new Uint8Array( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map((c) => c.charCodeAt(0)) +) + +const base64lookup = new Array(256) as (number | undefined)[] +base64chars.forEach((c, i) => (base64lookup[c] = i)) + +function encodeBase64(a: Uint8Array): Uint8Array { + const len = a.length + const b64len = Math.ceil(len / 3) * 4 + const b64 = new Uint8Array(b64len) + + let j = 0 + for (let i = 0; i < len; i += 3) { + b64[j++] = base64chars[a[i] >> 2] + b64[j++] = base64chars[((a[i] & 3) << 4) | (a[i + 1] >> 4)] + b64[j++] = base64chars[((a[i + 1] & 15) << 2) | (a[i + 2] >> 6)] + b64[j++] = base64chars[a[i + 2] & 63] + } + + if (len % 3) b64[b64len - 1] = char_equal + if (len % 3 === 1) b64[b64len - 2] = char_equal + + return b64 +} + +function decodeBase64(b64: Uint8Array): Uint8Array | undefined { + let len = b64.length + if (len % 4) return + let bLen = (len * 3) / 4 + + if (b64[len - 1] === char_equal) { + len-- + bLen-- + if (b64[len - 1] === char_equal) { + len-- + bLen-- + } + } + + const bytes = new Uint8Array(bLen) + + let i = 0 + let pos = 0 + while (i < len) { + const enc1 = base64lookup[b64[i++]] + const enc2 = i < len ? base64lookup[b64[i++]] : 0 + const enc3 = i < len ? base64lookup[b64[i++]] : 0 + const enc4 = i < len ? base64lookup[b64[i++]] : 0 + if (enc1 === undefined || enc2 === undefined || enc3 === undefined || enc4 === undefined) return + bytes[pos++] = (enc1 << 2) | (enc2 >> 4) + bytes[pos++] = ((enc2 & 15) << 4) | (enc3 >> 2) + bytes[pos++] = ((enc3 & 3) << 6) | (enc4 & 63) + } + + return bytes +} diff --git a/packages/simplex-chat-webrtc/src/style.css b/packages/simplex-chat-webrtc/src/style.css new file mode 100644 index 0000000000..6debc26285 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/style.css @@ -0,0 +1,24 @@ +video::-webkit-media-controls { + display: none; +} +html, body { + padding: 0; + margin: 0; +} +#remote-video-stream { + position: absolute; + width: 100%; + height: 100%; + object-fit: cover; +} + +#local-video-stream { + position: absolute; + width: 30%; + max-width: 30%; + object-fit: cover; + margin: 16px; + border-radius: 16px; + bottom: 0; + right: 0; +} diff --git a/packages/simplex-chat-webrtc/tsconfig.json b/packages/simplex-chat-webrtc/tsconfig.json new file mode 100644 index 0000000000..d4d0acb3d0 --- /dev/null +++ b/packages/simplex-chat-webrtc/tsconfig.json @@ -0,0 +1,21 @@ +{ + "include": ["src"], + "compilerOptions": { + "declaration": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ES2018", "DOM"], + "module": "CommonJS", + "moduleResolution": "Node", + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "outDir": "dist", + "sourceMap": true, + "strict": true, + "strictNullChecks": true, + "target": "ES2018" + } +} From cdb919db967619f27eddccf3d0e1b93746e6e9a0 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 2 May 2022 17:06:49 +0100 Subject: [PATCH 43/95] core: webrtc calls api types (#590) * core: webrtc calls api * call: call state, chat items, update commands/responses * update sequence diagram * remove CRCallInvitationSent, add CISCallError --- docs/rfcs/webrtc.mmd | 67 +++++++++++++ simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 15 ++- src/Simplex/Chat/Call.hs | 170 +++++++++++++++++++++++++++++++++ src/Simplex/Chat/Controller.hs | 13 +++ src/Simplex/Chat/Messages.hs | 50 +++++++++- src/Simplex/Chat/Protocol.hs | 36 +++++++ src/Simplex/Chat/View.hs | 9 ++ 8 files changed, 357 insertions(+), 4 deletions(-) create mode 100644 docs/rfcs/webrtc.mmd create mode 100644 src/Simplex/Chat/Call.hs diff --git a/docs/rfcs/webrtc.mmd b/docs/rfcs/webrtc.mmd new file mode 100644 index 0000000000..4da2841e33 --- /dev/null +++ b/docs/rfcs/webrtc.mmd @@ -0,0 +1,67 @@ +sequenceDiagram + participant AW as Alice's
web view + participant AN as Alice's
app native + participant AC as Alice's
chat core + participant BC as Bob's
chat core + participant BN as Bob's
app native + participant BW as Bob's
web view + + note over AW, AC: Alice's app + note over BC, BW: Bob's app + + note over AW, BW: 1. Establishing call + + note over AN: user: start call + AN ->> AW: WCCapabilities + AW ->> AN: WRCapabilities (e2e?) + + AN ->> AC: APISendCallInvitation + AC -->> BC: XCallInv + AC ->> AN: CRCmdOk + + BC ->> BN: CRCallInvitation + note over BN: show: accept call? + + alt user accepted? + BN ->> BC: no: APIRejectCall
(sender not notified) + BC ->> BN: CRCmdOk + else + BN ->> BW: yes: WCStartCall + BW ->> BN: WCallOffer + end + + BN ->> BC: APISendCallOffer + BC -->> AC: XCallOffer + BC ->> BN: CRCmdOk + AC ->> AN: CRCallOffer + note over AN: show if no e2e: continue call? + + AN ->> AW: WCallOffer + AW ->> AN: WCallAnswer + AN ->> AC: APISendCallAnswer + AC -->> BC: XCallAnswer + AC ->> AN: CRCmdOk + BC ->> BN: CRCallAnswer + BN ->> BW: WCallAnswer + + note over AW, BW: call can be established at this point + + note over AW, BW: 2. Sending additional ice candidates
(optional, same for another side): + + BW ->> BN: WCallICE + BN ->> BC: APISendCallExtraInfo + BC -->> AC: XCallExtra + BC ->> BN: CRCmdOk + AC ->> AN: CRCallExtraInfo + AN ->> AW: WCallICE + + note over AW, BW: 3. Call termination (same for another party): + + note over AN: user: end call + AN ->> AW: WEndCall + AN ->> AC: APIEndCall + AC -->> BC: XCallEnd + AC ->> AN: CRCmdOk + BC ->> BN: CRCallEnded + note over BN: show: call ended + BN ->> BW: WEndCall diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 92baa1eb24..87d37efeff 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -21,6 +21,7 @@ library exposed-modules: Simplex.Chat Simplex.Chat.Bot + Simplex.Chat.Call Simplex.Chat.Controller Simplex.Chat.Core Simplex.Chat.Help diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 49963a22de..721c0f0add 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -123,8 +123,9 @@ newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize} Ch chatLock <- newTMVarIO () sndFiles <- newTVarIO M.empty rcvFiles <- newTVarIO M.empty + currentCall <- newTVarIO Nothing filesFolder <- newTVarIO Nothing - pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, config, sendNotification, filesFolder} + pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCall, config, sendNotification, filesFolder} where resolveServers :: IO (NonEmpty SMPServer) resolveServers = case user of @@ -388,6 +389,12 @@ processChatCommand = \case `E.finally` deleteContactRequest st userId connReqId withAgent $ \a -> rejectContact a connId invId pure $ CRContactRequestRejected cReq + APISendCallInvitation _contactId _callType -> pure $ chatCmdError "not implemented" + APIRejectCall _contactId -> pure $ chatCmdError "not implemented" + APISendCallOffer _contactId _wCallOffer -> pure $ chatCmdError "not implemented" + APISendCallAnswer _contactId _rtcSession -> pure $ chatCmdError "not implemented" + APISendCallExtraInfo _contactId _rtcExtraInfo -> pure $ chatCmdError "not implemented" + APIEndCall _contactId -> pure $ chatCmdError "not implemented" APIUpdateProfile profile -> withUser (`updateProfile` profile) APIParseMarkdown text -> pure . CRApiParsedMarkdown $ parseMaybeMarkdownList text APIRegisterToken token -> CRNtfTokenStatus <$> withUser (\_ -> withAgent (`registerNtfToken` token)) @@ -1881,6 +1888,12 @@ chatCommandP = <|> "/_delete " *> (APIDeleteChat <$> chatRefP) <|> "/_accept " *> (APIAcceptContact <$> A.decimal) <|> "/_reject " *> (APIRejectContact <$> A.decimal) + <|> "/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP) + <|> "/_call reject @" *> (APIRejectCall <$> A.decimal) + <|> "/_call offer @" *> (APISendCallOffer <$> A.decimal <* A.space <*> jsonP) + <|> "/_call answer @" *> (APISendCallAnswer <$> A.decimal <* A.space <*> jsonP) + <|> "/_call extra @" *> (APISendCallExtraInfo <$> A.decimal <* A.space <*> jsonP) + <|> "/_call end @" *> (APIEndCall <$> A.decimal) <|> "/_profile " *> (APIUpdateProfile <$> jsonP) <|> "/_parse " *> (APIParseMarkdown . safeDecodeUtf8 <$> A.takeByteString) <|> "/_ntf register " *> (APIRegisterToken <$> tokenP) diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs new file mode 100644 index 0000000000..9461ff505f --- /dev/null +++ b/src/Simplex/Chat/Call.hs @@ -0,0 +1,170 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.Call where + +import Data.Aeson (FromJSON, ToJSON) +import qualified Data.Aeson as J +import Data.ByteString.Char8 (ByteString) +import Data.Int (Int64) +import GHC.Generics (Generic) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (dropPrefix, enumJSON) + +data Call = Call + { contactId :: Int64, + callId :: CallId, + chatItemId :: Int64, + callState :: CallState + } + +data CallState + = CallInvitationSent + { localCallType :: CallType, + localDhPrivKey :: Maybe C.PrivateKeyX25519 + } + | CallInvitationReceived + { peerCallType :: CallType, + localDhPubKey :: Maybe C.PublicKeyX25519, + sharedKey :: Maybe C.Key + } + | CallOfferSent + { localCallType :: CallType, + peerCallType :: CallType, + localCallSession :: WebRTCSession, + sharedKey :: Maybe C.Key + } + | CallOfferReceived + { localCallType :: CallType, + peerCallType :: CallType, + peerCallSession :: WebRTCSession, + sharedKey :: Maybe C.Key + } + | CallNegotiated + { localCallType :: CallType, + peerCallType :: CallType, + localCallSession :: WebRTCSession, + peerCallSession :: WebRTCSession, + sharedKey :: Maybe C.Key + } + +newtype CallId = CallId ByteString + deriving (Eq, Show) + +instance StrEncoding CallId where + strEncode (CallId m) = strEncode m + strDecode s = CallId <$> strDecode s + strP = CallId <$> strP + +instance FromJSON CallId where + parseJSON = strParseJSON "CallId" + +instance ToJSON CallId where + toJSON = strToJSON + toEncoding = strToJEncoding + +data CallType = CallType + { media :: CallMedia, + capabilities :: CallCapabilities + } + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON CallType where toEncoding = J.genericToEncoding J.defaultOptions + +-- | * Types for chat protocol +data CallInvitation = CallInvitation + { callType :: CallType, + callDhPubKey :: Maybe C.PublicKeyX25519 + } + deriving (Eq, Show, Generic) + +instance FromJSON CallInvitation where + parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} + +instance ToJSON CallInvitation where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + +data CallMedia = CMAudio | CMVideo + deriving (Eq, Show, Generic) + +instance FromJSON CallMedia where + parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "CM" + +instance ToJSON CallMedia where + toJSON = J.genericToJSON . enumJSON $ dropPrefix "CM" + toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CM" + +data CallCapabilities = CallCapabilities + { encryption :: Bool + } + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON CallCapabilities where + toJSON = J.genericToJSON J.defaultOptions + toEncoding = J.genericToEncoding J.defaultOptions + +data CallOffer = CallOffer + { callType :: CallType, + rtcSession :: WebRTCSession, + callDhPubKey :: Maybe C.PublicKeyX25519 + } + deriving (Eq, Show, Generic) + +instance FromJSON CallOffer where + parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} + +instance ToJSON CallOffer where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + +data WebRTCCallOffer = WebRTCCallOffer + { callType :: CallType, + rtcSession :: WebRTCSession + } + deriving (Eq, Show, Generic) + +instance FromJSON WebRTCCallOffer where + parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} + +data CallAnswer = CallAnswer + { rtcSession :: WebRTCSession + } + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON CallAnswer where + toJSON = J.genericToJSON J.defaultOptions + toEncoding = J.genericToEncoding J.defaultOptions + +data CallExtraInfo = CallExtraInfo + { rtcExtraInfo :: WebRTCExtraInfo + } + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON CallExtraInfo where + toJSON = J.genericToJSON J.defaultOptions + toEncoding = J.genericToEncoding J.defaultOptions + +data WebRTCSession = WebRTCSession + { rtcSession :: J.Value, + rtcIceCandidates :: [J.Value] + } + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON WebRTCSession where + toJSON = J.genericToJSON J.defaultOptions + toEncoding = J.genericToEncoding J.defaultOptions + +data WebRTCExtraInfo = WebRTCExtraInfo + { rtcIceCandidates :: [J.Value] + } + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON WebRTCExtraInfo where + toJSON = J.genericToJSON J.defaultOptions + toEncoding = J.genericToEncoding J.defaultOptions diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 4c0c667c02..1339256d1d 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -26,6 +26,7 @@ import Data.Word (Word16) import GHC.Generics (Generic) import Numeric.Natural import qualified Paths_simplex_chat as SC +import Simplex.Chat.Call import Simplex.Chat.Markdown (MarkdownList) import Simplex.Chat.Messages import Simplex.Chat.Protocol @@ -80,6 +81,7 @@ data ChatController = ChatController chatLock :: TMVar (), sndFiles :: TVar (Map Int64 Handle), rcvFiles :: TVar (Map Int64 Handle), + currentCall :: TVar (Maybe Call), config :: ChatConfig, filesFolder :: TVar (Maybe FilePath) -- path to files folder for mobile apps } @@ -108,6 +110,12 @@ data ChatCommand | APIDeleteChat ChatRef | APIAcceptContact Int64 | APIRejectContact Int64 + | APISendCallInvitation Int64 CallType + | APIRejectCall Int64 + | APISendCallOffer Int64 WebRTCCallOffer + | APISendCallAnswer Int64 WebRTCSession + | APISendCallExtraInfo Int64 WebRTCExtraInfo + | APIEndCall Int64 | APIUpdateProfile Profile | APIParseMarkdown Text | APIRegisterToken DeviceToken @@ -240,6 +248,11 @@ data ChatResponse | CRPendingSubSummary {pendingSubStatus :: [PendingSubStatus]} | CRSndFileSubError {sndFileTransfer :: SndFileTransfer, chatError :: ChatError} | CRRcvFileSubError {rcvFileTransfer :: RcvFileTransfer, chatError :: ChatError} + | CRCallInvitation {contact :: Contact, callType :: CallType, encryptionKey :: Maybe C.Key} + | CRCallOffer {contact :: Contact, callType :: CallType, offer :: WebRTCSession, encryptionKey :: Maybe C.Key, askConfirmation :: Bool} + | CRCallAnswer {contact :: Contact, answer :: WebRTCSession} + | CRCallExtraInfo {contact :: Contact, extraInfo :: WebRTCExtraInfo} + | CRCallEnded {contact :: Contact} | CRUserContactLinkSubscribed | CRUserContactLinkSubError {chatError :: ChatError} | CRNtfTokenStatus {status :: NtfTknStatus} diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index a54e779fb2..db8efceac3 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -20,6 +20,7 @@ import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Lazy.Char8 as LB import Data.Int (Int64) import Data.Text (Text) +import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time.Clock (UTCTime, diffUTCTime, nominalDay) import Data.Time.LocalTime (TimeZone, ZonedTime, utcToZonedTime) @@ -439,6 +440,8 @@ data CIContent (d :: MsgDirection) where CIRcvMsgContent :: MsgContent -> CIContent 'MDRcv CISndDeleted :: CIDeleteMode -> CIContent 'MDSnd CIRcvDeleted :: CIDeleteMode -> CIContent 'MDRcv + CISndCall :: CICallStatus -> Int -> CIContent 'MDSnd + CIRcvCall :: CICallStatus -> Int -> CIContent 'MDRcv deriving instance Show (CIContent d) @@ -448,6 +451,8 @@ ciContentToText = \case CIRcvMsgContent mc -> msgContentText mc CISndDeleted cidm -> ciDeleteModeToText cidm CIRcvDeleted cidm -> ciDeleteModeToText cidm + CISndCall status duration -> "outgoing call: " <> ciCallInfoText status duration + CIRcvCall status duration -> "incoming call: " <> ciCallInfoText status duration msgDirToDeletedContent_ :: SMsgDirection d -> CIDeleteMode -> CIContent d msgDirToDeletedContent_ msgDir mode = case msgDir of @@ -480,6 +485,8 @@ data JSONCIContent | JCIRcvMsgContent {msgContent :: MsgContent} | JCISndDeleted {deleteMode :: CIDeleteMode} | JCIRcvDeleted {deleteMode :: CIDeleteMode} + | JCISndCall {status :: CICallStatus, duration :: Int} -- duration in seconds + | JCIRcvCall {status :: CICallStatus, duration :: Int} deriving (Generic) instance FromJSON JSONCIContent where @@ -495,6 +502,8 @@ jsonCIContent = \case CIRcvMsgContent mc -> JCIRcvMsgContent mc CISndDeleted cidm -> JCISndDeleted cidm CIRcvDeleted cidm -> JCIRcvDeleted cidm + CISndCall status duration -> JCISndCall {status, duration} + CIRcvCall status duration -> JCIRcvCall {status, duration} aciContentJSON :: JSONCIContent -> ACIContent aciContentJSON = \case @@ -502,6 +511,8 @@ aciContentJSON = \case JCIRcvMsgContent mc -> ACIContent SMDRcv $ CIRcvMsgContent mc JCISndDeleted cidm -> ACIContent SMDSnd $ CISndDeleted cidm JCIRcvDeleted cidm -> ACIContent SMDRcv $ CIRcvDeleted cidm + JCISndCall {status, duration} -> ACIContent SMDSnd $ CISndCall status duration + JCIRcvCall {status, duration} -> ACIContent SMDRcv $ CIRcvCall status duration -- platform independent data DBJSONCIContent @@ -509,6 +520,8 @@ data DBJSONCIContent | DBJCIRcvMsgContent {msgContent :: MsgContent} | DBJCISndDeleted {deleteMode :: CIDeleteMode} | DBJCIRcvDeleted {deleteMode :: CIDeleteMode} + | DBJCISndCall {status :: CICallStatus, duration :: Int} + | DBJCIRcvCall {status :: CICallStatus, duration :: Int} deriving (Generic) instance FromJSON DBJSONCIContent where @@ -524,6 +537,8 @@ dbJsonCIContent = \case CIRcvMsgContent mc -> DBJCIRcvMsgContent mc CISndDeleted cidm -> DBJCISndDeleted cidm CIRcvDeleted cidm -> DBJCIRcvDeleted cidm + CISndCall status duration -> DBJCISndCall {status, duration} + CIRcvCall status duration -> DBJCIRcvCall {status, duration} aciContentDBJSON :: DBJSONCIContent -> ACIContent aciContentDBJSON = \case @@ -531,6 +546,35 @@ aciContentDBJSON = \case DBJCIRcvMsgContent mc -> ACIContent SMDRcv $ CIRcvMsgContent mc DBJCISndDeleted cidm -> ACIContent SMDSnd $ CISndDeleted cidm DBJCIRcvDeleted cidm -> ACIContent SMDRcv $ CIRcvDeleted cidm + DBJCISndCall {status, duration} -> ACIContent SMDSnd $ CISndCall status duration + DBJCIRcvCall {status, duration} -> ACIContent SMDRcv $ CIRcvCall status duration + +data CICallStatus + = CISCallPending + | CISCallMissed + | CISCallRejected -- only possible for received calls, not on type level + | CISCallProgress + | CISCallEnded + | CISCallError + deriving (Show, Generic) + +instance FromJSON CICallStatus where + parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "CISCall" + +instance ToJSON CICallStatus where + toJSON = J.genericToJSON . enumJSON $ dropPrefix "CISCall" + toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CISCall" + +ciCallInfoText :: CICallStatus -> Int -> Text +ciCallInfoText status duration = case status of + CISCallPending -> "calling..." + CISCallMissed -> "missed" + CISCallRejected -> "rejected" + CISCallProgress -> "in progress " <> d + CISCallEnded -> "ended " <> d + CISCallError -> "error" + where + d = let (mins, secs) = duration `divMod` 60 in T.pack $ "(" <> show mins <> ":" <> show secs <> ")" data SChatType (c :: ChatType) where SCTDirect :: SChatType 'CTDirect @@ -548,11 +592,11 @@ instance TestEquality SChatType where testEquality _ _ = Nothing class ChatTypeI (c :: ChatType) where - chatType :: SChatType c + chatTypeI :: SChatType c -instance ChatTypeI 'CTDirect where chatType = SCTDirect +instance ChatTypeI 'CTDirect where chatTypeI = SCTDirect -instance ChatTypeI 'CTGroup where chatType = SCTGroup +instance ChatTypeI 'CTGroup where chatTypeI = SCTGroup data NewMessage = NewMessage { chatMsgEvent :: ChatMsgEvent, diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 928e4dca98..6a61f96691 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -29,6 +29,7 @@ import Data.Time.Clock (UTCTime) import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) +import Simplex.Chat.Call import Simplex.Chat.Types import Simplex.Chat.Util (eitherToMaybe, safeDecodeUtf8) import Simplex.Messaging.Encoding.String @@ -132,6 +133,11 @@ data ChatMsgEvent | XInfoProbe Probe | XInfoProbeCheck ProbeHash | XInfoProbeOk Probe + | XCallInv CallId CallInvitation + | XCallOffer CallId CallOffer + | XCallAnswer CallId CallAnswer + | XCallExtra CallId CallExtraInfo + | XCallEnd CallId | XOk | XUnknown {event :: Text, params :: J.Object} deriving (Eq, Show) @@ -306,6 +312,11 @@ data CMEventTag | XInfoProbe_ | XInfoProbeCheck_ | XInfoProbeOk_ + | XCallInv_ + | XCallOffer_ + | XCallAnswer_ + | XCallExtra_ + | XCallEnd_ | XOk_ | XUnknown_ Text deriving (Eq, Show) @@ -336,6 +347,11 @@ instance StrEncoding CMEventTag where XInfoProbe_ -> "x.info.probe" XInfoProbeCheck_ -> "x.info.probe.check" XInfoProbeOk_ -> "x.info.probe.ok" + XCallInv_ -> "x.call.inv" + XCallOffer_ -> "x.call.offer" + XCallAnswer_ -> "x.call.answer" + XCallExtra_ -> "x.call.extra" + XCallEnd_ -> "x.call.end" XOk_ -> "x.ok" XUnknown_ t -> encodeUtf8 t strDecode = \case @@ -363,6 +379,11 @@ instance StrEncoding CMEventTag where "x.info.probe" -> Right XInfoProbe_ "x.info.probe.check" -> Right XInfoProbeCheck_ "x.info.probe.ok" -> Right XInfoProbeOk_ + "x.call.inv" -> Right XCallInv_ + "x.call.offer" -> Right XCallOffer_ + "x.call.answer" -> Right XCallAnswer_ + "x.call.extra" -> Right XCallExtra_ + "x.call.end" -> Right XCallEnd_ "x.ok" -> Right XOk_ t -> Right . XUnknown_ $ safeDecodeUtf8 t strP = strDecode <$?> A.takeTill (== ' ') @@ -393,6 +414,11 @@ toCMEventTag = \case XInfoProbe _ -> XInfoProbe_ XInfoProbeCheck _ -> XInfoProbeCheck_ XInfoProbeOk _ -> XInfoProbeOk_ + XCallInv _ _ -> XCallInv_ + XCallOffer _ _ -> XCallOffer_ + XCallAnswer _ _ -> XCallAnswer_ + XCallExtra _ _ -> XCallExtra_ + XCallEnd _ -> XCallEnd_ XOk -> XOk_ XUnknown t _ -> XUnknown_ t @@ -441,6 +467,11 @@ appToChatMessage AppMessage {msgId, event, params} = do XInfoProbe_ -> XInfoProbe <$> p "probe" XInfoProbeCheck_ -> XInfoProbeCheck <$> p "probeHash" XInfoProbeOk_ -> XInfoProbeOk <$> p "probe" + XCallInv_ -> XCallInv <$> p "callId" <*> p "invitation" + XCallOffer_ -> XCallOffer <$> p "callId" <*> p "offer" + XCallAnswer_ -> XCallAnswer <$> p "callId" <*> p "answer" + XCallExtra_ -> XCallExtra <$> p "callId" <*> p "extra" + XCallEnd_ -> XCallEnd <$> p "callId" XOk_ -> pure XOk XUnknown_ t -> pure $ XUnknown t params @@ -476,6 +507,11 @@ chatToAppMessage ChatMessage {msgId, chatMsgEvent} = AppMessage {msgId, event, p XInfoProbe probe -> o ["probe" .= probe] XInfoProbeCheck probeHash -> o ["probeHash" .= probeHash] XInfoProbeOk probe -> o ["probe" .= probe] + XCallInv callId inv -> o ["callId" .= callId, "invitation" .= inv] + XCallOffer callId offer -> o ["callId" .= callId, "offer" .= offer] + XCallAnswer callId answer -> o ["callId" .= callId, "answer" .= answer] + XCallExtra callId extra -> o ["callId" .= callId, "extra" .= extra] + XCallEnd callId -> o ["callId" .= callId] XOk -> JM.empty XUnknown _ ps -> ps diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index fe489e2c72..080c25b4f5 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -139,6 +139,11 @@ responseToView testView = \case ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] CRRcvFileSubError RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e -> ["received file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] + CRCallInvitation {} -> [] + CRCallOffer {} -> [] + CRCallAnswer {} -> [] + CRCallExtraInfo {} -> [] + CRCallEnded {} -> [] CRUserContactLinkSubscribed -> ["Your address is active! To show: " <> highlight' "/sa"] CRUserContactLinkSubError e -> ["user address error: " <> sShow e, "to delete your address: " <> highlight' "/da"] CRNewContactConnection _ -> [] @@ -185,11 +190,13 @@ viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} = case cha CIDirectSnd -> case content of CISndMsgContent mc -> withSndFile to $ sndMsg to quote mc CISndDeleted _ -> [] + CISndCall {} -> [] where to = ttyToContact' c CIDirectRcv -> case content of CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc CIRcvDeleted _ -> [] + CIRcvCall {} -> [] where from = ttyFromContact' c where @@ -198,11 +205,13 @@ viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} = case cha CIGroupSnd -> case content of CISndMsgContent mc -> withSndFile to $ sndMsg to quote mc CISndDeleted _ -> [] + CISndCall {} -> [] where to = ttyToGroup g CIGroupRcv m -> case content of CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc CIRcvDeleted _ -> [] + CIRcvCall {} -> [] where from = ttyFromGroup' g m where From 3519032784cb95ac9f7d371151cc58df9c6a838c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 3 May 2022 08:20:19 +0100 Subject: [PATCH 44/95] ios: notifications service extension - refactor model and API (#578) * ios: notifications service extension * create notifications in NSE (WIP) * refactor notifications to use in NSE * prepend team ID to shared defaults name to silence the warning * remove whitespace --- apps/ios/Shared/Model/ChatModel.swift | 777 +---------------- apps/ios/Shared/Model/NtfManager.swift | 70 +- apps/ios/Shared/Model/Shared/API.swift | 66 ++ apps/ios/Shared/Model/Shared/APITypes.swift | 451 ++++++++++ apps/ios/Shared/Model/Shared/ChatTypes.swift | 783 ++++++++++++++++++ .../Shared/Model/Shared/GroupDefaults.swift | 30 + apps/ios/Shared/Model/{ => Shared}/JSON.swift | 0 .../Shared/Model/Shared/Notifications.swift | 81 ++ apps/ios/Shared/Model/SimpleXAPI.swift | 506 +---------- apps/ios/Shared/SimpleXApp.swift | 2 + .../Views/Chat/ChatItem/FramedItemView.swift | 2 +- .../Views/ChatList/ChatPreviewView.swift | 2 +- .../ChatList/ContactConnectionView.swift | 2 +- .../Views/ChatList/ContactRequestView.swift | 2 +- apps/ios/SimpleX (iOS).entitlements | 4 + apps/ios/SimpleX NSE/Info.plist | 13 + .../ios/SimpleX NSE/NotificationService.swift | 132 +++ .../SimpleX NSE/SimpleX NSE-Bridging-Header.h | 11 + apps/ios/SimpleX NSE/SimpleX NSE.entitlements | 10 + apps/ios/SimpleX NSE/dummy.m | 24 + apps/ios/SimpleX.xcodeproj/project.pbxproj | 259 +++++- 21 files changed, 1874 insertions(+), 1353 deletions(-) create mode 100644 apps/ios/Shared/Model/Shared/API.swift create mode 100644 apps/ios/Shared/Model/Shared/APITypes.swift create mode 100644 apps/ios/Shared/Model/Shared/ChatTypes.swift create mode 100644 apps/ios/Shared/Model/Shared/GroupDefaults.swift rename apps/ios/Shared/Model/{ => Shared}/JSON.swift (100%) create mode 100644 apps/ios/Shared/Model/Shared/Notifications.swift create mode 100644 apps/ios/SimpleX NSE/Info.plist create mode 100644 apps/ios/SimpleX NSE/NotificationService.swift create mode 100644 apps/ios/SimpleX NSE/SimpleX NSE-Bridging-Header.h create mode 100644 apps/ios/SimpleX NSE/SimpleX NSE.entitlements create mode 100644 apps/ios/SimpleX NSE/dummy.m diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 4e21969b70..3e5fde0aa0 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -110,7 +110,7 @@ final class ChatModel: ObservableObject { if case .rcvNew = cItem.meta.itemStatus { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { if self.chatId == cInfo.id { - Task { await SimpleX.markChatItemRead(cInfo, cItem) } + Task { await apiMarkChatItemRead(cInfo, cItem) } } } } @@ -216,178 +216,6 @@ final class ChatModel: ObservableObject { } } -struct User: Decodable, NamedChat { - var userId: Int64 - var userContactId: Int64 - var localDisplayName: ContactName - var profile: Profile - var activeUser: Bool - - var displayName: String { get { profile.displayName } } - var fullName: String { get { profile.fullName } } - var image: String? { get { profile.image } } - - static let sampleData = User( - userId: 1, - userContactId: 1, - localDisplayName: "alice", - profile: Profile.sampleData, - activeUser: true - ) -} - -typealias ContactName = String - -typealias GroupName = String - -struct Profile: Codable, NamedChat { - var displayName: String - var fullName: String - var image: String? - - static let sampleData = Profile( - displayName: "alice", - fullName: "Alice" - ) -} - -enum ChatType: String { - case direct = "@" - case group = "#" - case contactRequest = "<@" - case contactConnection = ":" -} - -protocol NamedChat { - var displayName: String { get } - var fullName: String { get } - var image: String? { get } -} - -extension NamedChat { - var chatViewName: String { - get { displayName + (fullName == "" || fullName == displayName ? "" : " / \(fullName)") } - } -} - -typealias ChatId = String - -enum ChatInfo: Identifiable, Decodable, NamedChat { - case direct(contact: Contact) - case group(groupInfo: GroupInfo) - case contactRequest(contactRequest: UserContactRequest) - case contactConnection(contactConnection: PendingContactConnection) - - var localDisplayName: String { - get { - switch self { - case let .direct(contact): return contact.localDisplayName - case let .group(groupInfo): return groupInfo.localDisplayName - case let .contactRequest(contactRequest): return contactRequest.localDisplayName - case let .contactConnection(contactConnection): return contactConnection.localDisplayName - } - } - } - - var displayName: String { - get { - switch self { - case let .direct(contact): return contact.displayName - case let .group(groupInfo): return groupInfo.displayName - case let .contactRequest(contactRequest): return contactRequest.displayName - case let .contactConnection(contactConnection): return contactConnection.displayName - } - } - } - - var fullName: String { - get { - switch self { - case let .direct(contact): return contact.fullName - case let .group(groupInfo): return groupInfo.fullName - case let .contactRequest(contactRequest): return contactRequest.fullName - case let .contactConnection(contactConnection): return contactConnection.fullName - } - } - } - - var image: String? { - get { - switch self { - case let .direct(contact): return contact.image - case let .group(groupInfo): return groupInfo.image - case let .contactRequest(contactRequest): return contactRequest.image - case let .contactConnection(contactConnection): return contactConnection.image - } - } - } - - var id: ChatId { - get { - switch self { - case let .direct(contact): return contact.id - case let .group(groupInfo): return groupInfo.id - case let .contactRequest(contactRequest): return contactRequest.id - case let .contactConnection(contactConnection): return contactConnection.id - } - } - } - - var chatType: ChatType { - get { - switch self { - case .direct: return .direct - case .group: return .group - case .contactRequest: return .contactRequest - case .contactConnection: return .contactConnection - } - } - } - - var apiId: Int64 { - get { - switch self { - case let .direct(contact): return contact.apiId - case let .group(groupInfo): return groupInfo.apiId - case let .contactRequest(contactRequest): return contactRequest.apiId - case let .contactConnection(contactConnection): return contactConnection.apiId - } - } - } - - var ready: Bool { - get { - switch self { - case let .direct(contact): return contact.ready - case let .group(groupInfo): return groupInfo.ready - case let .contactRequest(contactRequest): return contactRequest.ready - case let .contactConnection(contactConnection): return contactConnection.ready - } - } - } - - var createdAt: Date { - switch self { - case let .direct(contact): return contact.createdAt - case let .group(groupInfo): return groupInfo.createdAt - case let .contactRequest(contactRequest): return contactRequest.createdAt - case let .contactConnection(contactConnection): return contactConnection.createdAt - } - } - - struct SampleData { - var direct: ChatInfo - var group: ChatInfo - var contactRequest: ChatInfo - } - - static var sampleData: ChatInfo.SampleData = SampleData( - direct: ChatInfo.direct(contact: Contact.sampleData), - group: ChatInfo.group(groupInfo: GroupInfo.sampleData), - contactRequest: ChatInfo.contactRequest(contactRequest: UserContactRequest.sampleData) - ) -} - final class Chat: ObservableObject, Identifiable { @Published var chatInfo: ChatInfo @Published var chatItems: [ChatItem] @@ -450,606 +278,3 @@ final class Chat: ObservableObject, Identifiable { var id: ChatId { get { chatInfo.id } } } - -struct ChatData: Decodable, Identifiable { - var chatInfo: ChatInfo - var chatItems: [ChatItem] - var chatStats: ChatStats - - var id: ChatId { get { chatInfo.id } } -} - -struct ChatStats: Decodable { - var unreadCount: Int = 0 - var minUnreadItemId: Int64 = 0 -} - -struct Contact: Identifiable, Decodable, NamedChat { - var contactId: Int64 - var localDisplayName: ContactName - var profile: Profile - var activeConn: Connection - var viaGroup: Int64? - var createdAt: Date - - var id: ChatId { get { "@\(contactId)" } } - var apiId: Int64 { get { contactId } } - var ready: Bool { get { activeConn.connStatus == .ready } } - var displayName: String { get { profile.displayName } } - var fullName: String { get { profile.fullName } } - var image: String? { get { profile.image } } - - static let sampleData = Contact( - contactId: 1, - localDisplayName: "alice", - profile: Profile.sampleData, - activeConn: Connection.sampleData, - createdAt: .now - ) -} - -struct ContactRef: Decodable { - var contactId: Int64 - var localDisplayName: ContactName - - var id: ChatId { get { "@\(contactId)" } } -} - -struct ContactSubStatus: Decodable { - var contact: Contact - var contactError: ChatError? -} - -struct Connection: Decodable { - var connId: Int64 - var connStatus: ConnStatus - - var id: ChatId { get { ":\(connId)" } } - - static let sampleData = Connection( - connId: 1, - connStatus: .ready - ) -} - -struct UserContactRequest: Decodable, NamedChat { - var contactRequestId: Int64 - var localDisplayName: ContactName - var profile: Profile - var createdAt: Date - var updatedAt: Date - - var id: ChatId { get { "<@\(contactRequestId)" } } - var apiId: Int64 { get { contactRequestId } } - var ready: Bool { get { true } } - var displayName: String { get { profile.displayName } } - var fullName: String { get { profile.fullName } } - var image: String? { get { profile.image } } - - static let sampleData = UserContactRequest( - contactRequestId: 1, - localDisplayName: "alice", - profile: Profile.sampleData, - createdAt: .now, - updatedAt: .now - ) -} - -struct PendingContactConnection: Decodable, NamedChat { - var pccConnId: Int64 - var pccAgentConnId: String - var pccConnStatus: ConnStatus - var viaContactUri: Bool - var createdAt: Date - var updatedAt: Date - - var id: ChatId { get { ":\(pccConnId)" } } - var apiId: Int64 { get { pccConnId } } - var ready: Bool { get { false } } - var localDisplayName: String { - get { String.localizedStringWithFormat(NSLocalizedString("connection:%@", comment: "connection information"), pccConnId) } - } - var displayName: String { - get { - if let initiated = pccConnStatus.initiated { - return initiated && !viaContactUri - ? NSLocalizedString("invited to connect", comment: "chat list item title") - : NSLocalizedString("connecting…", comment: "chat list item title") - } else { - // this should not be in the list - return NSLocalizedString("connection established", comment: "chat list item title (it should not be shown") - } - } - } - var fullName: String { get { "" } } - var image: String? { get { nil } } - var initiated: Bool { get { (pccConnStatus.initiated ?? false) && !viaContactUri } } - - var description: String { - get { - if let initiated = pccConnStatus.initiated { - return initiated && !viaContactUri - ? NSLocalizedString("you shared one-time link", comment: "chat list item description") - : viaContactUri - ? NSLocalizedString("via contact address link", comment: "chat list item description") - : NSLocalizedString("via one-time link", comment: "chat list item description") - } else { - return "" - } - } - } - - static func getSampleData(_ status: ConnStatus = .new, viaContactUri: Bool = false) -> PendingContactConnection { - PendingContactConnection( - pccConnId: 1, - pccAgentConnId: "abcd", - pccConnStatus: status, - viaContactUri: viaContactUri, - createdAt: .now, - updatedAt: .now - ) - } -} - -enum ConnStatus: String, Decodable { - case new = "new" - case joined = "joined" - case requested = "requested" - case accepted = "accepted" - case sndReady = "snd-ready" - case ready = "ready" - case deleted = "deleted" - - var initiated: Bool? { - get { - switch self { - case .new: return true - case .joined: return false - case .requested: return true - case .accepted: return true - case .sndReady: return false - case .ready: return nil - case .deleted: return nil - } - } - } -} - -struct GroupInfo: Identifiable, Decodable, NamedChat { - var groupId: Int64 - var localDisplayName: GroupName - var groupProfile: GroupProfile - var createdAt: Date - - var id: ChatId { get { "#\(groupId)" } } - var apiId: Int64 { get { groupId } } - var ready: Bool { get { true } } - var displayName: String { get { groupProfile.displayName } } - var fullName: String { get { groupProfile.fullName } } - var image: String? { get { groupProfile.image } } - - static let sampleData = GroupInfo( - groupId: 1, - localDisplayName: "team", - groupProfile: GroupProfile.sampleData, - createdAt: .now - ) -} - -struct GroupProfile: Codable, NamedChat { - var displayName: String - var fullName: String - var image: String? - - static let sampleData = GroupProfile( - displayName: "team", - fullName: "My Team" - ) -} - -struct GroupMember: Decodable { - var groupMemberId: Int64 - var memberId: String -// var memberRole: GroupMemberRole -// var memberCategory: GroupMemberCategory -// var memberStatus: GroupMemberStatus -// var invitedBy: InvitedBy - var localDisplayName: ContactName - var memberProfile: Profile - var memberContactId: Int64? -// var activeConn: Connection? - - var directChatId: ChatId? { - get { - if let chatId = memberContactId { - return "@\(chatId)" - } else { - return nil - } - } - } - - static let sampleData = GroupMember( - groupMemberId: 1, - memberId: "abcd", - localDisplayName: "alice", - memberProfile: Profile.sampleData, - memberContactId: 1 - ) -} - -struct MemberSubError: Decodable { - var member: GroupMember - var memberError: ChatError -} - -struct AChatItem: Decodable { - var chatInfo: ChatInfo - var chatItem: ChatItem -} - -struct ChatItem: Identifiable, Decodable { - var chatDir: CIDirection - var meta: CIMeta - var content: CIContent - var formattedText: [FormattedText]? - var quotedItem: CIQuote? - var file: CIFile? - - var id: Int64 { get { meta.itemId } } - - var timestampText: Text { get { meta.timestampText } } - - var text: String { - get { - switch (content.text, file) { - case let ("", .some(file)): return file.fileName - default: return content.text - } - } - } - - func isRcvNew() -> Bool { - if case .rcvNew = meta.itemStatus { return true } - return false - } - - func isMsgContent() -> Bool { - switch content { - case .sndMsgContent: return true - case .rcvMsgContent: return true - default: return false - } - } - - func isDeletedContent() -> Bool { - switch content { - case .sndDeleted: return true - case .rcvDeleted: return true - default: return false - } - } - - var memberDisplayName: String? { - get { - if case let .groupRcv(groupMember) = chatDir { - return groupMember.memberProfile.displayName - } else { - return nil - } - } - } - - static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil, file: CIFile? = nil, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> ChatItem { - ChatItem( - chatDir: dir, - meta: CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable), - content: .sndMsgContent(msgContent: .text(text)), - quotedItem: quotedItem, - file: file - ) - } - - static func getDeletedContentSample (_ id: Int64 = 1, dir: CIDirection = .directRcv, _ ts: Date = .now, _ text: String = "this item is deleted", _ status: CIStatus = .rcvRead) -> ChatItem { - ChatItem( - chatDir: dir, - meta: CIMeta.getSample(id, ts, text, status, false, false, false), - content: .rcvDeleted(deleteMode: .cidmBroadcast), - quotedItem: nil, - file: nil - ) - } -} - -enum CIDirection: Decodable { - case directSnd - case directRcv - case groupSnd - case groupRcv(groupMember: GroupMember) - - var sent: Bool { - get { - switch self { - case .directSnd: return true - case .directRcv: return false - case .groupSnd: return true - case .groupRcv: return false - } - } - } -} - -struct CIMeta: Decodable { - var itemId: Int64 - var itemTs: Date - var itemText: String - var itemStatus: CIStatus - var createdAt: Date - var itemDeleted: Bool - var itemEdited: Bool - var editable: Bool - - var timestampText: Text { get { SimpleX.timestampText(itemTs) } } - - static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> CIMeta { - CIMeta( - itemId: id, - itemTs: ts, - itemText: text, - itemStatus: status, - createdAt: ts, - itemDeleted: itemDeleted, - itemEdited: itemEdited, - editable: editable - ) - } -} - -let msgTimeFormat = Date.FormatStyle.dateTime.hour().minute() -let msgDateFormat = Date.FormatStyle.dateTime.day(.twoDigits).month(.twoDigits) - -func timestampText(_ date: Date) -> Text { - let now = Calendar.current.dateComponents([.day, .hour], from: .now) - let dc = Calendar.current.dateComponents([.day, .hour], from: date) - let recent = now.day == dc.day || ((now.day ?? 0) - (dc.day ?? 0) == 1 && (dc.hour ?? 0) >= 18 && (now.hour ?? 0) < 12) - return Text(date, format: recent ? msgTimeFormat : msgDateFormat) -} - -enum CIStatus: Decodable { - case sndNew - case sndSent - case sndErrorAuth - case sndError(agentError: AgentErrorType) - case rcvNew - case rcvRead -} - -enum CIDeleteMode: String, Decodable { - case cidmBroadcast = "broadcast" - case cidmInternal = "internal" -} - -protocol ItemContent { - var text: String { get } -} - -enum CIContent: Decodable, ItemContent { - case sndMsgContent(msgContent: MsgContent) - case rcvMsgContent(msgContent: MsgContent) - case sndDeleted(deleteMode: CIDeleteMode) - case rcvDeleted(deleteMode: CIDeleteMode) - - var text: String { - get { - switch self { - case let .sndMsgContent(mc): return mc.text - case let .rcvMsgContent(mc): return mc.text - case .sndDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") - case .rcvDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") - } - } - } - - var msgContent: MsgContent? { - get { - switch self { - case let .sndMsgContent(mc): return mc - case let .rcvMsgContent(mc): return mc - default: return nil - } - } - } -} - -struct CIQuote: Decodable, ItemContent { - var chatDir: CIDirection? - var itemId: Int64? - var sharedMsgId: String? = nil - var sentAt: Date - var content: MsgContent - var formattedText: [FormattedText]? - - var text: String { get { content.text } } - - var sender: String? { - get { - switch (chatDir) { - case .directSnd: return "you" - case .directRcv: return nil - case .groupSnd: return ChatModel.shared.currentUser?.displayName - case let .groupRcv(member): return member.memberProfile.displayName - case nil: return nil - } - } - } - - static func getSample(_ itemId: Int64?, _ sentAt: Date, _ text: String, chatDir: CIDirection?, image: String? = nil) -> CIQuote { - let mc: MsgContent - if let image = image { - mc = .image(text: text, image: image) - } else { - mc = .text(text) - } - return CIQuote(chatDir: chatDir, itemId: itemId, sentAt: sentAt, content: mc) - } -} - -struct CIFile: Decodable { - var fileId: Int64 - var fileName: String - var fileSize: Int64 - var filePath: String? - var fileStatus: CIFileStatus - - static func getSample(_ fileId: Int64, _ fileName: String, _ fileSize: Int64, filePath: String?, fileStatus: CIFileStatus = .sndStored) -> CIFile { - CIFile(fileId: fileId, fileName: fileName, fileSize: fileSize, filePath: filePath, fileStatus: fileStatus) - } - - var stored: Bool { - get { - switch self.fileStatus { - case .sndStored: return true - case .sndCancelled: return true - case .rcvComplete: return true - default: return false - } - } - } -} - -enum CIFileStatus: String, Decodable { - case sndStored = "snd_stored" - case sndCancelled = "snd_cancelled" - case rcvInvitation = "rcv_invitation" - case rcvTransfer = "rcv_transfer" - case rcvComplete = "rcv_complete" - case rcvCancelled = "rcv_cancelled" -} - -enum MsgContent { - case text(String) - case link(text: String, preview: LinkPreview) - case image(text: String, image: String) - // TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift - case unknown(type: String, text: String) - - var text: String { - get { - switch self { - case let .text(text): return text - case let .link(text, _): return text - case let .image(text, _): return text - case let .unknown(_, text): return text - } - } - } - - var cmdString: String { - get { - switch self { - case let .text(text): return "text \(text)" - case let .link(text: text, preview: preview): - return "json {\"type\":\"link\",\"text\":\(encodeJSON(text)),\"preview\":\(encodeJSON(preview))}" - case let .image(text: text, image: image): - return "json {\"type\":\"image\",\"text\":\(encodeJSON(text)),\"image\":\(encodeJSON(image))}" - default: return "" - } - } - } - - enum CodingKeys: String, CodingKey { - case type - case text - case preview - case image - } -} - -// TODO define Encodable -extension MsgContent: Decodable { - init(from decoder: Decoder) throws { - do { - let container = try decoder.container(keyedBy: CodingKeys.self) - let type = try container.decode(String.self, forKey: CodingKeys.type) - switch type { - case "text": - let text = try container.decode(String.self, forKey: CodingKeys.text) - self = .text(text) - case "link": - let text = try container.decode(String.self, forKey: CodingKeys.text) - let preview = try container.decode(LinkPreview.self, forKey: CodingKeys.preview) - self = .link(text: text, preview: preview) - case "image": - let text = try container.decode(String.self, forKey: CodingKeys.text) - let image = try container.decode(String.self, forKey: CodingKeys.image) - self = .image(text: text, image: image) - default: - let text = try? container.decode(String.self, forKey: CodingKeys.text) - self = .unknown(type: type, text: text ?? "unknown message format") - } - } catch { - self = .unknown(type: "unknown", text: "invalid message format") - } - } -} - -struct FormattedText: Decodable { - var text: String - var format: Format? -} - -enum Format: Decodable, Equatable { - case bold - case italic - case strikeThrough - case snippet - case secret - case colored(color: FormatColor) - case uri - case email - case phone -} - -enum FormatColor: String, Decodable { - case red = "red" - case green = "green" - case blue = "blue" - case yellow = "yellow" - case cyan = "cyan" - case magenta = "magenta" - case black = "black" - case white = "white" - - var uiColor: Color { - get { - switch (self) { - case .red: return .red - case .green: return .green - case .blue: return .blue - case .yellow: return .yellow - case .cyan: return .cyan - case .magenta: return .purple - case .black: return .primary - case .white: return .primary - } - } - } -} - -// Struct to use with simplex API -struct LinkPreview: Codable { - var uri: URL - var title: String - // TODO remove once optional in haskell - var description: String = "" - var image: String -} - -enum NtfTknStatus: String, Decodable { - case new = "NEW" - case registered = "REGISTERED" - case invalid = "INVALID" - case confirmed = "CONFIRMED" - case active = "ACTIVE" - case expired = "EXPIRED" -} diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index 176ed9bf18..f6ba0fda00 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -12,14 +12,6 @@ import UIKit let ntfActionAccept = "NTF_ACT_ACCEPT" -let ntfCategoryContactRequest = "NTF_CAT_CONTACT_REQUEST" -let ntfCategoryContactConnected = "NTF_CAT_CONTACT_CONNECTED" -let ntfCategoryMessageReceived = "NTF_CAT_MESSAGE_RECEIVED" -// TODO remove -let ntfCategoryCheckingMessages = "NTF_CAT_CHECKING_MESSAGES" - -let appNotificationId = "chat.simplex.app.notification" - private let ntfTimeInterval: TimeInterval = 1 class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { @@ -69,10 +61,13 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { // in another chat return recentInTheSameChat(content) ? [.banner, .list] : [.sound, .banner, .list] } + // this notification is deliverd from the notifications server + // when the app is in foreground it does not need to be shown + case ntfCategoryCheckMessage: return [] default: return [.sound, .banner, .list] } } else { - return [.sound, .banner, .list] + return content.categoryIdentifier == ntfCategoryCheckMessage ? [] : [.sound, .banner, .list] } } @@ -146,76 +141,31 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { func notifyContactRequest(_ contactRequest: UserContactRequest) { logger.debug("NtfManager.notifyContactRequest") - addNotification( - categoryIdentifier: ntfCategoryContactRequest, - title: String.localizedStringWithFormat(NSLocalizedString("%@ wants to connect!", comment: "notification title"), contactRequest.displayName), - body: String.localizedStringWithFormat(NSLocalizedString("Accept contact request from %@?", comment: "notification body"), contactRequest.chatViewName), - targetContentIdentifier: nil, - userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId] - ) + addNotification(createContactRequestNtf(contactRequest)) } func notifyContactConnected(_ contact: Contact) { logger.debug("NtfManager.notifyContactConnected") - addNotification( - categoryIdentifier: ntfCategoryContactConnected, - title: String.localizedStringWithFormat(NSLocalizedString("%@ is connected!", comment: "notification title"), contact.displayName), - body: String.localizedStringWithFormat(NSLocalizedString("You can now send messages to %@", comment: "notification body"), contact.chatViewName), - targetContentIdentifier: contact.id -// userInfo: ["chatId": contact.id, "contactId": contact.apiId] - ) + addNotification(createContactConnectedNtf(contact)) } func notifyMessageReceived(_ cInfo: ChatInfo, _ cItem: ChatItem) { logger.debug("NtfManager.notifyMessageReceived") - addNotification( - categoryIdentifier: ntfCategoryMessageReceived, - title: "\(cInfo.chatViewName):", - body: hideSecrets(cItem), - targetContentIdentifier: cInfo.id -// userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id] - ) + addNotification(createMessageReceivedNtf(cInfo, cItem)) } // TODO remove func notifyCheckingMessages() { logger.debug("NtfManager.notifyCheckingMessages") - addNotification( + let content = createNotification( categoryIdentifier: ntfCategoryCheckingMessages, title: NSLocalizedString("Checking new messages...", comment: "notification") ) + addNotification(content) } - func hideSecrets(_ cItem: ChatItem) -> String { - if let md = cItem.formattedText { - var res = "" - for ft in md { - if case .secret = ft.format { - res = res + "..." - } else { - res = res + ft.text - } - } - return res - } else { - return cItem.content.text - } - } - - private func addNotification(categoryIdentifier: String, title: String, subtitle: String? = nil, body: String? = nil, - targetContentIdentifier: String? = nil, userInfo: [AnyHashable : Any] = [:]) { + private func addNotification(_ content: UNMutableNotificationContent) { if !granted { return } - let content = UNMutableNotificationContent() - content.categoryIdentifier = categoryIdentifier - content.title = title - if let s = subtitle { content.subtitle = s } - if let s = body { content.body = s } - content.targetContentIdentifier = targetContentIdentifier - content.userInfo = userInfo - // TODO move logic of adding sound here, so it applies to background notifications too - content.sound = .default -// content.interruptionLevel = .active -// content.relevanceScore = 0.5 // 0-1 let trigger = UNTimeIntervalNotificationTrigger(timeInterval: ntfTimeInterval, repeats: false) let request = UNNotificationRequest(identifier: appNotificationId, content: content, trigger: trigger) UNUserNotificationCenter.current().add(request) { error in diff --git a/apps/ios/Shared/Model/Shared/API.swift b/apps/ios/Shared/Model/Shared/API.swift new file mode 100644 index 0000000000..7208c45d99 --- /dev/null +++ b/apps/ios/Shared/Model/Shared/API.swift @@ -0,0 +1,66 @@ +// +// API.swift +// SimpleX NSE +// +// Created by Evgeny on 26/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation + +private var chatController: chat_ctrl? + +func getChatCtrl() -> chat_ctrl { + if let controller = chatController { return controller } + let dataDir = getDocumentsDirectory().path + "/mobile_v1" + logger.debug("documents directory \(dataDir)") + var cstr = dataDir.cString(using: .utf8)! + chatController = chat_init(&cstr) + logger.debug("getChatCtrl: chat_init") + return chatController! +} + +func sendSimpleXCmd(_ cmd: ChatCommand) -> ChatResponse { + var c = cmd.cmdString.cString(using: .utf8)! + return chatResponse(chat_send_cmd(getChatCtrl(), &c)) +} + +func chatResponse(_ cjson: UnsafeMutablePointer) -> ChatResponse { + let s = String.init(cString: cjson) + let d = s.data(using: .utf8)! +// TODO is there a way to do it without copying the data? e.g: +// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) +// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) + do { + let r = try jsonDecoder.decode(APIResponse.self, from: d) + return r.resp + } catch { + logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") + } + + var type: String? + var json: String? + if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { + if let j1 = j["resp"] as? NSDictionary, j1.count == 1 { + type = j1.allKeys[0] as? String + } + json = prettyJSON(j) + } + free(cjson) + return ChatResponse.response(type: type ?? "invalid", json: json ?? s) +} + +func prettyJSON(_ obj: NSDictionary) -> String? { + if let d = try? JSONSerialization.data(withJSONObject: obj, options: .prettyPrinted) { + return String(decoding: d, as: UTF8.self) + } + return nil +} + +func responseError(_ err: Error) -> String { + if let r = err as? ChatResponse { + return String(describing: r) + } else { + return err.localizedDescription + } +} diff --git a/apps/ios/Shared/Model/Shared/APITypes.swift b/apps/ios/Shared/Model/Shared/APITypes.swift new file mode 100644 index 0000000000..c813ee23ff --- /dev/null +++ b/apps/ios/Shared/Model/Shared/APITypes.swift @@ -0,0 +1,451 @@ +// +// SimpleXAPI.swift +// SimpleX NSE +// +// Created by Evgeny on 26/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation + +let jsonDecoder = getJSONDecoder() +let jsonEncoder = getJSONEncoder() + +enum ChatCommand { + case showActiveUser + case createActiveUser(profile: Profile) + case startChat + case setFilesFolder(filesFolder: String) + case apiGetChats + case apiGetChat(type: ChatType, id: Int64) + case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent) + case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) + case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) + case apiRegisterToken(token: String) + case apiVerifyToken(token: String, code: String, nonce: String) + case apiIntervalNofication(token: String, interval: Int) + case apiDeleteToken(token: String) + case getUserSMPServers + case setUserSMPServers(smpServers: [String]) + case addContact + case connect(connReq: String) + case apiDeleteChat(type: ChatType, id: Int64) + case apiUpdateProfile(profile: Profile) + case apiParseMarkdown(text: String) + case createMyAddress + case deleteMyAddress + case showMyAddress + case apiAcceptContact(contactReqId: Int64) + case apiRejectContact(contactReqId: Int64) + case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) + case receiveFile(fileId: Int64) + case string(String) + + var cmdString: String { + get { + switch self { + case .showActiveUser: return "/u" + case let .createActiveUser(profile): return "/u \(profile.displayName) \(profile.fullName)" + case .startChat: return "/_start" + case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)" + case .apiGetChats: return "/_get chats pcc=on" + case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100" + case let .apiSendMessage(type, id, file, quotedItemId, mc): + switch (file, quotedItemId) { + case (nil, nil): return "/_send \(ref(type, id)) \(mc.cmdString)" + case let (.some(file), nil): return "/_send \(ref(type, id)) file \(file) \(mc.cmdString)" + case let (nil, .some(quotedItemId)): return "/_send \(ref(type, id)) quoted \(quotedItemId) \(mc.cmdString)" + case let (.some(file), .some(quotedItemId)): return "/_send \(ref(type, id)) file \(file) quoted \(quotedItemId) \(mc.cmdString)" + } + case let .apiUpdateChatItem(type, id, itemId, mc): return "/_update item \(ref(type, id)) \(itemId) \(mc.cmdString)" + case let .apiDeleteChatItem(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)" + case let .apiRegisterToken(token): return "/_ntf register apns \(token)" + case let .apiVerifyToken(token, code, nonce): return "/_ntf verify apns \(token) \(code) \(nonce)" + case let .apiIntervalNofication(token, interval): return "/_ntf interval apns \(token) \(interval)" + case let .apiDeleteToken(token): return "/_ntf delete apns \(token)" + case .getUserSMPServers: return "/smp_servers" + case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))" + case .addContact: return "/connect" + case let .connect(connReq): return "/connect \(connReq)" + case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))" + case let .apiUpdateProfile(profile): return "/_profile \(encodeJSON(profile))" + case let .apiParseMarkdown(text): return "/_parse \(text)" + case .createMyAddress: return "/address" + case .deleteMyAddress: return "/delete_address" + case .showMyAddress: return "/show_address" + case let .apiAcceptContact(contactReqId): return "/_accept \(contactReqId)" + case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)" + case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)" + case let .receiveFile(fileId): return "/freceive \(fileId)" + case let .string(str): return str + } + } + } + + var cmdType: String { + get { + switch self { + case .showActiveUser: return "showActiveUser" + case .createActiveUser: return "createActiveUser" + case .startChat: return "startChat" + case .setFilesFolder: return "setFilesFolder" + case .apiGetChats: return "apiGetChats" + case .apiGetChat: return "apiGetChat" + case .apiSendMessage: return "apiSendMessage" + case .apiUpdateChatItem: return "apiUpdateChatItem" + case .apiDeleteChatItem: return "apiDeleteChatItem" + case .apiRegisterToken: return "apiRegisterToken" + case .apiVerifyToken: return "apiVerifyToken" + case .apiIntervalNofication: return "apiIntervalNofication" + case .apiDeleteToken: return "apiDeleteToken" + case .getUserSMPServers: return "getUserSMPServers" + case .setUserSMPServers: return "setUserSMPServers" + case .addContact: return "addContact" + case .connect: return "connect" + case .apiDeleteChat: return "apiDeleteChat" + case .apiUpdateProfile: return "apiUpdateProfile" + case .apiParseMarkdown: return "apiParseMarkdown" + case .createMyAddress: return "createMyAddress" + case .deleteMyAddress: return "deleteMyAddress" + case .showMyAddress: return "showMyAddress" + case .apiAcceptContact: return "apiAcceptContact" + case .apiRejectContact: return "apiRejectContact" + case .apiChatRead: return "apiChatRead" + case .receiveFile: return "receiveFile" + case .string: return "console command" + } + } + } + + func ref(_ type: ChatType, _ id: Int64) -> String { + "\(type.rawValue)\(id)" + } + + func smpServersStr(smpServers: [String]) -> String { + smpServers.isEmpty ? "default" : smpServers.joined(separator: ",") + } +} + +struct APIResponse: Decodable { + var resp: ChatResponse +} + +enum ChatResponse: Decodable, Error { + case response(type: String, json: String) + case activeUser(user: User) + case chatStarted + case chatRunning + case apiChats(chats: [ChatData]) + case apiChat(chat: ChatData) + case userSMPServers(smpServers: [String]) + case invitation(connReqInvitation: String) + case sentConfirmation + case sentInvitation + case contactAlreadyExists(contact: Contact) + case contactDeleted(contact: Contact) + case userProfileNoChange + case userProfileUpdated(fromProfile: Profile, toProfile: Profile) + case apiParsedMarkdown(formattedText: [FormattedText]?) + case userContactLink(connReqContact: String) + case userContactLinkCreated(connReqContact: String) + case userContactLinkDeleted + case contactConnected(contact: Contact) + case contactConnecting(contact: Contact) + case receivedContactRequest(contactRequest: UserContactRequest) + case acceptingContactRequest(contact: Contact) + case contactRequestRejected + case contactUpdated(toContact: Contact) + case contactsSubscribed(server: String, contactRefs: [ContactRef]) + case contactsDisconnected(server: String, contactRefs: [ContactRef]) + case contactSubError(contact: Contact, chatError: ChatError) + case contactSubSummary(contactSubscriptions: [ContactSubStatus]) + case groupSubscribed(groupInfo: GroupInfo) + case memberSubErrors(memberSubErrors: [MemberSubError]) + case groupEmpty(groupInfo: GroupInfo) + case userContactLinkSubscribed + case newChatItem(chatItem: AChatItem) + case chatItemStatusUpdated(chatItem: AChatItem) + case chatItemUpdated(chatItem: AChatItem) + case chatItemDeleted(deletedChatItem: AChatItem, toChatItem: AChatItem) + case rcvFileAccepted + case rcvFileComplete(chatItem: AChatItem) + case ntfTokenStatus(status: NtfTknStatus) + case newContactConnection(connection: PendingContactConnection) + case contactConnectionDeleted(connection: PendingContactConnection) + case cmdOk + case chatCmdError(chatError: ChatError) + case chatError(chatError: ChatError) + + var responseType: String { + get { + switch self { + case let .response(type, _): return "* \(type)" + case .activeUser: return "activeUser" + case .chatStarted: return "chatStarted" + case .chatRunning: return "chatRunning" + case .apiChats: return "apiChats" + case .apiChat: return "apiChat" + case .userSMPServers: return "userSMPServers" + case .invitation: return "invitation" + case .sentConfirmation: return "sentConfirmation" + case .sentInvitation: return "sentInvitation" + case .contactAlreadyExists: return "contactAlreadyExists" + case .contactDeleted: return "contactDeleted" + case .userProfileNoChange: return "userProfileNoChange" + case .userProfileUpdated: return "userProfileUpdated" + case .apiParsedMarkdown: return "apiParsedMarkdown" + case .userContactLink: return "userContactLink" + case .userContactLinkCreated: return "userContactLinkCreated" + case .userContactLinkDeleted: return "userContactLinkDeleted" + case .contactConnected: return "contactConnected" + case .contactConnecting: return "contactConnecting" + case .receivedContactRequest: return "receivedContactRequest" + case .acceptingContactRequest: return "acceptingContactRequest" + case .contactRequestRejected: return "contactRequestRejected" + case .contactUpdated: return "contactUpdated" + case .contactsSubscribed: return "contactsSubscribed" + case .contactsDisconnected: return "contactsDisconnected" + case .contactSubError: return "contactSubError" + case .contactSubSummary: return "contactSubSummary" + case .groupSubscribed: return "groupSubscribed" + case .memberSubErrors: return "memberSubErrors" + case .groupEmpty: return "groupEmpty" + case .userContactLinkSubscribed: return "userContactLinkSubscribed" + case .newChatItem: return "newChatItem" + case .chatItemStatusUpdated: return "chatItemStatusUpdated" + case .chatItemUpdated: return "chatItemUpdated" + case .chatItemDeleted: return "chatItemDeleted" + case .rcvFileAccepted: return "rcvFileAccepted" + case .rcvFileComplete: return "rcvFileComplete" + case .ntfTokenStatus: return "ntfTokenStatus" + case .newContactConnection: return "newContactConnection" + case .contactConnectionDeleted: return "contactConnectionDeleted" + case .cmdOk: return "cmdOk" + case .chatCmdError: return "chatCmdError" + case .chatError: return "chatError" + } + } + } + + var details: String { + get { + switch self { + case let .response(_, json): return json + case let .activeUser(user): return String(describing: user) + case .chatStarted: return noDetails + case .chatRunning: return noDetails + case let .apiChats(chats): return String(describing: chats) + case let .apiChat(chat): return String(describing: chat) + case let .userSMPServers(smpServers): return String(describing: smpServers) + case let .invitation(connReqInvitation): return connReqInvitation + case .sentConfirmation: return noDetails + case .sentInvitation: return noDetails + case let .contactAlreadyExists(contact): return String(describing: contact) + case let .contactDeleted(contact): return String(describing: contact) + case .userProfileNoChange: return noDetails + case let .userProfileUpdated(_, toProfile): return String(describing: toProfile) + case let .apiParsedMarkdown(formattedText): return String(describing: formattedText) + case let .userContactLink(connReq): return connReq + case let .userContactLinkCreated(connReq): return connReq + case .userContactLinkDeleted: return noDetails + case let .contactConnected(contact): return String(describing: contact) + case let .contactConnecting(contact): return String(describing: contact) + case let .receivedContactRequest(contactRequest): return String(describing: contactRequest) + case let .acceptingContactRequest(contact): return String(describing: contact) + case .contactRequestRejected: return noDetails + case let .contactUpdated(toContact): return String(describing: toContact) + case let .contactsSubscribed(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" + case let .contactsDisconnected(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" + case let .contactSubError(contact, chatError): return "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))" + case let .contactSubSummary(contactSubscriptions): return String(describing: contactSubscriptions) + case let .groupSubscribed(groupInfo): return String(describing: groupInfo) + case let .memberSubErrors(memberSubErrors): return String(describing: memberSubErrors) + case let .groupEmpty(groupInfo): return String(describing: groupInfo) + case .userContactLinkSubscribed: return noDetails + case let .newChatItem(chatItem): return String(describing: chatItem) + case let .chatItemStatusUpdated(chatItem): return String(describing: chatItem) + case let .chatItemUpdated(chatItem): return String(describing: chatItem) + case let .chatItemDeleted(deletedChatItem, toChatItem): return "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))" + case .rcvFileAccepted: return noDetails + case let .rcvFileComplete(chatItem): return String(describing: chatItem) + case let .ntfTokenStatus(status): return String(describing: status) + case let .newContactConnection(connection): return String(describing: connection) + case let .contactConnectionDeleted(connection): return String(describing: connection) + case .cmdOk: return noDetails + case let .chatCmdError(chatError): return String(describing: chatError) + case let .chatError(chatError): return String(describing: chatError) + } + } + } + + private var noDetails: String { get { "\(responseType): no details" } } +} + +private func decodeCJSON(_ cjson: UnsafePointer) -> T? { + let s = String.init(cString: cjson) + let d = s.data(using: .utf8)! +// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) +// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) + return try? jsonDecoder.decode(T.self, from: d) +} + +private func getJSONObject(_ cjson: UnsafePointer) -> NSDictionary? { + let s = String.init(cString: cjson) + let d = s.data(using: .utf8)! + return try? JSONSerialization.jsonObject(with: d) as? NSDictionary +} + +func encodeJSON(_ value: T) -> String { + let data = try! jsonEncoder.encode(value) + return String(decoding: data, as: UTF8.self) +} + +private func encodeCJSON(_ value: T) -> [CChar] { + encodeJSON(value).cString(using: .utf8)! +} + +enum ChatError: Decodable { + case error(errorType: ChatErrorType) + case errorAgent(agentError: AgentErrorType) + case errorStore(storeError: StoreError) +} + +enum ChatErrorType: Decodable { + case noActiveUser + case activeUserExists + case chatNotStarted + case invalidConnReq + case invalidChatMessage(message: String) + case contactNotReady(contact: Contact) + case contactGroups(contact: Contact, groupNames: [GroupName]) + case groupUserRole + case groupContactRole(contactName: ContactName) + case groupDuplicateMember(contactName: ContactName) + case groupDuplicateMemberId + case groupNotJoined(groupInfo: GroupInfo) + case groupMemberNotActive + case groupMemberUserRemoved + case groupMemberNotFound(contactName: ContactName) + case groupMemberIntroNotFound(contactName: ContactName) + case groupCantResendInvitation(groupInfo: GroupInfo, contactName: ContactName) + case groupInternal(message: String) + case fileNotFound(message: String) + case fileAlreadyReceiving(message: String) + case fileAlreadyExists(filePath: String) + case fileRead(filePath: String, message: String) + case fileWrite(filePath: String, message: String) + case fileSend(fileId: Int64, agentError: String) + case fileRcvChunk(message: String) + case fileInternal(message: String) + case invalidQuote + case invalidChatItemUpdate + case invalidChatItemDelete + case agentVersion + case commandError(message: String) +} + +enum StoreError: Decodable { + case duplicateName + case contactNotFound(contactId: Int64) + case contactNotFoundByName(contactName: ContactName) + case contactNotReady(contactName: ContactName) + case duplicateContactLink + case userContactLinkNotFound + case contactRequestNotFound(contactRequestId: Int64) + case contactRequestNotFoundByName(contactName: ContactName) + case groupNotFound(groupId: Int64) + case groupNotFoundByName(groupName: GroupName) + case groupWithoutUser + case duplicateGroupMember + case groupAlreadyJoined + case groupInvitationNotFound + case sndFileNotFound(fileId: Int64) + case sndFileInvalid(fileId: Int64) + case rcvFileNotFound(fileId: Int64) + case fileNotFound(fileId: Int64) + case rcvFileInvalid(fileId: Int64) + case connectionNotFound(agentConnId: String) + case pendingConnectionNotFound(connId: Int64) + case introNotFound + case uniqueID + case internalError(message: String) + case noMsgDelivery(connId: Int64, agentMsgId: String) + case badChatItem(itemId: Int64) + case chatItemNotFound(itemId: Int64) + case quotedChatItemNotFound + case chatItemSharedMsgIdNotFound(sharedMsgId: String) + case chatItemNotFoundByFileId(fileId: Int64) +} + +enum AgentErrorType: Decodable { + case CMD(cmdErr: CommandErrorType) + case CONN(connErr: ConnectionErrorType) + case SMP(smpErr: ProtocolErrorType) + case NTF(ntfErr: ProtocolErrorType) + case BROKER(brokerErr: BrokerErrorType) + case AGENT(agentErr: SMPAgentError) + case INTERNAL(internalErr: String) +} + +enum CommandErrorType: Decodable { + case PROHIBITED + case SYNTAX + case NO_CONN + case SIZE + case LARGE +} + +enum ConnectionErrorType: Decodable { + case NOT_FOUND + case DUPLICATE + case SIMPLEX + case NOT_ACCEPTED + case NOT_AVAILABLE +} + +enum BrokerErrorType: Decodable { + case RESPONSE(smpErr: ProtocolErrorType) + case UNEXPECTED + case NETWORK + case TRANSPORT(transportErr: ProtocolTransportError) + case TIMEOUT +} + +enum ProtocolErrorType: Decodable { + case BLOCK + case SESSION + case CMD(cmdErr: ProtocolCommandError) + case AUTH + case QUOTA + case NO_MSG + case LARGE_MSG + case INTERNAL +} + +enum ProtocolCommandError: Decodable { + case UNKNOWN + case SYNTAX + case NO_AUTH + case HAS_AUTH + case NO_ENTITY +} + +enum ProtocolTransportError: Decodable { + case badBlock + case largeMsg + case badSession + case handshake(handshakeErr: SMPHandshakeError) +} + +enum SMPHandshakeError: Decodable { + case PARSE + case VERSION + case IDENTITY +} + +enum SMPAgentError: Decodable { + case A_MESSAGE + case A_PROHIBITED + case A_VERSION + case A_ENCRYPTION +} diff --git a/apps/ios/Shared/Model/Shared/ChatTypes.swift b/apps/ios/Shared/Model/Shared/ChatTypes.swift new file mode 100644 index 0000000000..0b3e90a6ec --- /dev/null +++ b/apps/ios/Shared/Model/Shared/ChatTypes.swift @@ -0,0 +1,783 @@ +// +// ChatModel.swift +// SimpleX NSE +// +// Created by Evgeny on 26/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import SwiftUI + +struct User: Decodable, NamedChat { + var userId: Int64 + var userContactId: Int64 + var localDisplayName: ContactName + var profile: Profile + var activeUser: Bool + + var displayName: String { get { profile.displayName } } + var fullName: String { get { profile.fullName } } + var image: String? { get { profile.image } } + + static let sampleData = User( + userId: 1, + userContactId: 1, + localDisplayName: "alice", + profile: Profile.sampleData, + activeUser: true + ) +} + +typealias ContactName = String + +typealias GroupName = String + +struct Profile: Codable, NamedChat { + var displayName: String + var fullName: String + var image: String? + + static let sampleData = Profile( + displayName: "alice", + fullName: "Alice" + ) +} + +enum ChatType: String { + case direct = "@" + case group = "#" + case contactRequest = "<@" + case contactConnection = ":" +} + +protocol NamedChat { + var displayName: String { get } + var fullName: String { get } + var image: String? { get } +} + +extension NamedChat { + var chatViewName: String { + get { displayName + (fullName == "" || fullName == displayName ? "" : " / \(fullName)") } + } +} + +typealias ChatId = String + +enum ChatInfo: Identifiable, Decodable, NamedChat { + case direct(contact: Contact) + case group(groupInfo: GroupInfo) + case contactRequest(contactRequest: UserContactRequest) + case contactConnection(contactConnection: PendingContactConnection) + + var localDisplayName: String { + get { + switch self { + case let .direct(contact): return contact.localDisplayName + case let .group(groupInfo): return groupInfo.localDisplayName + case let .contactRequest(contactRequest): return contactRequest.localDisplayName + case let .contactConnection(contactConnection): return contactConnection.localDisplayName + } + } + } + + var displayName: String { + get { + switch self { + case let .direct(contact): return contact.displayName + case let .group(groupInfo): return groupInfo.displayName + case let .contactRequest(contactRequest): return contactRequest.displayName + case let .contactConnection(contactConnection): return contactConnection.displayName + } + } + } + + var fullName: String { + get { + switch self { + case let .direct(contact): return contact.fullName + case let .group(groupInfo): return groupInfo.fullName + case let .contactRequest(contactRequest): return contactRequest.fullName + case let .contactConnection(contactConnection): return contactConnection.fullName + } + } + } + + var image: String? { + get { + switch self { + case let .direct(contact): return contact.image + case let .group(groupInfo): return groupInfo.image + case let .contactRequest(contactRequest): return contactRequest.image + case let .contactConnection(contactConnection): return contactConnection.image + } + } + } + + var id: ChatId { + get { + switch self { + case let .direct(contact): return contact.id + case let .group(groupInfo): return groupInfo.id + case let .contactRequest(contactRequest): return contactRequest.id + case let .contactConnection(contactConnection): return contactConnection.id + } + } + } + + var chatType: ChatType { + get { + switch self { + case .direct: return .direct + case .group: return .group + case .contactRequest: return .contactRequest + case .contactConnection: return .contactConnection + } + } + } + + var apiId: Int64 { + get { + switch self { + case let .direct(contact): return contact.apiId + case let .group(groupInfo): return groupInfo.apiId + case let .contactRequest(contactRequest): return contactRequest.apiId + case let .contactConnection(contactConnection): return contactConnection.apiId + } + } + } + + var ready: Bool { + get { + switch self { + case let .direct(contact): return contact.ready + case let .group(groupInfo): return groupInfo.ready + case let .contactRequest(contactRequest): return contactRequest.ready + case let .contactConnection(contactConnection): return contactConnection.ready + } + } + } + + var createdAt: Date { + switch self { + case let .direct(contact): return contact.createdAt + case let .group(groupInfo): return groupInfo.createdAt + case let .contactRequest(contactRequest): return contactRequest.createdAt + case let .contactConnection(contactConnection): return contactConnection.createdAt + } + } + + struct SampleData { + var direct: ChatInfo + var group: ChatInfo + var contactRequest: ChatInfo + } + + static var sampleData: ChatInfo.SampleData = SampleData( + direct: ChatInfo.direct(contact: Contact.sampleData), + group: ChatInfo.group(groupInfo: GroupInfo.sampleData), + contactRequest: ChatInfo.contactRequest(contactRequest: UserContactRequest.sampleData) + ) +} + +struct ChatData: Decodable, Identifiable { + var chatInfo: ChatInfo + var chatItems: [ChatItem] + var chatStats: ChatStats + + var id: ChatId { get { chatInfo.id } } +} + +struct ChatStats: Decodable { + var unreadCount: Int = 0 + var minUnreadItemId: Int64 = 0 +} + +struct Contact: Identifiable, Decodable, NamedChat { + var contactId: Int64 + var localDisplayName: ContactName + var profile: Profile + var activeConn: Connection + var viaGroup: Int64? + var createdAt: Date + + var id: ChatId { get { "@\(contactId)" } } + var apiId: Int64 { get { contactId } } + var ready: Bool { get { activeConn.connStatus == .ready } } + var displayName: String { get { profile.displayName } } + var fullName: String { get { profile.fullName } } + var image: String? { get { profile.image } } + + static let sampleData = Contact( + contactId: 1, + localDisplayName: "alice", + profile: Profile.sampleData, + activeConn: Connection.sampleData, + createdAt: .now + ) +} + +struct ContactRef: Decodable { + var contactId: Int64 + var localDisplayName: ContactName + + var id: ChatId { get { "@\(contactId)" } } +} + +struct ContactSubStatus: Decodable { + var contact: Contact + var contactError: ChatError? +} + +struct Connection: Decodable { + var connId: Int64 + var connStatus: ConnStatus + + var id: ChatId { get { ":\(connId)" } } + + static let sampleData = Connection( + connId: 1, + connStatus: .ready + ) +} + +struct UserContactRequest: Decodable, NamedChat { + var contactRequestId: Int64 + var localDisplayName: ContactName + var profile: Profile + var createdAt: Date + var updatedAt: Date + + var id: ChatId { get { "<@\(contactRequestId)" } } + var apiId: Int64 { get { contactRequestId } } + var ready: Bool { get { true } } + var displayName: String { get { profile.displayName } } + var fullName: String { get { profile.fullName } } + var image: String? { get { profile.image } } + + static let sampleData = UserContactRequest( + contactRequestId: 1, + localDisplayName: "alice", + profile: Profile.sampleData, + createdAt: .now, + updatedAt: .now + ) +} + +struct PendingContactConnection: Decodable, NamedChat { + var pccConnId: Int64 + var pccAgentConnId: String + var pccConnStatus: ConnStatus + var viaContactUri: Bool + var createdAt: Date + var updatedAt: Date + + var id: ChatId { get { ":\(pccConnId)" } } + var apiId: Int64 { get { pccConnId } } + var ready: Bool { get { false } } + var localDisplayName: String { + get { String.localizedStringWithFormat(NSLocalizedString("connection:%@", comment: "connection information"), pccConnId) } + } + var displayName: String { + get { + if let initiated = pccConnStatus.initiated { + return initiated && !viaContactUri + ? NSLocalizedString("invited to connect", comment: "chat list item title") + : NSLocalizedString("connecting…", comment: "chat list item title") + } else { + // this should not be in the list + return NSLocalizedString("connection established", comment: "chat list item title (it should not be shown") + } + } + } + var fullName: String { get { "" } } + var image: String? { get { nil } } + var initiated: Bool { get { (pccConnStatus.initiated ?? false) && !viaContactUri } } + + var description: String { + get { + if let initiated = pccConnStatus.initiated { + return initiated && !viaContactUri + ? NSLocalizedString("you shared one-time link", comment: "chat list item description") + : viaContactUri + ? NSLocalizedString("via contact address link", comment: "chat list item description") + : NSLocalizedString("via one-time link", comment: "chat list item description") + } else { + return "" + } + } + } + + static func getSampleData(_ status: ConnStatus = .new, viaContactUri: Bool = false) -> PendingContactConnection { + PendingContactConnection( + pccConnId: 1, + pccAgentConnId: "abcd", + pccConnStatus: status, + viaContactUri: viaContactUri, + createdAt: .now, + updatedAt: .now + ) + } +} + +enum ConnStatus: String, Decodable { + case new = "new" + case joined = "joined" + case requested = "requested" + case accepted = "accepted" + case sndReady = "snd-ready" + case ready = "ready" + case deleted = "deleted" + + var initiated: Bool? { + get { + switch self { + case .new: return true + case .joined: return false + case .requested: return true + case .accepted: return true + case .sndReady: return false + case .ready: return nil + case .deleted: return nil + } + } + } +} + +struct GroupInfo: Identifiable, Decodable, NamedChat { + var groupId: Int64 + var localDisplayName: GroupName + var groupProfile: GroupProfile + var createdAt: Date + + var id: ChatId { get { "#\(groupId)" } } + var apiId: Int64 { get { groupId } } + var ready: Bool { get { true } } + var displayName: String { get { groupProfile.displayName } } + var fullName: String { get { groupProfile.fullName } } + var image: String? { get { groupProfile.image } } + + static let sampleData = GroupInfo( + groupId: 1, + localDisplayName: "team", + groupProfile: GroupProfile.sampleData, + createdAt: .now + ) +} + +struct GroupProfile: Codable, NamedChat { + var displayName: String + var fullName: String + var image: String? + + static let sampleData = GroupProfile( + displayName: "team", + fullName: "My Team" + ) +} + +struct GroupMember: Decodable { + var groupMemberId: Int64 + var memberId: String +// var memberRole: GroupMemberRole +// var memberCategory: GroupMemberCategory +// var memberStatus: GroupMemberStatus +// var invitedBy: InvitedBy + var localDisplayName: ContactName + var memberProfile: Profile + var memberContactId: Int64? +// var activeConn: Connection? + + var directChatId: ChatId? { + get { + if let chatId = memberContactId { + return "@\(chatId)" + } else { + return nil + } + } + } + + static let sampleData = GroupMember( + groupMemberId: 1, + memberId: "abcd", + localDisplayName: "alice", + memberProfile: Profile.sampleData, + memberContactId: 1 + ) +} + +struct MemberSubError: Decodable { + var member: GroupMember + var memberError: ChatError +} + +struct AChatItem: Decodable { + var chatInfo: ChatInfo + var chatItem: ChatItem +} + +struct ChatItem: Identifiable, Decodable { + var chatDir: CIDirection + var meta: CIMeta + var content: CIContent + var formattedText: [FormattedText]? + var quotedItem: CIQuote? + var file: CIFile? + + var id: Int64 { get { meta.itemId } } + + var timestampText: Text { get { meta.timestampText } } + + var text: String { + get { + switch (content.text, file) { + case let ("", .some(file)): return file.fileName + default: return content.text + } + } + } + + func isRcvNew() -> Bool { + if case .rcvNew = meta.itemStatus { return true } + return false + } + + func isMsgContent() -> Bool { + switch content { + case .sndMsgContent: return true + case .rcvMsgContent: return true + default: return false + } + } + + func isDeletedContent() -> Bool { + switch content { + case .sndDeleted: return true + case .rcvDeleted: return true + default: return false + } + } + + var memberDisplayName: String? { + get { + if case let .groupRcv(groupMember) = chatDir { + return groupMember.memberProfile.displayName + } else { + return nil + } + } + } + + static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil, file: CIFile? = nil, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> ChatItem { + ChatItem( + chatDir: dir, + meta: CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable), + content: .sndMsgContent(msgContent: .text(text)), + quotedItem: quotedItem, + file: file + ) + } + + static func getDeletedContentSample (_ id: Int64 = 1, dir: CIDirection = .directRcv, _ ts: Date = .now, _ text: String = "this item is deleted", _ status: CIStatus = .rcvRead) -> ChatItem { + ChatItem( + chatDir: dir, + meta: CIMeta.getSample(id, ts, text, status, false, false, false), + content: .rcvDeleted(deleteMode: .cidmBroadcast), + quotedItem: nil, + file: nil + ) + } +} + +enum CIDirection: Decodable { + case directSnd + case directRcv + case groupSnd + case groupRcv(groupMember: GroupMember) + + var sent: Bool { + get { + switch self { + case .directSnd: return true + case .directRcv: return false + case .groupSnd: return true + case .groupRcv: return false + } + } + } +} + +struct CIMeta: Decodable { + var itemId: Int64 + var itemTs: Date + var itemText: String + var itemStatus: CIStatus + var createdAt: Date + var itemDeleted: Bool + var itemEdited: Bool + var editable: Bool + + var timestampText: Text { get { formatTimestampText(itemTs) } } + + static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> CIMeta { + CIMeta( + itemId: id, + itemTs: ts, + itemText: text, + itemStatus: status, + createdAt: ts, + itemDeleted: itemDeleted, + itemEdited: itemEdited, + editable: editable + ) + } +} + +let msgTimeFormat = Date.FormatStyle.dateTime.hour().minute() +let msgDateFormat = Date.FormatStyle.dateTime.day(.twoDigits).month(.twoDigits) + +func formatTimestampText(_ date: Date) -> Text { + let now = Calendar.current.dateComponents([.day, .hour], from: .now) + let dc = Calendar.current.dateComponents([.day, .hour], from: date) + let recent = now.day == dc.day || ((now.day ?? 0) - (dc.day ?? 0) == 1 && (dc.hour ?? 0) >= 18 && (now.hour ?? 0) < 12) + return Text(date, format: recent ? msgTimeFormat : msgDateFormat) +} + +enum CIStatus: Decodable { + case sndNew + case sndSent + case sndErrorAuth + case sndError(agentError: AgentErrorType) + case rcvNew + case rcvRead +} + +enum CIDeleteMode: String, Decodable { + case cidmBroadcast = "broadcast" + case cidmInternal = "internal" +} + +protocol ItemContent { + var text: String { get } +} + +enum CIContent: Decodable, ItemContent { + case sndMsgContent(msgContent: MsgContent) + case rcvMsgContent(msgContent: MsgContent) + case sndDeleted(deleteMode: CIDeleteMode) + case rcvDeleted(deleteMode: CIDeleteMode) + + var text: String { + get { + switch self { + case let .sndMsgContent(mc): return mc.text + case let .rcvMsgContent(mc): return mc.text + case .sndDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") + case .rcvDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") + } + } + } + + var msgContent: MsgContent? { + get { + switch self { + case let .sndMsgContent(mc): return mc + case let .rcvMsgContent(mc): return mc + default: return nil + } + } + } +} + +struct CIQuote: Decodable, ItemContent { + var chatDir: CIDirection? + var itemId: Int64? + var sharedMsgId: String? = nil + var sentAt: Date + var content: MsgContent + var formattedText: [FormattedText]? + + var text: String { get { content.text } } + + func getSender(_ currentUser: User?) -> String? { + switch (chatDir) { + case .directSnd: return "you" + case .directRcv: return nil + case .groupSnd: return currentUser?.displayName + case let .groupRcv(member): return member.memberProfile.displayName + case nil: return nil + } + } + + static func getSample(_ itemId: Int64?, _ sentAt: Date, _ text: String, chatDir: CIDirection?, image: String? = nil) -> CIQuote { + let mc: MsgContent + if let image = image { + mc = .image(text: text, image: image) + } else { + mc = .text(text) + } + return CIQuote(chatDir: chatDir, itemId: itemId, sentAt: sentAt, content: mc) + } +} + +struct CIFile: Decodable { + var fileId: Int64 + var fileName: String + var fileSize: Int64 + var filePath: String? + var fileStatus: CIFileStatus + + static func getSample(_ fileId: Int64, _ fileName: String, _ fileSize: Int64, filePath: String?, fileStatus: CIFileStatus = .sndStored) -> CIFile { + CIFile(fileId: fileId, fileName: fileName, fileSize: fileSize, filePath: filePath, fileStatus: fileStatus) + } + + var stored: Bool { + get { + switch self.fileStatus { + case .sndStored: return true + case .sndCancelled: return true + case .rcvComplete: return true + default: return false + } + } + } +} + +enum CIFileStatus: String, Decodable { + case sndStored = "snd_stored" + case sndCancelled = "snd_cancelled" + case rcvInvitation = "rcv_invitation" + case rcvTransfer = "rcv_transfer" + case rcvComplete = "rcv_complete" + case rcvCancelled = "rcv_cancelled" +} + +enum MsgContent { + case text(String) + case link(text: String, preview: LinkPreview) + case image(text: String, image: String) + // TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift + case unknown(type: String, text: String) + + var text: String { + get { + switch self { + case let .text(text): return text + case let .link(text, _): return text + case let .image(text, _): return text + case let .unknown(_, text): return text + } + } + } + + var cmdString: String { + get { + switch self { + case let .text(text): return "text \(text)" + case let .link(text: text, preview: preview): + return "json {\"type\":\"link\",\"text\":\(encodeJSON(text)),\"preview\":\(encodeJSON(preview))}" + case let .image(text: text, image: image): + return "json {\"type\":\"image\",\"text\":\(encodeJSON(text)),\"image\":\(encodeJSON(image))}" + default: return "" + } + } + } + + enum CodingKeys: String, CodingKey { + case type + case text + case preview + case image + } +} + +// TODO define Encodable +extension MsgContent: Decodable { + init(from decoder: Decoder) throws { + do { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: CodingKeys.type) + switch type { + case "text": + let text = try container.decode(String.self, forKey: CodingKeys.text) + self = .text(text) + case "link": + let text = try container.decode(String.self, forKey: CodingKeys.text) + let preview = try container.decode(LinkPreview.self, forKey: CodingKeys.preview) + self = .link(text: text, preview: preview) + case "image": + let text = try container.decode(String.self, forKey: CodingKeys.text) + let image = try container.decode(String.self, forKey: CodingKeys.image) + self = .image(text: text, image: image) + default: + let text = try? container.decode(String.self, forKey: CodingKeys.text) + self = .unknown(type: type, text: text ?? "unknown message format") + } + } catch { + self = .unknown(type: "unknown", text: "invalid message format") + } + } +} + +struct FormattedText: Decodable { + var text: String + var format: Format? +} + +enum Format: Decodable, Equatable { + case bold + case italic + case strikeThrough + case snippet + case secret + case colored(color: FormatColor) + case uri + case email + case phone +} + +enum FormatColor: String, Decodable { + case red = "red" + case green = "green" + case blue = "blue" + case yellow = "yellow" + case cyan = "cyan" + case magenta = "magenta" + case black = "black" + case white = "white" + + var uiColor: Color { + get { + switch (self) { + case .red: return .red + case .green: return .green + case .blue: return .blue + case .yellow: return .yellow + case .cyan: return .cyan + case .magenta: return .purple + case .black: return .primary + case .white: return .primary + } + } + } +} + +// Struct to use with simplex API +struct LinkPreview: Codable { + var uri: URL + var title: String + // TODO remove once optional in haskell + var description: String = "" + var image: String +} + +enum NtfTknStatus: String, Decodable { + case new = "NEW" + case registered = "REGISTERED" + case invalid = "INVALID" + case confirmed = "CONFIRMED" + case active = "ACTIVE" + case expired = "EXPIRED" +} diff --git a/apps/ios/Shared/Model/Shared/GroupDefaults.swift b/apps/ios/Shared/Model/Shared/GroupDefaults.swift new file mode 100644 index 0000000000..ca5aaef410 --- /dev/null +++ b/apps/ios/Shared/Model/Shared/GroupDefaults.swift @@ -0,0 +1,30 @@ +// +// GroupDefaults.swift +// SimpleX (iOS) +// +// Created by Evgeny on 26/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import SwiftUI + +func getGroupDefaults() -> UserDefaults? { + UserDefaults(suiteName: "5NN7GUYB6T.group.chat.simplex.app") +} + +func setAppState(_ phase: ScenePhase) { + if let defaults = getGroupDefaults() { + defaults.set(phase == .background, forKey: "appInBackground") + defaults.synchronize() + } +} + +func getAppState() -> ScenePhase { + if let defaults = getGroupDefaults() { + if defaults.bool(forKey: "appInBackground") { + return .background + } + } + return .active +} diff --git a/apps/ios/Shared/Model/JSON.swift b/apps/ios/Shared/Model/Shared/JSON.swift similarity index 100% rename from apps/ios/Shared/Model/JSON.swift rename to apps/ios/Shared/Model/Shared/JSON.swift diff --git a/apps/ios/Shared/Model/Shared/Notifications.swift b/apps/ios/Shared/Model/Shared/Notifications.swift new file mode 100644 index 0000000000..5da0fe08ad --- /dev/null +++ b/apps/ios/Shared/Model/Shared/Notifications.swift @@ -0,0 +1,81 @@ +// +// Notifications.swift +// SimpleX +// +// Created by Evgeny on 28/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import UserNotifications + +let ntfCategoryContactRequest = "NTF_CAT_CONTACT_REQUEST" +let ntfCategoryContactConnected = "NTF_CAT_CONTACT_CONNECTED" +let ntfCategoryMessageReceived = "NTF_CAT_MESSAGE_RECEIVED" +let ntfCategoryCheckMessage = "NTF_CAT_CHECK_MESSAGE" +// TODO remove +let ntfCategoryCheckingMessages = "NTF_CAT_CHECKING_MESSAGES" + +let appNotificationId = "chat.simplex.app.notification" + +func createContactRequestNtf(_ contactRequest: UserContactRequest) -> UNMutableNotificationContent { + createNotification( + categoryIdentifier: ntfCategoryContactRequest, + title: String.localizedStringWithFormat(NSLocalizedString("%@ wants to connect!", comment: "notification title"), contactRequest.displayName), + body: String.localizedStringWithFormat(NSLocalizedString("Accept contact request from %@?", comment: "notification body"), contactRequest.chatViewName), + targetContentIdentifier: nil, + userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId] + ) +} + +func createContactConnectedNtf(_ contact: Contact) -> UNMutableNotificationContent { + createNotification( + categoryIdentifier: ntfCategoryContactConnected, + title: String.localizedStringWithFormat(NSLocalizedString("%@ is connected!", comment: "notification title"), contact.displayName), + body: String.localizedStringWithFormat(NSLocalizedString("You can now send messages to %@", comment: "notification body"), contact.chatViewName), + targetContentIdentifier: contact.id +// userInfo: ["chatId": contact.id, "contactId": contact.apiId] + ) +} + +func createMessageReceivedNtf(_ cInfo: ChatInfo, _ cItem: ChatItem) -> UNMutableNotificationContent { + createNotification( + categoryIdentifier: ntfCategoryMessageReceived, + title: "\(cInfo.chatViewName):", + body: hideSecrets(cItem), + targetContentIdentifier: cInfo.id +// userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id] + ) +} + +func createNotification(categoryIdentifier: String, title: String, subtitle: String? = nil, body: String? = nil, + targetContentIdentifier: String? = nil, userInfo: [AnyHashable : Any] = [:]) -> UNMutableNotificationContent { + let content = UNMutableNotificationContent() + content.categoryIdentifier = categoryIdentifier + content.title = title + if let s = subtitle { content.subtitle = s } + if let s = body { content.body = s } + content.targetContentIdentifier = targetContentIdentifier + content.userInfo = userInfo + // TODO move logic of adding sound here, so it applies to background notifications too + content.sound = .default +// content.interruptionLevel = .active +// content.relevanceScore = 0.5 // 0-1 + return content +} + +func hideSecrets(_ cItem: ChatItem) -> String { + if let md = cItem.formattedText { + var res = "" + for ft in md { + if case .secret = ft.format { + res = res + "..." + } else { + res = res + ft.text + } + } + return res + } else { + return cItem.content.text + } +} diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index e719956a66..cd46e4c28b 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -12,278 +12,6 @@ import Dispatch import BackgroundTasks private var chatController: chat_ctrl? -private let jsonDecoder = getJSONDecoder() -let jsonEncoder = getJSONEncoder() - -enum ChatCommand { - case showActiveUser - case createActiveUser(profile: Profile) - case startChat - case setFilesFolder(filesFolder: String) - case apiGetChats - case apiGetChat(type: ChatType, id: Int64) - case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent) - case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) - case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) - case apiRegisterToken(token: String) - case apiVerifyToken(token: String, code: String, nonce: String) - case apiIntervalNofication(token: String, interval: Int) - case apiDeleteToken(token: String) - case getUserSMPServers - case setUserSMPServers(smpServers: [String]) - case addContact - case connect(connReq: String) - case apiDeleteChat(type: ChatType, id: Int64) - case apiUpdateProfile(profile: Profile) - case apiParseMarkdown(text: String) - case createMyAddress - case deleteMyAddress - case showMyAddress - case apiAcceptContact(contactReqId: Int64) - case apiRejectContact(contactReqId: Int64) - case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) - case receiveFile(fileId: Int64) - case string(String) - - var cmdString: String { - get { - switch self { - case .showActiveUser: return "/u" - case let .createActiveUser(profile): return "/u \(profile.displayName) \(profile.fullName)" - case .startChat: return "/_start" - case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)" - case .apiGetChats: return "/_get chats pcc=on" - case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100" - case let .apiSendMessage(type, id, file, quotedItemId, mc): - switch (file, quotedItemId) { - case (nil, nil): return "/_send \(ref(type, id)) \(mc.cmdString)" - case let (.some(file), nil): return "/_send \(ref(type, id)) file \(file) \(mc.cmdString)" - case let (nil, .some(quotedItemId)): return "/_send \(ref(type, id)) quoted \(quotedItemId) \(mc.cmdString)" - case let (.some(file), .some(quotedItemId)): return "/_send \(ref(type, id)) file \(file) quoted \(quotedItemId) \(mc.cmdString)" - } - case let .apiUpdateChatItem(type, id, itemId, mc): return "/_update item \(ref(type, id)) \(itemId) \(mc.cmdString)" - case let .apiDeleteChatItem(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)" - case let .apiRegisterToken(token): return "/_ntf register apns \(token)" - case let .apiVerifyToken(token, code, nonce): return "/_ntf verify apns \(token) \(code) \(nonce)" - case let .apiIntervalNofication(token, interval): return "/_ntf interval apns \(token) \(interval)" - case let .apiDeleteToken(token): return "/_ntf delete apns \(token)" - case .getUserSMPServers: return "/smp_servers" - case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))" - case .addContact: return "/connect" - case let .connect(connReq): return "/connect \(connReq)" - case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))" - case let .apiUpdateProfile(profile): return "/_profile \(encodeJSON(profile))" - case let .apiParseMarkdown(text): return "/_parse \(text)" - case .createMyAddress: return "/address" - case .deleteMyAddress: return "/delete_address" - case .showMyAddress: return "/show_address" - case let .apiAcceptContact(contactReqId): return "/_accept \(contactReqId)" - case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)" - case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)" - case let .receiveFile(fileId): return "/freceive \(fileId)" - case let .string(str): return str - } - } - } - - var cmdType: String { - get { - switch self { - case .showActiveUser: return "showActiveUser" - case .createActiveUser: return "createActiveUser" - case .startChat: return "startChat" - case .setFilesFolder: return "setFilesFolder" - case .apiGetChats: return "apiGetChats" - case .apiGetChat: return "apiGetChat" - case .apiSendMessage: return "apiSendMessage" - case .apiUpdateChatItem: return "apiUpdateChatItem" - case .apiDeleteChatItem: return "apiDeleteChatItem" - case .apiRegisterToken: return "apiRegisterToken" - case .apiVerifyToken: return "apiVerifyToken" - case .apiIntervalNofication: return "apiIntervalNofication" - case .apiDeleteToken: return "apiDeleteToken" - case .getUserSMPServers: return "getUserSMPServers" - case .setUserSMPServers: return "setUserSMPServers" - case .addContact: return "addContact" - case .connect: return "connect" - case .apiDeleteChat: return "apiDeleteChat" - case .apiUpdateProfile: return "apiUpdateProfile" - case .apiParseMarkdown: return "apiParseMarkdown" - case .createMyAddress: return "createMyAddress" - case .deleteMyAddress: return "deleteMyAddress" - case .showMyAddress: return "showMyAddress" - case .apiAcceptContact: return "apiAcceptContact" - case .apiRejectContact: return "apiRejectContact" - case .apiChatRead: return "apiChatRead" - case .receiveFile: return "receiveFile" - case .string: return "console command" - } - } - } - - func ref(_ type: ChatType, _ id: Int64) -> String { - "\(type.rawValue)\(id)" - } - - func smpServersStr(smpServers: [String]) -> String { - smpServers.isEmpty ? "default" : smpServers.joined(separator: ",") - } -} - -struct APIResponse: Decodable { - var resp: ChatResponse -} - -enum ChatResponse: Decodable, Error { - case response(type: String, json: String) - case activeUser(user: User) - case chatStarted - case chatRunning - case apiChats(chats: [ChatData]) - case apiChat(chat: ChatData) - case userSMPServers(smpServers: [String]) - case invitation(connReqInvitation: String) - case sentConfirmation - case sentInvitation - case contactAlreadyExists(contact: Contact) - case contactDeleted(contact: Contact) - case userProfileNoChange - case userProfileUpdated(fromProfile: Profile, toProfile: Profile) - case apiParsedMarkdown(formattedText: [FormattedText]?) - case userContactLink(connReqContact: String) - case userContactLinkCreated(connReqContact: String) - case userContactLinkDeleted - case contactConnected(contact: Contact) - case contactConnecting(contact: Contact) - case receivedContactRequest(contactRequest: UserContactRequest) - case acceptingContactRequest(contact: Contact) - case contactRequestRejected - case contactUpdated(toContact: Contact) - case contactsSubscribed(server: String, contactRefs: [ContactRef]) - case contactsDisconnected(server: String, contactRefs: [ContactRef]) - case contactSubError(contact: Contact, chatError: ChatError) - case contactSubSummary(contactSubscriptions: [ContactSubStatus]) - case groupSubscribed(groupInfo: GroupInfo) - case memberSubErrors(memberSubErrors: [MemberSubError]) - case groupEmpty(groupInfo: GroupInfo) - case userContactLinkSubscribed - case newChatItem(chatItem: AChatItem) - case chatItemStatusUpdated(chatItem: AChatItem) - case chatItemUpdated(chatItem: AChatItem) - case chatItemDeleted(deletedChatItem: AChatItem, toChatItem: AChatItem) - case rcvFileAccepted - case rcvFileComplete(chatItem: AChatItem) - case ntfTokenStatus(status: NtfTknStatus) - case newContactConnection(connection: PendingContactConnection) - case contactConnectionDeleted(connection: PendingContactConnection) - case cmdOk - case chatCmdError(chatError: ChatError) - case chatError(chatError: ChatError) - - var responseType: String { - get { - switch self { - case let .response(type, _): return "* \(type)" - case .activeUser: return "activeUser" - case .chatStarted: return "chatStarted" - case .chatRunning: return "chatRunning" - case .apiChats: return "apiChats" - case .apiChat: return "apiChat" - case .userSMPServers: return "userSMPServers" - case .invitation: return "invitation" - case .sentConfirmation: return "sentConfirmation" - case .sentInvitation: return "sentInvitation" - case .contactAlreadyExists: return "contactAlreadyExists" - case .contactDeleted: return "contactDeleted" - case .userProfileNoChange: return "userProfileNoChange" - case .userProfileUpdated: return "userProfileUpdated" - case .apiParsedMarkdown: return "apiParsedMarkdown" - case .userContactLink: return "userContactLink" - case .userContactLinkCreated: return "userContactLinkCreated" - case .userContactLinkDeleted: return "userContactLinkDeleted" - case .contactConnected: return "contactConnected" - case .contactConnecting: return "contactConnecting" - case .receivedContactRequest: return "receivedContactRequest" - case .acceptingContactRequest: return "acceptingContactRequest" - case .contactRequestRejected: return "contactRequestRejected" - case .contactUpdated: return "contactUpdated" - case .contactsSubscribed: return "contactsSubscribed" - case .contactsDisconnected: return "contactsDisconnected" - case .contactSubError: return "contactSubError" - case .contactSubSummary: return "contactSubSummary" - case .groupSubscribed: return "groupSubscribed" - case .memberSubErrors: return "memberSubErrors" - case .groupEmpty: return "groupEmpty" - case .userContactLinkSubscribed: return "userContactLinkSubscribed" - case .newChatItem: return "newChatItem" - case .chatItemStatusUpdated: return "chatItemStatusUpdated" - case .chatItemUpdated: return "chatItemUpdated" - case .chatItemDeleted: return "chatItemDeleted" - case .rcvFileAccepted: return "rcvFileAccepted" - case .rcvFileComplete: return "rcvFileComplete" - case .ntfTokenStatus: return "ntfTokenStatus" - case .newContactConnection: return "newContactConnection" - case .contactConnectionDeleted: return "contactConnectionDeleted" - case .cmdOk: return "cmdOk" - case .chatCmdError: return "chatCmdError" - case .chatError: return "chatError" - } - } - } - - var details: String { - get { - switch self { - case let .response(_, json): return json - case let .activeUser(user): return String(describing: user) - case .chatStarted: return noDetails - case .chatRunning: return noDetails - case let .apiChats(chats): return String(describing: chats) - case let .apiChat(chat): return String(describing: chat) - case let .userSMPServers(smpServers): return String(describing: smpServers) - case let .invitation(connReqInvitation): return connReqInvitation - case .sentConfirmation: return noDetails - case .sentInvitation: return noDetails - case let .contactAlreadyExists(contact): return String(describing: contact) - case let .contactDeleted(contact): return String(describing: contact) - case .userProfileNoChange: return noDetails - case let .userProfileUpdated(_, toProfile): return String(describing: toProfile) - case let .apiParsedMarkdown(formattedText): return String(describing: formattedText) - case let .userContactLink(connReq): return connReq - case let .userContactLinkCreated(connReq): return connReq - case .userContactLinkDeleted: return noDetails - case let .contactConnected(contact): return String(describing: contact) - case let .contactConnecting(contact): return String(describing: contact) - case let .receivedContactRequest(contactRequest): return String(describing: contactRequest) - case let .acceptingContactRequest(contact): return String(describing: contact) - case .contactRequestRejected: return noDetails - case let .contactUpdated(toContact): return String(describing: toContact) - case let .contactsSubscribed(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" - case let .contactsDisconnected(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" - case let .contactSubError(contact, chatError): return "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))" - case let .contactSubSummary(contactSubscriptions): return String(describing: contactSubscriptions) - case let .groupSubscribed(groupInfo): return String(describing: groupInfo) - case let .memberSubErrors(memberSubErrors): return String(describing: memberSubErrors) - case let .groupEmpty(groupInfo): return String(describing: groupInfo) - case .userContactLinkSubscribed: return noDetails - case let .newChatItem(chatItem): return String(describing: chatItem) - case let .chatItemStatusUpdated(chatItem): return String(describing: chatItem) - case let .chatItemUpdated(chatItem): return String(describing: chatItem) - case let .chatItemDeleted(deletedChatItem, toChatItem): return "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))" - case .rcvFileAccepted: return noDetails - case let .rcvFileComplete(chatItem): return String(describing: chatItem) - case let .ntfTokenStatus(status): return String(describing: status) - case let .newContactConnection(connection): return String(describing: connection) - case let .contactConnectionDeleted(connection): return String(describing: connection) - case .cmdOk: return noDetails - case let .chatCmdError(chatError): return String(describing: chatError) - case let .chatError(chatError): return String(describing: chatError) - } - } - } - - private var noDetails: String { get { "\(responseType): no details" } } -} enum TerminalItem: Identifiable { case cmd(Date, ChatCommand) @@ -317,11 +45,6 @@ enum TerminalItem: Identifiable { } } -private func _sendCmd(_ cmd: ChatCommand) -> ChatResponse { - var c = cmd.cmdString.cString(using: .utf8)! - return chatResponse(chat_send_cmd(getChatCtrl(), &c)) -} - private func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) { var id: UIBackgroundTaskIdentifier! var running = true @@ -362,8 +85,8 @@ private func withBGTask(bgDelay: Double? = nil, f: @escaping () -> ChatResponse) func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil) -> ChatResponse { logger.debug("chatSendCmd \(cmd.cmdType)") let resp = bgTask - ? withBGTask(bgDelay: bgDelay) { _sendCmd(cmd) } - : _sendCmd(cmd) + ? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd) } + : sendSimpleXCmd(cmd) logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)") if case let .response(_, json) = resp { logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)") @@ -640,7 +363,7 @@ func markChatRead(_ chat: Chat) async { } } -func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async { +func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async { do { try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id)) DispatchQueue.main.async { ChatModel.shared.markChatItemRead(cInfo, cItem) } @@ -655,14 +378,6 @@ private func sendCommandOkResp(_ cmd: ChatCommand) async throws { throw r } -func responseError(_ err: Error) -> String { - if let r = err as? ChatResponse { - return String(describing: r) - } else { - return err.localizedDescription - } -} - func initializeChat() { do { ChatModel.shared.currentUser = try apiGetActiveUser() @@ -833,218 +548,3 @@ private struct UserResponse: Decodable { var user: User? var error: String? } - -private func chatResponse(_ cjson: UnsafeMutablePointer) -> ChatResponse { - let s = String.init(cString: cjson) - let d = s.data(using: .utf8)! -// TODO is there a way to do it without copying the data? e.g: -// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) -// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) - -// TODO some mechanism to update model without passing it - maybe Publisher / Subscriber? - - do { - let r = try jsonDecoder.decode(APIResponse.self, from: d) - return r.resp - } catch { - logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") - } - - var type: String? - var json: String? - if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { - if let j1 = j["resp"] as? NSDictionary, j1.count == 1 { - type = j1.allKeys[0] as? String - } - json = prettyJSON(j) - } - free(cjson) - return ChatResponse.response(type: type ?? "invalid", json: json ?? s) -} - -func prettyJSON(_ obj: NSDictionary) -> String? { - if let d = try? JSONSerialization.data(withJSONObject: obj, options: .prettyPrinted) { - return String(decoding: d, as: UTF8.self) - } - return nil -} - -private func getChatCtrl() -> chat_ctrl { - if let controller = chatController { return controller } - let dataDir = getDocumentsDirectory().path + "/mobile_v1" - var cstr = dataDir.cString(using: .utf8)! - logger.debug("getChatCtrl: chat_init") - ChatModel.shared.terminalItems.append(.cmd(.now, .string("chat_init"))) - chatController = chat_init(&cstr) - ChatModel.shared.terminalItems.append(.resp(.now, .response(type: "chat_controller", json: "chat_controller: no details"))) - return chatController! -} - -private func decodeCJSON(_ cjson: UnsafePointer) -> T? { - let s = String.init(cString: cjson) - let d = s.data(using: .utf8)! -// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) -// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) - return try? jsonDecoder.decode(T.self, from: d) -} - -private func getJSONObject(_ cjson: UnsafePointer) -> NSDictionary? { - let s = String.init(cString: cjson) - let d = s.data(using: .utf8)! - return try? JSONSerialization.jsonObject(with: d) as? NSDictionary -} - -func encodeJSON(_ value: T) -> String { - let data = try! jsonEncoder.encode(value) - return String(decoding: data, as: UTF8.self) -} - -private func encodeCJSON(_ value: T) -> [CChar] { - encodeJSON(value).cString(using: .utf8)! -} - -enum ChatError: Decodable { - case error(errorType: ChatErrorType) - case errorAgent(agentError: AgentErrorType) - case errorStore(storeError: StoreError) -} - -enum ChatErrorType: Decodable { - case noActiveUser - case activeUserExists - case chatNotStarted - case invalidConnReq - case invalidChatMessage(message: String) - case contactNotReady(contact: Contact) - case contactGroups(contact: Contact, groupNames: [GroupName]) - case groupUserRole - case groupContactRole(contactName: ContactName) - case groupDuplicateMember(contactName: ContactName) - case groupDuplicateMemberId - case groupNotJoined(groupInfo: GroupInfo) - case groupMemberNotActive - case groupMemberUserRemoved - case groupMemberNotFound(contactName: ContactName) - case groupMemberIntroNotFound(contactName: ContactName) - case groupCantResendInvitation(groupInfo: GroupInfo, contactName: ContactName) - case groupInternal(message: String) - case fileNotFound(message: String) - case fileAlreadyReceiving(message: String) - case fileAlreadyExists(filePath: String) - case fileRead(filePath: String, message: String) - case fileWrite(filePath: String, message: String) - case fileSend(fileId: Int64, agentError: String) - case fileRcvChunk(message: String) - case fileInternal(message: String) - case invalidQuote - case invalidChatItemUpdate - case invalidChatItemDelete - case agentVersion - case commandError(message: String) -} - -enum StoreError: Decodable { - case duplicateName - case contactNotFound(contactId: Int64) - case contactNotFoundByName(contactName: ContactName) - case contactNotReady(contactName: ContactName) - case duplicateContactLink - case userContactLinkNotFound - case contactRequestNotFound(contactRequestId: Int64) - case contactRequestNotFoundByName(contactName: ContactName) - case groupNotFound(groupId: Int64) - case groupNotFoundByName(groupName: GroupName) - case groupWithoutUser - case duplicateGroupMember - case groupAlreadyJoined - case groupInvitationNotFound - case sndFileNotFound(fileId: Int64) - case sndFileInvalid(fileId: Int64) - case rcvFileNotFound(fileId: Int64) - case fileNotFound(fileId: Int64) - case rcvFileInvalid(fileId: Int64) - case connectionNotFound(agentConnId: String) - case pendingConnectionNotFound(connId: Int64) - case introNotFound - case uniqueID - case internalError(message: String) - case noMsgDelivery(connId: Int64, agentMsgId: String) - case badChatItem(itemId: Int64) - case chatItemNotFound(itemId: Int64) - case quotedChatItemNotFound - case chatItemSharedMsgIdNotFound(sharedMsgId: String) - case chatItemNotFoundByFileId(fileId: Int64) -} - -enum AgentErrorType: Decodable { - case CMD(cmdErr: CommandErrorType) - case CONN(connErr: ConnectionErrorType) - case SMP(smpErr: ProtocolErrorType) - case NTF(ntfErr: ProtocolErrorType) - case BROKER(brokerErr: BrokerErrorType) - case AGENT(agentErr: SMPAgentError) - case INTERNAL(internalErr: String) -} - -enum CommandErrorType: Decodable { - case PROHIBITED - case SYNTAX - case NO_CONN - case SIZE - case LARGE -} - -enum ConnectionErrorType: Decodable { - case NOT_FOUND - case DUPLICATE - case SIMPLEX - case NOT_ACCEPTED - case NOT_AVAILABLE -} - -enum BrokerErrorType: Decodable { - case RESPONSE(smpErr: ProtocolErrorType) - case UNEXPECTED - case NETWORK - case TRANSPORT(transportErr: ProtocolTransportError) - case TIMEOUT -} - -enum ProtocolErrorType: Decodable { - case BLOCK - case SESSION - case CMD(cmdErr: ProtocolCommandError) - case AUTH - case QUOTA - case NO_MSG - case LARGE_MSG - case INTERNAL -} - -enum ProtocolCommandError: Decodable { - case UNKNOWN - case SYNTAX - case NO_AUTH - case HAS_AUTH - case NO_ENTITY -} - -enum ProtocolTransportError: Decodable { - case badBlock - case largeMsg - case badSession - case handshake(handshakeErr: SMPHandshakeError) -} - -enum SMPHandshakeError: Decodable { - case PARSE - case VERSION - case IDENTITY -} - -enum SMPAgentError: Decodable { - case A_MESSAGE - case A_PROHIBITED - case A_VERSION - case A_ENCRYPTION -} diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index d99a0d197c..8dac273ab8 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -34,6 +34,8 @@ struct SimpleXApp: App { initializeChat() } .onChange(of: scenePhase) { phase in + logger.debug("scenePhase \(String(describing: scenePhase))") + setAppState(phase) if phase == .background { BGManager.shared.schedule() } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 80ba354785..21d7f7cf9e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -153,7 +153,7 @@ private struct MetaColorPreferenceKey: PreferenceKey { private func ciQuotedMsgView(_ qi: CIQuote) -> some View { MsgContentView( content: qi, - sender: qi.sender + sender: qi.getSender(ChatModel.shared.currentUser) ) .lineLimit(3) .font(.subheadline) diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 5b94a4b7d2..d039f6eeb3 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -35,7 +35,7 @@ struct ChatPreviewView: View { .foregroundColor(chat.chatInfo.ready ? .primary : .secondary) .frame(maxHeight: .infinity, alignment: .topLeading) Spacer() - (cItem?.timestampText ?? timestampText(chat.chatInfo.createdAt)) + (cItem?.timestampText ?? formatTimestampText(chat.chatInfo.createdAt)) .font(.subheadline) .frame(minWidth: 60, alignment: .trailing) .foregroundColor(.secondary) diff --git a/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift b/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift index 90c9b70bd8..5243188c6b 100644 --- a/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift +++ b/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift @@ -30,7 +30,7 @@ struct ContactConnectionView: View { .padding(.top, 4) .frame(maxHeight: .infinity, alignment: .topLeading) Spacer() - timestampText(contactConnection.updatedAt) + formatTimestampText(contactConnection.updatedAt) .font(.subheadline) .padding(.trailing, 8) .padding(.top, 4) diff --git a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift index d139550664..ac873fc8f9 100644 --- a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift +++ b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift @@ -26,7 +26,7 @@ struct ContactRequestView: View { .padding(.top, 4) .frame(maxHeight: .infinity, alignment: .topLeading) Spacer() - timestampText(contactRequest.updatedAt) + formatTimestampText(contactRequest.updatedAt) .font(.subheadline) .padding(.trailing, 8) .padding(.top, 4) diff --git a/apps/ios/SimpleX (iOS).entitlements b/apps/ios/SimpleX (iOS).entitlements index 7e024ae355..6dda31ceba 100644 --- a/apps/ios/SimpleX (iOS).entitlements +++ b/apps/ios/SimpleX (iOS).entitlements @@ -10,5 +10,9 @@ applinks:www.simplex.chat applinks:simplex.chat?mode=developer + com.apple.security.application-groups + + group.chat.simplex.app + diff --git a/apps/ios/SimpleX NSE/Info.plist b/apps/ios/SimpleX NSE/Info.plist new file mode 100644 index 0000000000..57421ebf9b --- /dev/null +++ b/apps/ios/SimpleX NSE/Info.plist @@ -0,0 +1,13 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift new file mode 100644 index 0000000000..a615ac5b71 --- /dev/null +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -0,0 +1,132 @@ +// +// NotificationService.swift +// SimpleX NSE +// +// Created by Evgeny on 26/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import UserNotifications +import OSLog + +let logger = Logger() + +class NotificationService: UNNotificationServiceExtension { + + var contentHandler: ((UNNotificationContent) -> Void)? + var bestAttemptContent: UNMutableNotificationContent? + + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + logger.debug("NotificationService.didReceive") + if getAppState() != .background { + contentHandler(request.content) + return + } + logger.debug("NotificationService: app is in the background") + self.contentHandler = contentHandler + bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) + if let _ = startChat() { + let content = receiveMessages() + contentHandler (content) + return + } + + if let bestAttemptContent = bestAttemptContent { + // Modify the notification content here... + bestAttemptContent.title = "\(bestAttemptContent.title) [modified]" + + contentHandler(bestAttemptContent) + } + } + + override func serviceExtensionTimeWillExpire() { + logger.debug("NotificationService.serviceExtensionTimeWillExpire") + // Called just before the extension will be terminated by the system. + // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. + if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { + contentHandler(bestAttemptContent) + } + } + +} + +func startChat() -> User? { + hs_init(0, nil) + if let user = apiGetActiveUser() { + logger.debug("active user \(String(describing: user))") + do { + try apiStartChat() + try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) + return user + } catch { + logger.error("NotificationService startChat error: \(responseError(error))") + } + } else { + logger.debug("no active user") + } + return nil +} + +func receiveMessages() -> UNNotificationContent { + logger.debug("NotificationService receiveMessages started") + while true { + let res = chatResponse(chat_recv_msg(getChatCtrl())!) + logger.debug("NotificationService receiveMessages: \(res.responseType)") + switch res { +// case let .newContactConnection(connection): +// case let .contactConnectionDeleted(connection): + case let .contactConnected(contact): + return createContactConnectedNtf(contact) +// case let .contactConnecting(contact): +// TODO profile update + case let .receivedContactRequest(contactRequest): + return createContactRequestNtf(contactRequest) +// case let .contactUpdated(toContact): +// TODO profile updated + case let .newChatItem(aChatItem): + let cInfo = aChatItem.chatInfo + let cItem = aChatItem.chatItem + return createMessageReceivedNtf(cInfo, cItem) +// case let .chatItemUpdated(aChatItem): +// TODO message updated +// let cInfo = aChatItem.chatInfo +// let cItem = aChatItem.chatItem +// NtfManager.shared.notifyMessageReceived(cInfo, cItem) +// case let .chatItemDeleted(_, toChatItem): +// TODO message updated +// case let .rcvFileComplete(aChatItem): +// TODO file received? +// let cInfo = aChatItem.chatInfo +// let cItem = aChatItem.chatItem +// NtfManager.shared.notifyMessageReceived(cInfo, cItem) + default: + logger.debug("NotificationService ignored event: \(res.responseType)") + } + } +} + +func apiGetActiveUser() -> User? { + let _ = getChatCtrl() + let r = sendSimpleXCmd(.showActiveUser) + logger.debug("apiGetActiveUser sendSimpleXCmd responce: \(String(describing: r))") + switch r { + case let .activeUser(user): return user + case .chatCmdError(.error(.noActiveUser)): return nil + default: + logger.error("NotificationService apiGetActiveUser unexpected response: \(String(describing: r))") + return nil + } +} + +func apiStartChat() throws { + let r = sendSimpleXCmd(.startChat) + if case .chatStarted = r { return } + throw r +} + +func apiSetFilesFolder(filesFolder: String) throws { + let r = sendSimpleXCmd(.setFilesFolder(filesFolder: filesFolder)) + if case .cmdOk = r { return } + throw r +} + diff --git a/apps/ios/SimpleX NSE/SimpleX NSE-Bridging-Header.h b/apps/ios/SimpleX NSE/SimpleX NSE-Bridging-Header.h new file mode 100644 index 0000000000..bc28b42d38 --- /dev/null +++ b/apps/ios/SimpleX NSE/SimpleX NSE-Bridging-Header.h @@ -0,0 +1,11 @@ +// +// Use this file to import your target's public headers that you would like to expose to Swift. +// + +extern void hs_init(int argc, char **argv[]); + +typedef void* chat_ctrl; + +extern chat_ctrl chat_init(char *path); +extern char *chat_send_cmd(chat_ctrl ctl, char *cmd); +extern char *chat_recv_msg(chat_ctrl ctl); diff --git a/apps/ios/SimpleX NSE/SimpleX NSE.entitlements b/apps/ios/SimpleX NSE/SimpleX NSE.entitlements new file mode 100644 index 0000000000..82cf32be67 --- /dev/null +++ b/apps/ios/SimpleX NSE/SimpleX NSE.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.chat.simplex.app + + + diff --git a/apps/ios/SimpleX NSE/dummy.m b/apps/ios/SimpleX NSE/dummy.m new file mode 100644 index 0000000000..adef540363 --- /dev/null +++ b/apps/ios/SimpleX NSE/dummy.m @@ -0,0 +1,24 @@ +// +// dummy.m +// SimpleX NSE +// +// Created by Evgeny on 26/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +#import + +#if defined(__x86_64__) && TARGET_IPHONE_SIMULATOR + +#import + +int readdir_r$INODE64(DIR *restrict dirp, struct dirent *restrict entry, + struct dirent **restrict result) { + return readdir_r(dirp, entry, result); +} + +DIR *opendir$INODE64(const char *name) { + return opendir(name); +} + +#endif diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index dff3f05f88..e5b00c54bc 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -63,6 +63,28 @@ 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */; }; 5CCD403A27A5F9BE00368C90 /* CreateGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */; }; + 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD472818589900503DA2 /* NotificationService.swift */; }; + 5CDCAD4C2818589900503DA2 /* SimpleX NSE.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 5CDCAD5328186F9500503DA2 /* GroupDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD5228186F9500503DA2 /* GroupDefaults.swift */; }; + 5CDCAD5428186F9700503DA2 /* GroupDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD5228186F9500503DA2 /* GroupDefaults.swift */; }; + 5CDCAD5828187C7500503DA2 /* dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD5728187C7500503DA2 /* dummy.m */; }; + 5CDCAD5928187CF200503DA2 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C882812A833007A6B96 /* libgmp.a */; }; + 5CDCAD5A28187CF200503DA2 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C8A2812A833007A6B96 /* libffi.a */; }; + 5CDCAD5B28187CF200503DA2 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C8B2812A833007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK-ghc8.10.7.a */; }; + 5CDCAD5C28187CF200503DA2 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C872812A833007A6B96 /* libgmpxx.a */; }; + 5CDCAD5D28187CF200503DA2 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C892812A833007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK.a */; }; + 5CDCAD5F28187D6900503DA2 /* libiconv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDCAD5E28187D4A00503DA2 /* libiconv.tbd */; }; + 5CDCAD6128187D8000503DA2 /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDCAD6028187D7900503DA2 /* libz.tbd */; }; + 5CDCAD682818876500503DA2 /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96A27A56D4D0075386C /* JSON.swift */; }; + 5CDCAD7628188D3600503DA2 /* APITypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD7428188D2900503DA2 /* APITypes.swift */; }; + 5CDCAD7728188D3800503DA2 /* ChatTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD7228188CFF00503DA2 /* ChatTypes.swift */; }; + 5CDCAD7828188FD300503DA2 /* ChatTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD7228188CFF00503DA2 /* ChatTypes.swift */; }; + 5CDCAD7928188FD600503DA2 /* APITypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD7428188D2900503DA2 /* APITypes.swift */; }; + 5CDCAD7C2818924D00503DA2 /* FileUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64DAE1502809D9F5000DA960 /* FileUtils.swift */; }; + 5CDCAD7E2818941F00503DA2 /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD7D2818941F00503DA2 /* API.swift */; }; + 5CDCAD7F281894FB00503DA2 /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD7D2818941F00503DA2 /* API.swift */; }; + 5CDCAD81281A7E2700503DA2 /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD80281A7E2700503DA2 /* Notifications.swift */; }; + 5CDCAD82281A7E2700503DA2 /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD80281A7E2700503DA2 /* Notifications.swift */; }; 5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407127ADB1D0007B033A /* Emoji.swift */; }; 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; }; 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; }; @@ -83,8 +105,29 @@ remoteGlobalIDString = 5CA059C9279559F40002BEB4; remoteInfo = "SimpleX (iOS)"; }; + 5CDCAD4A2818589900503DA2 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5CA059BE279559F40002BEB4 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5CDCAD442818589900503DA2; + remoteInfo = "SimpleX NSE"; + }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + 5CDCAD4D2818589900503DA2 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 5CDCAD4C2818589900503DA2 /* SimpleX NSE.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteToConnectView.swift; sourceTree = ""; }; 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = ""; }; @@ -146,6 +189,19 @@ 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = ""; }; 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToConnectView.swift; sourceTree = ""; }; 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateGroupView.swift; sourceTree = ""; }; + 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "SimpleX NSE.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; + 5CDCAD472818589900503DA2 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + 5CDCAD492818589900503DA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 5CDCAD5128186DE400503DA2 /* SimpleX NSE.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX NSE.entitlements"; sourceTree = ""; }; + 5CDCAD5228186F9500503DA2 /* GroupDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupDefaults.swift; sourceTree = ""; }; + 5CDCAD5628187C7500503DA2 /* SimpleX NSE-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SimpleX NSE-Bridging-Header.h"; sourceTree = ""; }; + 5CDCAD5728187C7500503DA2 /* dummy.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = dummy.m; sourceTree = ""; }; + 5CDCAD5E28187D4A00503DA2 /* libiconv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libiconv.tbd; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.4.sdk/usr/lib/libiconv.tbd; sourceTree = DEVELOPER_DIR; }; + 5CDCAD6028187D7900503DA2 /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.4.sdk/usr/lib/libz.tbd; sourceTree = DEVELOPER_DIR; }; + 5CDCAD7228188CFF00503DA2 /* ChatTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTypes.swift; sourceTree = ""; }; + 5CDCAD7428188D2900503DA2 /* APITypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APITypes.swift; sourceTree = ""; }; + 5CDCAD7D2818941F00503DA2 /* API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = API.swift; sourceTree = ""; }; + 5CDCAD80281A7E2700503DA2 /* Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifications.swift; sourceTree = ""; }; 5CE4407127ADB1D0007B033A /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = ""; }; 5CE4407827ADB701007B033A /* EmojiItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiItemView.swift; sourceTree = ""; }; 5CEACCE227DE9246000BD591 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; @@ -182,6 +238,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5CDCAD422818589900503DA2 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5CDCAD5F28187D6900503DA2 /* libiconv.tbd in Frameworks */, + 5CDCAD5C28187CF200503DA2 /* libgmpxx.a in Frameworks */, + 5CDCAD6128187D8000503DA2 /* libz.tbd in Frameworks */, + 5CDCAD5B28187CF200503DA2 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK-ghc8.10.7.a in Frameworks */, + 5CDCAD5928187CF200503DA2 /* libgmp.a in Frameworks */, + 5CDCAD5A28187CF200503DA2 /* libffi.a in Frameworks */, + 5CDCAD5D28187CF200503DA2 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -228,6 +298,8 @@ 5C764E7A279C71D4000C6508 /* Frameworks */ = { isa = PBXGroup; children = ( + 5CDCAD6028187D7900503DA2 /* libz.tbd */, + 5CDCAD5E28187D4A00503DA2 /* libiconv.tbd */, 5C764E7C279C71DB000C6508 /* libz.tbd */, 5C764E7B279C71D4000C6508 /* libiconv.tbd */, ); @@ -237,9 +309,9 @@ 5C764E87279CBC8E000C6508 /* Model */ = { isa = PBXGroup; children = ( + 5CDCAD7128188CEB00503DA2 /* Shared */, 5C764E88279CBCB3000C6508 /* ChatModel.swift */, 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */, - 5C9FD96A27A56D4D0075386C /* JSON.swift */, 5C35CFC727B2782E00FB6C6D /* BGManager.swift */, 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */, ); @@ -271,6 +343,7 @@ 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */, 5C764E5C279C70B7000C6508 /* Libraries */, 5CA059C2279559F40002BEB4 /* Shared */, + 5CDCAD462818589900503DA2 /* SimpleX NSE */, 5CA059DA279559F40002BEB4 /* Tests iOS */, 5CA059CB279559F40002BEB4 /* Products */, 5C764E7A279C71D4000C6508 /* Frameworks */, @@ -299,6 +372,7 @@ children = ( 5CA059CA279559F40002BEB4 /* SimpleX.app */, 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */, + 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */, ); name = Products; sourceTree = ""; @@ -351,6 +425,31 @@ path = ChatList; sourceTree = ""; }; + 5CDCAD462818589900503DA2 /* SimpleX NSE */ = { + isa = PBXGroup; + children = ( + 5CDCAD5128186DE400503DA2 /* SimpleX NSE.entitlements */, + 5CDCAD472818589900503DA2 /* NotificationService.swift */, + 5CDCAD492818589900503DA2 /* Info.plist */, + 5CDCAD5728187C7500503DA2 /* dummy.m */, + 5CDCAD5628187C7500503DA2 /* SimpleX NSE-Bridging-Header.h */, + ); + path = "SimpleX NSE"; + sourceTree = ""; + }; + 5CDCAD7128188CEB00503DA2 /* Shared */ = { + isa = PBXGroup; + children = ( + 5CDCAD5228186F9500503DA2 /* GroupDefaults.swift */, + 5CDCAD7228188CFF00503DA2 /* ChatTypes.swift */, + 5CDCAD7428188D2900503DA2 /* APITypes.swift */, + 5C9FD96A27A56D4D0075386C /* JSON.swift */, + 5CDCAD7D2818941F00503DA2 /* API.swift */, + 5CDCAD80281A7E2700503DA2 /* Notifications.swift */, + ); + path = Shared; + sourceTree = ""; + }; 5CE4407427ADB657007B033A /* ChatItem */ = { isa = PBXGroup; children = ( @@ -383,10 +482,12 @@ 5CA059C6279559F40002BEB4 /* Sources */, 5CA059C7279559F40002BEB4 /* Frameworks */, 5CA059C8279559F40002BEB4 /* Resources */, + 5CDCAD4D2818589900503DA2 /* Embed App Extensions */, ); buildRules = ( ); dependencies = ( + 5CDCAD4B2818589900503DA2 /* PBXTargetDependency */, ); name = "SimpleX (iOS)"; packageProductDependencies = ( @@ -414,6 +515,23 @@ productReference = 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + 5CDCAD442818589900503DA2 /* SimpleX NSE */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5CDCAD502818589900503DA2 /* Build configuration list for PBXNativeTarget "SimpleX NSE" */; + buildPhases = ( + 5CDCAD412818589900503DA2 /* Sources */, + 5CDCAD422818589900503DA2 /* Frameworks */, + 5CDCAD432818589900503DA2 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "SimpleX NSE"; + productName = "SimpleX NSE"; + productReference = 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -421,7 +539,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1320; + LastSwiftUpdateCheck = 1330; LastUpgradeCheck = 1330; ORGANIZATIONNAME = "SimpleX Chat"; TargetAttributes = { @@ -433,6 +551,10 @@ CreatedOnToolsVersion = 13.2.1; TestTargetID = 5CA059C9279559F40002BEB4; }; + 5CDCAD442818589900503DA2 = { + CreatedOnToolsVersion = 13.3; + LastSwiftMigration = 1330; + }; }; }; buildConfigurationList = 5CA059C1279559F40002BEB4 /* Build configuration list for PBXProject "SimpleX" */; @@ -454,6 +576,7 @@ targets = ( 5CA059C9279559F40002BEB4 /* SimpleX (iOS) */, 5CA059D6279559F40002BEB4 /* Tests iOS */, + 5CDCAD442818589900503DA2 /* SimpleX NSE */, ); }; /* End PBXProject section */ @@ -476,6 +599,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5CDCAD432818589900503DA2 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -484,10 +614,14 @@ buildActionMask = 2147483647; files = ( 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */, + 5CDCAD7F281894FB00503DA2 /* API.swift in Sources */, + 5CDCAD81281A7E2700503DA2 /* Notifications.swift in Sources */, 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */, 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */, + 5CDCAD7728188D3800503DA2 /* ChatTypes.swift in Sources */, 5C36027327F47AD5009F19D9 /* AppDelegate.swift in Sources */, 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */, + 5CDCAD5328186F9500503DA2 /* GroupDefaults.swift in Sources */, 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */, 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */, 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */, @@ -502,6 +636,7 @@ 5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */, 3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */, 5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */, + 5CDCAD7628188D3600503DA2 /* APITypes.swift in Sources */, 5C9FD96B27A56D4D0075386C /* JSON.swift in Sources */, 5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */, 5CC1C99227A6C7F5000D9FF6 /* QRCode.swift in Sources */, @@ -548,6 +683,22 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5CDCAD412818589900503DA2 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5CDCAD7928188FD600503DA2 /* APITypes.swift in Sources */, + 5CDCAD5828187C7500503DA2 /* dummy.m in Sources */, + 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */, + 5CDCAD82281A7E2700503DA2 /* Notifications.swift in Sources */, + 5CDCAD7C2818924D00503DA2 /* FileUtils.swift in Sources */, + 5CDCAD682818876500503DA2 /* JSON.swift in Sources */, + 5CDCAD7828188FD300503DA2 /* ChatTypes.swift in Sources */, + 5CDCAD7E2818941F00503DA2 /* API.swift in Sources */, + 5CDCAD5428186F9700503DA2 /* GroupDefaults.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -556,6 +707,11 @@ target = 5CA059C9279559F40002BEB4 /* SimpleX (iOS) */; targetProxy = 5CA059D8279559F40002BEB4 /* PBXContainerItemProxy */; }; + 5CDCAD4B2818589900503DA2 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5CDCAD442818589900503DA2 /* SimpleX NSE */; + targetProxy = 5CDCAD4A2818589900503DA2 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -695,6 +851,7 @@ 5CA059F4279559F40002BEB4 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; @@ -718,10 +875,6 @@ "$(inherited)", "@executable_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Libraries", - ); "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; MARKETING_VERSION = 1.7; @@ -739,6 +892,7 @@ 5CA059F5279559F40002BEB4 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; @@ -762,10 +916,6 @@ "$(inherited)", "@executable_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Libraries", - ); "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; MARKETING_VERSION = 1.7; @@ -821,6 +971,86 @@ }; name = Release; }; + 5CDCAD4E2818589900503DA2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 39; + DEVELOPMENT_TEAM = 5NN7GUYB6T; + ENABLE_BITCODE = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "SimpleX NSE/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "SimpleX NSE"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 SimpleX Chat. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Libraries/ios", + ); + "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Libraries/sim", + ); + MARKETING_VERSION = 1.6; + PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "SimpleX NSE/SimpleX NSE-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 5CDCAD4F2818589900503DA2 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 39; + DEVELOPMENT_TEAM = 5NN7GUYB6T; + ENABLE_BITCODE = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "SimpleX NSE/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "SimpleX NSE"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 SimpleX Chat. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Libraries/ios", + ); + "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Libraries/sim", + ); + MARKETING_VERSION = 1.6; + PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "SimpleX NSE/SimpleX NSE-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -851,6 +1081,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 5CDCAD502818589900503DA2 /* Build configuration list for PBXNativeTarget "SimpleX NSE" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5CDCAD4E2818589900503DA2 /* Debug */, + 5CDCAD4F2818589900503DA2 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ From 20d253ea359aff5b7382afb42bd6c5369302af2c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 3 May 2022 10:22:35 +0100 Subject: [PATCH 45/95] core: webrtc calls API implementation (#593) * core: webrtc calls API implementation * process call messages, send events to the UI --- src/Simplex/Chat.hs | 207 ++++++++++++++++++++++++++++++--- src/Simplex/Chat/Call.hs | 20 ++++ src/Simplex/Chat/Controller.hs | 8 +- src/Simplex/Chat/Messages.hs | 6 +- src/Simplex/Chat/Store.hs | 30 +++-- src/Simplex/Chat/View.hs | 4 + 6 files changed, 246 insertions(+), 29 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 721c0f0add..3a7f790174 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -40,6 +40,7 @@ import qualified Data.Text as T import Data.Time.Clock (UTCTime, getCurrentTime) import Data.Time.LocalTime (getCurrentTimeZone, getZonedTime) import Data.Word (Word32) +import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Markdown import Simplex.Chat.Messages @@ -389,12 +390,75 @@ processChatCommand = \case `E.finally` deleteContactRequest st userId connReqId withAgent $ \a -> rejectContact a connId invId pure $ CRContactRequestRejected cReq - APISendCallInvitation _contactId _callType -> pure $ chatCmdError "not implemented" - APIRejectCall _contactId -> pure $ chatCmdError "not implemented" - APISendCallOffer _contactId _wCallOffer -> pure $ chatCmdError "not implemented" - APISendCallAnswer _contactId _rtcSession -> pure $ chatCmdError "not implemented" - APISendCallExtraInfo _contactId _rtcExtraInfo -> pure $ chatCmdError "not implemented" - APIEndCall _contactId -> pure $ chatCmdError "not implemented" + APISendCallInvitation contactId callType@CallType {capabilities = CallCapabilities {encryption}} -> withUser $ \user@User {userId} -> do + -- party initiating call + ct <- withStore $ \st -> getContact st userId contactId + call <- asks currentCall + withChatLock $ + readTVarIO call >>= \case + Just _ -> throwChatError CEHasCurrentCall + _ -> do + callId <- CallId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16)) + dhKeyPair <- if encryption then Just <$> liftIO C.generateKeyPair' else pure Nothing + let invitation = CallInvitation {callType, callDhPubKey = fst <$> dhKeyPair} + callState = CallInvitationSent {localCallType = callType, localDhPrivKey = snd <$> dhKeyPair} + msg <- sendDirectContactMessage ct (XCallInv callId invitation) + ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndCall CISCallPending 0) Nothing Nothing + atomically . writeTVar call $ Just Call {contactId, callId, chatItemId = chatItemId' ci, callState} + toView . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci + pure CRCmdOk + APIRejectCall contactId -> + -- party accepting call + withCurrentCall contactId $ \userId ct Call {chatItemId, callState} -> case callState of + CallInvitationReceived {} -> do + updCi <- withStore $ \st -> updateDirectChatItemNoMsg st userId contactId chatItemId (CIRcvCall CISCallRejected 0) + toView . CRChatItemUpdated $ AChatItem SCTDirect SMDRcv (DirectChat ct) updCi + pure Nothing + _ -> throwChatError . CECallState $ callStateTag callState + APISendCallOffer contactId WebRTCCallOffer {callType, rtcSession} -> + -- party accepting call + withCurrentCall contactId $ \userId ct call@Call {callId, chatItemId, callState} -> case callState of + CallInvitationReceived {peerCallType, localDhPubKey, sharedKey} -> do + -- TODO check that call type matches peerCallType + let offer = CallOffer {callType, rtcSession, callDhPubKey = localDhPubKey} + callState' = CallOfferSent {localCallType = callType, peerCallType, localCallSession = rtcSession, sharedKey} + SndMessage {msgId} <- sendDirectContactMessage ct (XCallOffer callId offer) + updCi <- withStore $ \st -> updateDirectChatItem st userId contactId chatItemId (CIRcvCall CISCallAccepted 0) msgId + toView . CRChatItemUpdated $ AChatItem SCTDirect SMDRcv (DirectChat ct) updCi + pure $ Just call {callState = callState'} + _ -> throwChatError . CECallState $ callStateTag callState + APISendCallAnswer contactId rtcSession -> + -- party initiating call + withCurrentCall contactId $ \userId ct call@Call {callId, chatItemId, callState} -> case callState of + CallOfferReceived {localCallType, peerCallType, peerCallSession, sharedKey} -> do + SndMessage {msgId} <- sendDirectContactMessage ct (XCallAnswer callId CallAnswer {rtcSession}) + updCi <- withStore $ \st -> updateDirectChatItem st userId contactId chatItemId (CISndCall CISCallNegotiated 0) msgId + toView . CRChatItemUpdated $ AChatItem SCTDirect SMDSnd (DirectChat ct) updCi + let callState' = CallNegotiated {localCallType, peerCallType, localCallSession = rtcSession, peerCallSession, sharedKey} + pure $ Just call {callState = callState'} + _ -> throwChatError . CECallState $ callStateTag callState + APISendCallExtraInfo contactId rtcExtraInfo -> + -- any call party + withCurrentCall contactId $ \_ ct call@Call {callId, callState} -> case callState of + CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession, sharedKey} -> do + -- TODO update the list of ice servers + _ <- sendDirectContactMessage ct (XCallExtra callId CallExtraInfo {rtcExtraInfo}) + let callState' = CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession, sharedKey} + pure $ Just call {callState = callState'} + _ -> throwChatError . CECallState $ callStateTag callState + APIEndCall contactId -> + -- any call party + withCurrentCall contactId $ \userId ct Call {callId, chatItemId} -> do + SndMessage {msgId} <- sendDirectContactMessage ct (XCallEnd callId) + CChatItem msgDir _ <- withStore $ \st -> getDirectChatItem st userId contactId chatItemId + let aciContent = case msgDir of + SMDSnd -> ACIContent SMDSnd $ CISndCall CISCallEnded 0 + SMDRcv -> ACIContent SMDRcv $ CIRcvCall CISCallEnded 0 + case aciContent of + ACIContent msgDir' ciContent -> do + updCi <- withStore $ \st -> updateDirectChatItem st userId contactId chatItemId ciContent msgId + toView . CRChatItemUpdated $ AChatItem SCTDirect msgDir' (DirectChat ct) updCi + pure Nothing APIUpdateProfile profile -> withUser (`updateProfile` profile) APIParseMarkdown text -> pure . CRApiParsedMarkdown $ parseMaybeMarkdownList text APIRegisterToken token -> CRNtfTokenStatus <$> withUser (\_ -> withAgent (`registerNtfToken` token)) @@ -693,6 +757,19 @@ processChatCommand = \case withStore $ \st -> do updateFileCancelled st userId fileId updateCIFileStatus st userId fileId ciFileStatus + withCurrentCall :: Int64 -> (UserId -> Contact -> Call -> m (Maybe Call)) -> m ChatResponse + withCurrentCall ctId action = withUser $ \User {userId} -> do + ct <- withStore $ \st -> getContact st userId ctId + callVar <- asks currentCall + withChatLock $ + readTVarIO callVar >>= \case + Nothing -> throwChatError CENoCurrentCall + Just call@Call {contactId} + | ctId == contactId -> do + call_ <- action userId ct call + atomically $ writeTVar callVar call_ + pure CRCmdOk + | otherwise -> throwChatError $ CECallContact contactId -- mobile clients use file paths relative to app directory (e.g. for the reason ios app directory changes on updates), -- so we have to differentiate between the file path stored in db and communicated with frontend, and the file path @@ -947,6 +1024,11 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage XInfoProbe probe -> xInfoProbe ct probe XInfoProbeCheck probeHash -> xInfoProbeCheck ct probeHash XInfoProbeOk probe -> xInfoProbeOk ct probe + XCallInv callId invitation -> xCallInv ct callId invitation msg msgMeta + XCallOffer callId offer -> xCallOffer ct callId offer msg msgMeta + XCallAnswer callId answer -> xCallAnswer ct callId answer msg msgMeta + XCallExtra callId extraInfo -> xCallExtra ct callId extraInfo msg msgMeta + XCallEnd callId -> xCallEnd ct callId msg msgMeta _ -> pure () ackMsgDeliveryEvent conn msgMeta CONF confId connInfo -> do @@ -1260,12 +1342,12 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage newContentMessage :: Contact -> MsgContainer -> RcvMessage -> MsgMeta -> m () newContentMessage ct@Contact {localDisplayName = c} mc msg msgMeta = do + checkIntegrity msgMeta $ toView . CRMsgIntegrityError let (ExtMsgContent content fileInvitation_) = mcExtMsgContent mc ciFile_ <- processFileInvitation fileInvitation_ $ \fi chSize -> withStore $ \st -> createRcvFileTransfer st userId ct fi chSize ci@ChatItem {formattedText} <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvMsgContent content) ciFile_ toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci - checkIntegrity msgMeta $ toView . CRMsgIntegrityError showMsgToast (c <> "> ") content formattedText setActive $ ActiveC c @@ -1280,28 +1362,24 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> m () messageUpdate ct@Contact {contactId} sharedMsgId mc RcvMessage {msgId} msgMeta = do + checkIntegrity msgMeta $ toView . CRMsgIntegrityError CChatItem msgDir ChatItem {meta = CIMeta {itemId}} <- withStore $ \st -> getDirectChatItemBySharedMsgId st userId contactId sharedMsgId case msgDir of SMDRcv -> do updCi <- withStore $ \st -> updateDirectChatItem st userId contactId itemId (CIRcvMsgContent mc) msgId toView . CRChatItemUpdated $ AChatItem SCTDirect SMDRcv (DirectChat ct) updCi - checkIntegrity msgMeta $ toView . CRMsgIntegrityError - SMDSnd -> do - messageError "x.msg.update: contact attempted invalid message update" - checkIntegrity msgMeta $ toView . CRMsgIntegrityError + SMDSnd -> messageError "x.msg.update: contact attempted invalid message update" messageDelete :: Contact -> SharedMsgId -> RcvMessage -> MsgMeta -> m () messageDelete ct@Contact {contactId} sharedMsgId RcvMessage {msgId} msgMeta = do + checkIntegrity msgMeta $ toView . CRMsgIntegrityError CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemId}} <- withStore $ \st -> getDirectChatItemBySharedMsgId st userId contactId sharedMsgId case msgDir of SMDRcv -> do -- TODO allow to locally delete items that were broadcast deleted by sender toCi <- withStore $ \st -> deleteDirectChatItemRcvBroadcast st userId ct itemId msgId toView $ CRChatItemDeleted (AChatItem SCTDirect SMDRcv (DirectChat ct) deletedItem) toCi - checkIntegrity msgMeta $ toView . CRMsgIntegrityError - SMDSnd -> do - messageError "x.msg.del: contact attempted invalid message delete" - checkIntegrity msgMeta $ toView . CRMsgIntegrityError + SMDSnd -> messageError "x.msg.del: contact attempted invalid message delete" newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> MsgMeta -> m () newGroupContentMessage gInfo m@GroupMember {localDisplayName = c} mc msg msgMeta = do @@ -1341,13 +1419,13 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage -- TODO remove once XFile is discontinued processFileInvitation' :: Contact -> FileInvitation -> RcvMessage -> MsgMeta -> m () processFileInvitation' ct@Contact {localDisplayName = c} fInv@FileInvitation {fileName, fileSize} msg msgMeta = do + checkIntegrity msgMeta $ toView . CRMsgIntegrityError -- TODO chunk size has to be sent as part of invitation chSize <- asks $ fileChunkSize . config RcvFileTransfer {fileId} <- withStore $ \st -> createRcvFileTransfer st userId ct fInv chSize let ciFile = Just $ CIFile {fileId, fileName, fileSize, filePath = Nothing, fileStatus = CIFSRcvInvitation} ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvMsgContent $ MCFile "") ciFile toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci - checkIntegrity msgMeta $ toView . CRMsgIntegrityError showToast (c <> "> ") "wants to send a file" setActive $ ActiveC c @@ -1401,8 +1479,8 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage groupMsgToView :: GroupInfo -> ChatItem 'CTGroup 'MDRcv -> MsgMeta -> m () groupMsgToView gInfo ci msgMeta = do - toView . CRNewChatItem $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci checkIntegrity msgMeta $ toView . CRMsgIntegrityError + toView . CRNewChatItem $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci processGroupInvitation :: Contact -> GroupInvitation -> m () processGroupInvitation ct@Contact {localDisplayName = c} inv@(GroupInvitation (MemberIdRole fromMemId fromRole) (MemberIdRole memId memRole) _ _) = do @@ -1443,6 +1521,101 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage r <- withStore $ \st -> matchSentProbe st userId c1 probe forM_ r $ \c2 -> mergeContacts c1 c2 + -- to party accepting call + xCallInv :: Contact -> CallId -> CallInvitation -> RcvMessage -> MsgMeta -> m () + xCallInv ct@Contact {contactId} callId CallInvitation {callType, callDhPubKey} msg msgMeta = do + checkIntegrity msgMeta $ toView . CRMsgIntegrityError + let CallType {capabilities = CallCapabilities {encryption}} = callType + call <- asks currentCall + ci <- + readTVarIO call >>= \case + Just _ -> saveCallItem CISCallMissed + -- showMsgToast (c <> "> ") content formattedText + -- setActive $ ActiveC c + _ -> do + ci <- saveCallItem CISCallPending + dhKeyPair <- if encryption then Just <$> liftIO C.generateKeyPair' else pure Nothing + let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> (snd <$> dhKeyPair)) + callState = CallInvitationReceived {peerCallType = callType, localDhPubKey = fst <$> dhKeyPair, sharedKey} + toView $ CRCallInvitation ct callType sharedKey + atomically . writeTVar call $ Just Call {contactId, callId, chatItemId = chatItemId' ci, callState} + pure ci + toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci + where + saveCallItem status = saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvCall status 0) Nothing + + -- to party initiating call + xCallOffer :: Contact -> CallId -> CallOffer -> RcvMessage -> MsgMeta -> m () + xCallOffer ct callId CallOffer {callType, rtcSession, callDhPubKey} msg msgMeta = do + msgCurrentCall ct callId "x.call.offer" msg msgMeta $ + \call -> case callState call of + CallInvitationSent {localCallType, localDhPrivKey} -> do + let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> localDhPrivKey) + callState' = CallOfferReceived {localCallType, peerCallType = callType, peerCallSession = rtcSession, sharedKey} + -- TODO decide if should askConfirmation + toView CRCallOffer {contact = ct, callType, offer = rtcSession, sharedKey, askConfirmation = False} + pure (Just call {callState = callState'}, Just . ACIContent SMDSnd $ CISndCall CISCallAccepted 0) + _ -> do + msgCallStateError "x.call.offer" call + pure (Just call, Nothing) + + -- to party accepting call + xCallAnswer :: Contact -> CallId -> CallAnswer -> RcvMessage -> MsgMeta -> m () + xCallAnswer ct callId CallAnswer {rtcSession} msg msgMeta = do + msgCurrentCall ct callId "x.call.answer" msg msgMeta $ + \call -> case callState call of + CallOfferSent {localCallType, peerCallType, localCallSession, sharedKey} -> do + let callState' = CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession = rtcSession, sharedKey} + toView $ CRCallAnswer ct rtcSession + pure (Just call {callState = callState'}, Just . ACIContent SMDRcv $ CIRcvCall CISCallNegotiated 0) + _ -> do + msgCallStateError "x.call.answer" call + pure (Just call, Nothing) + + -- to any call party + xCallExtra :: Contact -> CallId -> CallExtraInfo -> RcvMessage -> MsgMeta -> m () + xCallExtra ct callId CallExtraInfo {rtcExtraInfo} msg msgMeta = do + msgCurrentCall ct callId "x.call.extra" msg msgMeta $ + \call -> case callState call of + CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession, sharedKey} -> do + -- TODO update the list of ice servers + let callState' = CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession, sharedKey} + toView $ CRCallExtraInfo ct rtcExtraInfo + pure (Just call {callState = callState'}, Nothing) + _ -> do + msgCallStateError "x.call.answer" call + pure (Just call, Nothing) + + -- to any call party + xCallEnd :: Contact -> CallId -> RcvMessage -> MsgMeta -> m () + xCallEnd ct@Contact {contactId} callId msg msgMeta = do + msgCurrentCall ct callId "x.call.end" msg msgMeta $ + \Call {chatItemId} -> do + toView $ CRCallEnded ct + CChatItem msgDir _ <- withStore $ \st -> getDirectChatItem st userId contactId chatItemId + pure $ case msgDir of + SMDSnd -> (Nothing, Just . ACIContent SMDSnd $ CISndCall CISCallEnded 0) + SMDRcv -> (Nothing, Just . ACIContent SMDRcv $ CIRcvCall CISCallEnded 0) + + msgCurrentCall :: Contact -> CallId -> Text -> RcvMessage -> MsgMeta -> (Call -> m (Maybe Call, Maybe ACIContent)) -> m () + msgCurrentCall ct@Contact {contactId = ctId'} callId' eventName RcvMessage {msgId} msgMeta action = do + checkIntegrity msgMeta $ toView . CRMsgIntegrityError + callVar <- asks currentCall + readTVarIO callVar >>= \case + Nothing -> messageError $ eventName <> ": no current call" + Just call@Call {contactId, callId, chatItemId} + | contactId /= ctId' || callId /= callId' -> messageError $ eventName <> ": wrong contact or callId" + | otherwise -> do + (call', aciContent_) <- action call + atomically $ writeTVar callVar call' + forM_ aciContent_ $ \(ACIContent msgDir ciContent) -> do + updCi <- withStore $ \st -> updateDirectChatItem st userId contactId chatItemId ciContent msgId + toView . CRChatItemUpdated $ AChatItem SCTDirect msgDir (DirectChat ct) updCi + + msgCallStateError :: Text -> Call -> m () + msgCallStateError eventName Call {callState} = + messageError $ eventName <> ": wrong call state " <> T.pack (show $ callStateTag callState) + mergeContacts :: Contact -> Contact -> m () mergeContacts to from = do withStore $ \st -> mergeContactRecords st userId to from diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index 9461ff505f..4559cf1e6e 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -23,6 +23,26 @@ data Call = Call callState :: CallState } +data CallStateTag + = CSTCallInvitationSent + | CSTCallInvitationReceived + | CSTCallOfferSent + | CSTCallOfferReceived + | CSTCallNegotiated + deriving (Show, Generic) + +instance ToJSON CallStateTag where + toJSON = J.genericToJSON . enumJSON $ dropPrefix "CSTCall" + toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CSTCall" + +callStateTag :: CallState -> CallStateTag +callStateTag = \case + CallInvitationSent {} -> CSTCallInvitationSent + CallInvitationReceived {} -> CSTCallInvitationReceived + CallOfferSent {} -> CSTCallOfferSent + CallOfferReceived {} -> CSTCallOfferReceived + CallNegotiated {} -> CSTCallNegotiated + data CallState = CallInvitationSent { localCallType :: CallType, diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 1339256d1d..89a2228f27 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -248,8 +248,8 @@ data ChatResponse | CRPendingSubSummary {pendingSubStatus :: [PendingSubStatus]} | CRSndFileSubError {sndFileTransfer :: SndFileTransfer, chatError :: ChatError} | CRRcvFileSubError {rcvFileTransfer :: RcvFileTransfer, chatError :: ChatError} - | CRCallInvitation {contact :: Contact, callType :: CallType, encryptionKey :: Maybe C.Key} - | CRCallOffer {contact :: Contact, callType :: CallType, offer :: WebRTCSession, encryptionKey :: Maybe C.Key, askConfirmation :: Bool} + | CRCallInvitation {contact :: Contact, callType :: CallType, sharedKey :: Maybe C.Key} + | CRCallOffer {contact :: Contact, callType :: CallType, offer :: WebRTCSession, sharedKey :: Maybe C.Key, askConfirmation :: Bool} | CRCallAnswer {contact :: Contact, answer :: WebRTCSession} | CRCallExtraInfo {contact :: Contact, extraInfo :: WebRTCExtraInfo} | CRCallEnded {contact :: Contact} @@ -336,6 +336,10 @@ data ChatErrorType | CEInvalidQuote | CEInvalidChatItemUpdate | CEInvalidChatItemDelete + | CEHasCurrentCall + | CENoCurrentCall + | CECallContact {contactId :: Int64} + | CECallState {currentCallState :: CallStateTag} | CEAgentVersion | CECommandError {message :: String} deriving (Show, Exception, Generic) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index db8efceac3..b46bf67f15 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -468,7 +468,7 @@ instance ToJSON (CIContent d) where toJSON = J.toJSON . jsonCIContent toEncoding = J.toEncoding . jsonCIContent -data ACIContent = forall d. ACIContent (SMsgDirection d) (CIContent d) +data ACIContent = forall d. MsgDirectionI d => ACIContent (SMsgDirection d) (CIContent d) deriving instance Show ACIContent @@ -553,6 +553,8 @@ data CICallStatus = CISCallPending | CISCallMissed | CISCallRejected -- only possible for received calls, not on type level + | CISCallAccepted + | CISCallNegotiated | CISCallProgress | CISCallEnded | CISCallError @@ -570,6 +572,8 @@ ciCallInfoText status duration = case status of CISCallPending -> "calling..." CISCallMissed -> "missed" CISCallRejected -> "rejected" + CISCallAccepted -> "accepted" + CISCallNegotiated -> "connecting..." CISCallProgress -> "in progress " <> d CISCallEnded -> "ended " <> d CISCallError -> "error" diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index d1835490ed..15e04bf194 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -142,6 +142,7 @@ module Simplex.Chat.Store getChatItemByFileId, updateDirectChatItemStatus, updateDirectChatItem, + updateDirectChatItemNoMsg, deleteDirectChatItemInternal, deleteDirectChatItemRcvBroadcast, deleteDirectChatItemSndBroadcast, @@ -170,7 +171,7 @@ import qualified Data.Aeson as J import Data.Bifunctor (first) import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) -import Data.Either (rights) +import Data.Either (isRight, rights) import Data.Function (on) import Data.Functor (($>)) import Data.Int (Int64) @@ -1286,7 +1287,7 @@ createNewGroup st gVar user groupProfile = "INSERT INTO groups (local_display_name, user_id, group_profile_id, created_at, updated_at) VALUES (?,?,?,?,?)" (displayName, uId, profileId, currentTs, currentTs) groupId <- insertedRowId db - memberId <- randomBytes gVar 12 + memberId <- encodedRandomBytes gVar 12 membership <- createContactMember_ db user groupId user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser currentTs pure $ Right GroupInfo {groupId, localDisplayName = displayName, groupProfile, membership, createdAt = currentTs} @@ -3178,12 +3179,21 @@ updateDirectChatItemStatus st userId contactId itemId itemStatus = updateDirectChatItem :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> UserId -> Int64 -> ChatItemId -> CIContent d -> MessageId -> m (ChatItem 'CTDirect d) updateDirectChatItem st userId contactId itemId newContent msgId = - liftIOEither . withTransaction st $ \db -> updateDirectChatItem_ db userId contactId itemId newContent msgId + liftIOEither . withTransaction st $ \db -> do + currentTs <- liftIO getCurrentTime + ci <- updateDirectChatItem_ db userId contactId itemId newContent currentTs + when (isRight ci) . liftIO $ insertChatItemMessage_ db itemId msgId currentTs + pure ci -updateDirectChatItem_ :: forall d. (MsgDirectionI d) => DB.Connection -> UserId -> Int64 -> ChatItemId -> CIContent d -> MessageId -> IO (Either StoreError (ChatItem 'CTDirect d)) -updateDirectChatItem_ db userId contactId itemId newContent msgId = runExceptT $ do +updateDirectChatItemNoMsg :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> UserId -> Int64 -> ChatItemId -> CIContent d -> m (ChatItem 'CTDirect d) +updateDirectChatItemNoMsg st userId contactId itemId newContent = + liftIOEither . withTransaction st $ \db -> do + currentTs <- liftIO getCurrentTime + updateDirectChatItem_ db userId contactId itemId newContent currentTs + +updateDirectChatItem_ :: forall d. (MsgDirectionI d) => DB.Connection -> UserId -> Int64 -> ChatItemId -> CIContent d -> UTCTime -> IO (Either StoreError (ChatItem 'CTDirect d)) +updateDirectChatItem_ db userId contactId itemId newContent currentTs = runExceptT $ do ci <- ExceptT $ (correctDir =<<) <$> getDirectChatItem_ db userId contactId itemId - currentTs <- liftIO getCurrentTime let newText = ciContentToText newContent liftIO $ do DB.execute @@ -3194,7 +3204,6 @@ updateDirectChatItem_ db userId contactId itemId newContent msgId = runExceptT $ WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? |] (newContent, newText, currentTs, userId, contactId, itemId) - insertChatItemMessage_ db itemId msgId currentTs pure ci {content = newContent, meta = (meta ci) {itemText = newText, itemEdited = True}, formattedText = parseMaybeMarkdownList newText} where correctDir :: CChatItem c -> Either StoreError (ChatItem c d) @@ -3747,15 +3756,18 @@ createWithRandomBytes size gVar create = tryCreate 3 tryCreate :: Int -> IO (Either StoreError a) tryCreate 0 = pure $ Left SEUniqueID tryCreate n = do - id' <- randomBytes gVar size + id' <- encodedRandomBytes gVar size E.try (create id') >>= \case Right x -> pure $ Right x Left e | DB.sqlError e == DB.ErrorConstraint -> tryCreate (n - 1) | otherwise -> pure . Left . SEInternalError $ show e +encodedRandomBytes :: TVar ChaChaDRG -> Int -> IO ByteString +encodedRandomBytes gVar = fmap B64.encode . randomBytes gVar + randomBytes :: TVar ChaChaDRG -> Int -> IO ByteString -randomBytes gVar n = B64.encode <$> (atomically . stateTVar gVar $ randomBytesGenerate n) +randomBytes gVar = atomically . stateTVar gVar . randomBytesGenerate listToEither :: e -> [a] -> Either e a listToEither _ (x : _) = Right x diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 080c25b4f5..1e816ee479 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -661,6 +661,10 @@ viewChatError = \case CEInvalidQuote -> ["cannot reply to this message"] CEInvalidChatItemUpdate -> ["cannot update this item"] CEInvalidChatItemDelete -> ["cannot delete this item"] + CEHasCurrentCall -> ["call already in progress"] + CENoCurrentCall -> ["no call in progress"] + CECallContact _ -> [] + CECallState _ -> [] CEAgentVersion -> ["unsupported agent version"] CECommandError e -> ["bad chat command: " <> plain e] -- e -> ["chat error: " <> sShow e] From 260a758b82ca9e43377551b568406e1d95f6ff7c Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Wed, 4 May 2022 09:09:59 +0400 Subject: [PATCH 46/95] test: fix race condition caused by small file transfer (sender cancelled) (#596) --- tests/ChatTests.hs | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 05e0ea7b6c..47689ab190 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -1027,20 +1027,20 @@ testFileSndCancel = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob - startFileTransfer alice bob + startFileTransfer' alice bob "test_1MB.pdf" "1017.7 KiB / 1042157 bytes" alice ##> "/fc 1" concurrentlyN_ [ do - alice <## "cancelled sending file 1 (test.jpg) to bob" + alice <## "cancelled sending file 1 (test_1MB.pdf) to bob" alice ##> "/fs 1" - alice <## "sending file 1 (test.jpg) cancelled: bob" + alice <## "sending file 1 (test_1MB.pdf) cancelled: bob" alice <## "file transfer cancelled", do - bob <## "alice cancelled sending file 1 (test.jpg)" + bob <## "alice cancelled sending file 1 (test_1MB.pdf)" bob ##> "/fs 1" - bob <## "receiving file 1 (test.jpg) cancelled, received part path: ./tests/tmp/test.jpg" + bob <## "receiving file 1 (test_1MB.pdf) cancelled, received part path: ./tests/tmp/test_1MB.pdf" ] - checkPartialTransfer + checkPartialTransfer "test_1MB.pdf" testFileRcvCancel :: IO () testFileRcvCancel = @@ -1062,7 +1062,7 @@ testFileRcvCancel = alice ##> "/fs 1" alice <## "sending file 1 (test.jpg) cancelled: bob" ] - checkPartialTransfer + checkPartialTransfer "test.jpg" testGroupFileTransfer :: IO () testGroupFileTransfer = @@ -1782,21 +1782,25 @@ withTestChatGroup3Connected' :: String -> IO () withTestChatGroup3Connected' dbPrefix = withTestChatGroup3Connected dbPrefix $ \_ -> pure () startFileTransfer :: TestCC -> TestCC -> IO () -startFileTransfer alice bob = do - alice #> "/f @bob ./tests/fixtures/test.jpg" +startFileTransfer alice bob = + startFileTransfer' alice bob "test.jpg" "136.5 KiB / 139737 bytes" + +startFileTransfer' :: TestCC -> TestCC -> String -> String -> IO () +startFileTransfer' alice bob fileName fileSize = do + alice #> ("/f @bob ./tests/fixtures/" <> fileName) alice <## "use /fc 1 to cancel sending" - bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <# ("alice> sends file " <> fileName <> " (" <> fileSize <> ")") bob <## "use /fr 1 [/ | ] to receive it" bob ##> "/fr 1 ./tests/tmp" - bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" + bob <## ("saving file 1 from alice to ./tests/tmp/" <> fileName) concurrently_ - (bob <## "started receiving file 1 (test.jpg) from alice") - (alice <## "started sending file 1 (test.jpg) to bob") + (bob <## ("started receiving file 1 (" <> fileName <> ") from alice")) + (alice <## ("started sending file 1 (" <> fileName <> ") to bob")) -checkPartialTransfer :: IO () -checkPartialTransfer = do - src <- B.readFile "./tests/fixtures/test.jpg" - dest <- B.readFile "./tests/tmp/test.jpg" +checkPartialTransfer :: String -> IO () +checkPartialTransfer fileName = do + src <- B.readFile $ "./tests/fixtures/" <> fileName + dest <- B.readFile $ "./tests/tmp/" <> fileName B.unpack src `shouldStartWith` B.unpack dest B.length src > B.length dest `shouldBe` True From 2c1ad9a6419c9c9002321d873e2d61bbb67a3590 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Wed, 4 May 2022 09:10:36 +0400 Subject: [PATCH 47/95] mobile: support receiving files (#584) Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../java/chat/simplex/app/model/ChatModel.kt | 37 ++- .../java/chat/simplex/app/model/SimpleXAPI.kt | 50 ++-- .../java/chat/simplex/app/ui/theme/Color.kt | 1 + .../chat/simplex/app/views/chat/ChatView.kt | 30 ++- .../simplex/app/views/chat/ComposeView.kt | 1 + .../simplex/app/views/chat/item/CIFileView.kt | 222 ++++++++++++++++++ .../app/views/chat/item/CIImageView.kt | 70 +++++- .../app/views/chat/item/ChatItemView.kt | 29 ++- .../app/views/chat/item/FramedItemView.kt | 25 +- .../chat/simplex/app/views/helpers/Share.kt | 28 +++ .../chat/simplex/app/views/helpers/Util.kt | 2 + .../app/src/main/res/values-ru/strings.xml | 16 +- .../app/src/main/res/values/strings.xml | 16 +- apps/ios/Shared/FileUtils.swift | 4 +- apps/ios/Shared/Model/Shared/APITypes.swift | 7 +- apps/ios/Shared/Model/Shared/ChatTypes.swift | 20 +- apps/ios/Shared/Model/SimpleXAPI.swift | 45 ++-- .../Views/Chat/ChatItem/FramedItemView.swift | 5 + apps/ios/Shared/Views/Chat/ChatView.swift | 6 + .../Chat/ComposeMessage/ComposeView.swift | 2 + .../ios/Shared/Views/Helpers/CIFileView.swift | 177 ++++++++++++++ .../Shared/Views/Helpers/CIImageView.swift | 39 ++- apps/ios/SimpleX.xcodeproj/project.pbxproj | 6 + 23 files changed, 759 insertions(+), 79 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt create mode 100644 apps/ios/Shared/Views/Helpers/CIFileView.swift diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index b2d03c0c08..b4ed65d1f5 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -675,6 +675,21 @@ data class ChatItem ( file = file ) + fun getFileMsgContentSample( + id: Long = 1, + text: String = "", + fileName: String = "test.txt", + fileSize: Long = 100, + fileStatus: CIFileStatus = CIFileStatus.RcvComplete + ) = + ChatItem( + chatDir = CIDirection.DirectRcv(), + meta = CIMeta.getSample(id, Clock.System.now(), text, CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false), + content = CIContent.RcvMsgContent(msgContent = MsgContent.MCFile(text)), + quotedItem = null, + file = CIFile.getSample(fileName = fileName, fileSize = fileSize, fileStatus = fileStatus) + ) + fun getDeletedContentSampleData( id: Long = 1, dir: CIDirection = CIDirection.DirectRcv(), @@ -858,7 +873,13 @@ class CIFile( } companion object { - fun getSample(fileId: Long, fileName: String, fileSize: Long, filePath: String?, fileStatus: CIFileStatus = CIFileStatus.SndStored): CIFile = + fun getSample( + fileId: Long = 1, + fileName: String = "test.txt", + fileSize: Long = 100, + filePath: String? = "test.txt", + fileStatus: CIFileStatus = CIFileStatus.RcvComplete + ): CIFile = CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, filePath = filePath, fileStatus = fileStatus) } } @@ -868,6 +889,7 @@ enum class CIFileStatus { @SerialName("snd_stored") SndStored, @SerialName("snd_cancelled") SndCancelled, @SerialName("rcv_invitation") RcvInvitation, + @SerialName("rcv_accepted") RcvAccepted, @SerialName("rcv_transfer") RcvTransfer, @SerialName("rcv_complete") RcvComplete, @SerialName("rcv_cancelled") RcvCancelled; @@ -887,6 +909,9 @@ sealed class MsgContent { @Serializable(with = MsgContentSerializer::class) class MCImage(override val text: String, val image: String): MsgContent() + @Serializable(with = MsgContentSerializer::class) + class MCFile(override val text: String): MsgContent() + @Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent() @@ -894,6 +919,7 @@ sealed class MsgContent { is MCText -> "text $text" is MCLink -> "json ${json.encodeToString(this)}" is MCImage -> "json ${json.encodeToString(this)}" + is MCFile -> "json ${json.encodeToString(this)}" is MCUnknown -> "json $json" } } @@ -911,6 +937,9 @@ object MsgContentSerializer : KSerializer { element("text") element("image") }) + element("MCFile", buildClassSerialDescriptor("MCFile") { + element("text") + }) element("MCUnknown", buildClassSerialDescriptor("MCUnknown")) } @@ -931,6 +960,7 @@ object MsgContentSerializer : KSerializer { val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format" MsgContent.MCImage(text, image) } + "file" -> MsgContent.MCFile(text) else -> MsgContent.MCUnknown(t, text, json) } } else { @@ -961,6 +991,11 @@ object MsgContentSerializer : KSerializer { put("text", value.text) put("image", value.image) } + is MsgContent.MCFile -> + buildJsonObject { + put("type", "file") + put("text", value.text) + } is MsgContent.MCUnknown -> value.json } encoder.encodeJsonElement(json) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 6e6f172f4e..c7381861f4 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -327,11 +327,11 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt return false } - suspend fun receiveFile(fileId: Long): Boolean { + suspend fun apiReceiveFile(fileId: Long): AChatItem? { val r = sendCmd(CC.ReceiveFile(fileId)) - if (r is CR.RcvFileAccepted) return true + if (r is CR.RcvFileAccepted) return r.chatItem Log.e(TAG, "receiveFile bad response: ${r.responseType} ${r.details}") - return false + return null } fun apiErrorAlert(method: String, title: String, r: CR) { @@ -390,8 +390,13 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt val cItem = r.chatItem.chatItem chatModel.addChatItem(cInfo, cItem) val file = cItem.file - if (file != null && file.fileSize <= MAX_IMAGE_SIZE) { - withApi {receiveFile(file.fileId)} + if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE) { + withApi { + val chatItem = apiReceiveFile(file.fileId) + if (chatItem != null) { + chatItemSimpleUpdate(chatItem) + } + } } if (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id) { ntfManager.notifyMessageReceived(cInfo, cItem) @@ -408,13 +413,8 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt ntfManager.notifyMessageReceived(cInfo, cItem) } } - is CR.ChatItemUpdated -> { - val cInfo = r.chatItem.chatInfo - val cItem = r.chatItem.chatItem - if (chatModel.upsertChatItem(cInfo, cItem)) { - ntfManager.notifyMessageReceived(cInfo, cItem) - } - } + is CR.ChatItemUpdated -> + chatItemSimpleUpdate(r.chatItem) is CR.ChatItemDeleted -> { val cInfo = r.toChatItem.chatInfo val cItem = r.toChatItem.chatItem @@ -425,18 +425,23 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt chatModel.upsertChatItem(cInfo, cItem) } } - is CR.RcvFileComplete -> { - val cInfo = r.chatItem.chatInfo - val cItem = r.chatItem.chatItem - if (chatModel.upsertChatItem(cInfo, cItem)) { - ntfManager.notifyMessageReceived(cInfo, cItem) - } - } + is CR.RcvFileStart -> + chatItemSimpleUpdate(r.chatItem) + is CR.RcvFileComplete -> + chatItemSimpleUpdate(r.chatItem) else -> Log.d(TAG , "unsupported event: ${r.responseType}") } } + private fun chatItemSimpleUpdate(aChatItem: AChatItem) { + val cInfo = aChatItem.chatInfo + val cItem = aChatItem.chatItem + if (chatModel.upsertChatItem(cInfo, cItem)) { + ntfManager.notifyMessageReceived(cInfo, cItem) + } + } + fun updateContactsStatus(contactRefs: List, status: Chat.NetworkStatus) { for (c in contactRefs) { chatModel.updateNetworkStatus(c.id, status) @@ -681,7 +686,8 @@ sealed class CR { @Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val chatItem: AChatItem): CR() @Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val chatItem: AChatItem): CR() @Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val deletedChatItem: AChatItem, val toChatItem: AChatItem): CR() - @Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted: CR() + @Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val chatItem: AChatItem): CR() + @Serializable @SerialName("rcvFileStart") class RcvFileStart(val chatItem: AChatItem): CR() @Serializable @SerialName("rcvFileComplete") class RcvFileComplete(val chatItem: AChatItem): CR() @Serializable @SerialName("newContactConnection") class NewContactConnection(val connection: PendingContactConnection): CR() @Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val connection: PendingContactConnection): CR() @@ -728,6 +734,7 @@ sealed class CR { is ChatItemUpdated -> "chatItemUpdated" is ChatItemDeleted -> "chatItemDeleted" is RcvFileAccepted -> "rcvFileAccepted" + is RcvFileStart -> "rcvFileStart" is RcvFileComplete -> "rcvFileComplete" is NewContactConnection -> "newContactConnection" is ContactConnectionDeleted -> "contactConnectionDeleted" @@ -774,7 +781,8 @@ sealed class CR { is ChatItemStatusUpdated -> json.encodeToString(chatItem) is ChatItemUpdated -> json.encodeToString(chatItem) is ChatItemDeleted -> "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}" - is RcvFileAccepted -> noDetails() + is RcvFileAccepted -> json.encodeToString(chatItem) + is RcvFileStart -> json.encodeToString(chatItem) is RcvFileComplete -> json.encodeToString(chatItem) is NewContactConnection -> json.encodeToString(connection) is ContactConnectionDeleted -> json.encodeToString(connection) diff --git a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt index 7aeecca387..2e8d94c47f 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt @@ -15,3 +15,4 @@ val DarkGray = Color(43, 44, 46, 255) val HighOrLowlight = Color(134, 135, 139, 255) val ToolbarLight = Color(220, 220, 220, 20) val ToolbarDark = Color(80, 80, 80, 20) +val WarningOrange = Color(255, 149, 0, 255) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index 880c423ef8..5aa7a98237 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -102,6 +102,16 @@ fun ChatView(chatModel: ChatModel) { ) if (toItem != null) chatModel.removeChatItem(cInfo, toItem.chatItem) } + }, + receiveFile = { fileId -> + withApi { + val chatItem = chatModel.controller.apiReceiveFile(fileId) + if (chatItem != null) { + val cInfo = chatItem.chatInfo + val cItem = chatItem.chatItem + chatModel.upsertChatItem(cInfo, cItem) + } + } } ) } @@ -120,7 +130,8 @@ fun ChatLayout( back: () -> Unit, info: () -> Unit, openDirectChat: (Long) -> Unit, - deleteMessage: (Long, CIDeleteMode) -> Unit + deleteMessage: (Long, CIDeleteMode) -> Unit, + receiveFile: (Long) -> Unit ) { fun onImageChange(bitmap: Bitmap) { val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000) @@ -153,7 +164,7 @@ fun ChatLayout( modifier = Modifier.navigationBarsWithImePadding() ) { contentPadding -> Box(Modifier.padding(contentPadding)) { - ChatItemsList(user, chat, composeState, chatItems, openDirectChat, deleteMessage) + ChatItemsList(user, chat, composeState, chatItems, openDirectChat, deleteMessage, receiveFile) } } } @@ -230,7 +241,8 @@ fun ChatItemsList( composeState: MutableState, chatItems: List, openDirectChat: (Long) -> Unit, - deleteMessage: (Long, CIDeleteMode) -> Unit + deleteMessage: (Long, CIDeleteMode) -> Unit, + receiveFile: (Long) -> Unit ) { val listState = rememberLazyListState(initialFirstVisibleItemIndex = chatItems.size - chatItems.count { it.isRcvNew }) val keyboardState by getKeyboardState() @@ -268,11 +280,11 @@ fun ChatItemsList( } else { Spacer(Modifier.size(42.dp)) } - ChatItemView(user, cItem, composeState, cxt, uriHandler, showMember = showMember, deleteMessage = deleteMessage) + ChatItemView(user, cItem, composeState, cxt, uriHandler, showMember = showMember, deleteMessage = deleteMessage, receiveFile = receiveFile) } } else { Box(Modifier.padding(start = 86.dp, end = 12.dp)) { - ChatItemView(user, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage) + ChatItemView(user, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage, receiveFile = receiveFile) } } } else { // direct message @@ -283,7 +295,7 @@ fun ChatItemsList( end = if (sent) 12.dp else 76.dp, ) ) { - ChatItemView(user, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage) + ChatItemView(user, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage, receiveFile = receiveFile) } } } @@ -350,7 +362,8 @@ fun PreviewChatLayout() { back = {}, info = {}, openDirectChat = {}, - deleteMessage = { _, _ -> } + deleteMessage = { _, _ -> }, + receiveFile = {} ) } } @@ -393,7 +406,8 @@ fun PreviewGroupChatLayout() { back = {}, info = {}, openDirectChat = {}, - deleteMessage = { _, _ -> } + deleteMessage = { _, _ -> }, + receiveFile = {} ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index aae695d4b9..05ab4d284b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -164,6 +164,7 @@ fun ComposeView( is MsgContent.MCText -> checkLinkPreview() is MsgContent.MCLink -> checkLinkPreview() is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image) + is MsgContent.MCFile -> MsgContent.MCFile(cs.message) is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt new file mode 100644 index 0000000000..eaf02c10a5 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt @@ -0,0 +1,222 @@ +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.InsertDriveFile +import androidx.compose.material.icons.outlined.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.* +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.R +import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.chat.item.FramedItemView +import chat.simplex.app.views.helpers.* +import kotlinx.datetime.Clock +import kotlin.math.log2 +import kotlin.math.pow + +@Composable +fun CIFileView( + file: CIFile?, + edited: Boolean, + receiveFile: (Long) -> Unit +) { + val context = LocalContext.current + val saveFileLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument(), + onResult = { destination -> + saveFile(context, file, destination) + } + ) + + @Composable + fun fileIcon(innerIcon: ImageVector? = null, color: Color = HighOrLowlight) { + Box( + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Filled.InsertDriveFile, + stringResource(R.string.icon_descr_file), + Modifier.fillMaxSize(), + tint = color + ) + if (innerIcon != null) { + Icon( + innerIcon, + stringResource(R.string.icon_descr_file), + Modifier + .size(32.dp) + .padding(top = 12.dp), + tint = Color.White + ) + } + } + } + + fun fileSizeValid(): Boolean { + if (file != null) { + return file.fileSize <= MAX_FILE_SIZE + } + return false + } + + fun fileAction() { + if (file != null) { + when (file.fileStatus) { + CIFileStatus.RcvInvitation -> { + if (fileSizeValid()) { + receiveFile(file.fileId) + } else { + AlertManager.shared.showAlertMsg( + generalGetString(R.string.large_file), + String.format(generalGetString(R.string.contact_sent_large_file), MAX_FILE_SIZE) + ) + } + } + CIFileStatus.RcvAccepted -> + AlertManager.shared.showAlertMsg( + generalGetString(R.string.waiting_for_file), + String.format(generalGetString(R.string.file_will_be_received_when_contact_is_online), MAX_FILE_SIZE) + ) + CIFileStatus.RcvComplete -> { + val filePath = getStoredFilePath(context, file) + if (filePath != null) { + saveFileLauncher.launch(file.fileName) + } else { + Toast.makeText(context, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show() + } + } + else -> {} + } + } + } + + @Composable + fun fileIndicator() { + Box( + Modifier.size(44.dp), + contentAlignment = Alignment.Center + ) { + if (file != null) { + when (file.fileStatus) { + CIFileStatus.SndCancelled -> fileIcon(innerIcon = Icons.Outlined.Close) + CIFileStatus.RcvInvitation -> + if (fileSizeValid()) + fileIcon(innerIcon = Icons.Outlined.ArrowDownward, color = MaterialTheme.colors.primary) + else + fileIcon(innerIcon = Icons.Outlined.PriorityHigh, color = WarningOrange) + CIFileStatus.RcvAccepted -> fileIcon(innerIcon = Icons.Outlined.MoreHoriz) + CIFileStatus.RcvTransfer -> + CircularProgressIndicator( + Modifier.size(36.dp), + color = HighOrLowlight, + strokeWidth = 4.dp + ) + CIFileStatus.RcvCancelled -> fileIcon(innerIcon = Icons.Outlined.Close) + else -> fileIcon() + } + } else { + fileIcon() + } + } + } + + fun formatBytes(bytes: Long): String { + if (bytes == 0.toLong()) { + return "0 bytes" + } + val bytesDouble = bytes.toDouble() + val k = 1000.toDouble() + val units = arrayOf("bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") + val i = kotlin.math.floor(log2(bytesDouble) / log2(k)) + val size = bytesDouble / k.pow(i) + val unit = units[i.toInt()] + + return if (i <= 1) { + String.format("%.0f %s", size, unit) + } else { + String.format("%.2f %s", size, unit) + } + } + + Row( + Modifier + .padding(top = 4.dp, bottom = 6.dp, start = 10.dp, end = 12.dp) + .clickable(onClick = { fileAction() }), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + fileIndicator() + val metaReserve = if (edited) + " " + else + " " + if (file != null) { + Column( + horizontalAlignment = Alignment.Start + ) { + Text( + file.fileName, + maxLines = 1 + ) + Text( + formatBytes(file.fileSize) + metaReserve, + color = HighOrLowlight, + fontSize = 14.sp, + maxLines = 1 + ) + } + } else { + Text(metaReserve) + } + } +} + +class ChatItemProvider: PreviewParameterProvider { + private val sentFile = ChatItem( + chatDir = CIDirection.DirectSnd(), + meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.SndSent(), itemDeleted = false, itemEdited = true, editable = false), + content = CIContent.SndMsgContent(msgContent = MsgContent.MCFile("")), + quotedItem = null, + file = CIFile.getSample(fileStatus = CIFileStatus.SndStored) + ) + private val fileChatItemWtFile = ChatItem( + chatDir = CIDirection.DirectRcv(), + meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false), + content = CIContent.RcvMsgContent(msgContent = MsgContent.MCFile("")), + quotedItem = null, + file = null + ) + override val values = listOf( + sentFile, + ChatItem.getFileMsgContentSample(), + ChatItem.getFileMsgContentSample(fileName = "some_long_file_name_here", fileStatus = CIFileStatus.RcvInvitation), + ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvAccepted), + ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvTransfer), + ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvCancelled), + ChatItem.getFileMsgContentSample(fileSize = 2000000, fileStatus = CIFileStatus.RcvInvitation), + ChatItem.getFileMsgContentSample(text = "Hello there", fileStatus = CIFileStatus.RcvInvitation), + ChatItem.getFileMsgContentSample(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus = CIFileStatus.RcvInvitation), + fileChatItemWtFile + ).asSequence() +} + +@Preview +@Composable +fun PreviewTextItemViewSnd(@PreviewParameter(ChatItemProvider::class) chatItem: ChatItem) { + val showMenu = remember { mutableStateOf(false) } + SimpleXTheme { + FramedItemView(User.sampleData, chatItem, showMenu = showMenu, receiveFile = {}) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt index 2f24e9f249..8df4976e8b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt @@ -1,15 +1,21 @@ import android.graphics.Bitmap import androidx.compose.foundation.* import androidx.compose.foundation.layout.* +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.MoreHoriz import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import chat.simplex.app.R -import chat.simplex.app.model.CIFile +import chat.simplex.app.model.* import chat.simplex.app.views.helpers.* @Composable @@ -18,12 +24,37 @@ fun CIImageView( file: CIFile?, showMenu: MutableState ) { - Column { - val context = LocalContext.current - var imageBitmap: Bitmap? = getStoredImage(context, file) - if (imageBitmap == null) { - imageBitmap = base64ToBitmap(image) + @Composable + fun loadingIndicator() { + if (file != null) { + Box( + Modifier + .padding(8.dp) + .size(20.dp), + contentAlignment = Alignment.Center + ) { + when (file.fileStatus) { + CIFileStatus.RcvAccepted -> + Icon( + Icons.Outlined.MoreHoriz, + stringResource(R.string.icon_descr_waiting_for_image), + Modifier.fillMaxSize(), + tint = Color.White + ) + CIFileStatus.RcvTransfer -> + CircularProgressIndicator( + Modifier.size(16.dp), + color = Color.White, + strokeWidth = 2.dp + ) + else -> {} + } + } } + } + + @Composable + fun imageView(imageBitmap: Bitmap, onClick: () -> Unit) { Image( imageBitmap.asImageBitmap(), contentDescription = stringResource(R.string.image_descr), @@ -33,13 +64,30 @@ fun CIImageView( .width(1000.dp) .combinedClickable( onLongClick = { showMenu.value = true }, - onClick = { - if (getStoredFilePath(context, file) != null) { - ModalManager.shared.showCustomModal { close -> ImageFullScreenView(imageBitmap, close) } - } - } + onClick = onClick ), contentScale = ContentScale.FillWidth, ) } + + Box(contentAlignment = Alignment.TopEnd) { + val context = LocalContext.current + val imageBitmap: Bitmap? = getStoredImage(context, file) + if (imageBitmap != null) { + imageView(imageBitmap, onClick = { + if (getStoredFilePath(context, file) != null) { + ModalManager.shared.showCustomModal { close -> ImageFullScreenView(imageBitmap, close) } + } + }) + } else { + imageView(base64ToBitmap(image), onClick = { + if (file != null && file.fileStatus == CIFileStatus.RcvAccepted) + AlertManager.shared.showAlertMsg( + generalGetString(R.string.waiting_for_image), + generalGetString(R.string.image_will_be_received_when_contact_is_online) + ) + }) + } + loadingIndicator() + } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index d005e1ea3f..5e87e6b2f7 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -1,6 +1,8 @@ package chat.simplex.app.views.chat.item import android.content.Context +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -33,11 +35,19 @@ fun ChatItemView( cxt: Context, uriHandler: UriHandler? = null, showMember: Boolean = false, - deleteMessage: (Long, CIDeleteMode) -> Unit + deleteMessage: (Long, CIDeleteMode) -> Unit, + receiveFile: (Long) -> Unit ) { + val context = LocalContext.current val sent = cItem.chatDir.sent val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart val showMenu = remember { mutableStateOf(false) } + val saveFileLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument(), + onResult = { destination -> + saveFile(context, cItem.file, destination) + } + ) Box( modifier = Modifier .padding(bottom = 4.dp) @@ -49,7 +59,7 @@ fun ChatItemView( if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) { EmojiItemView(cItem) } else { - FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu) + FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu, receiveFile) } } else if (cItem.isDeletedContent) { DeletedItemView(cItem, showMember = showMember) @@ -72,6 +82,15 @@ fun ChatItemView( copyText(cxt, cItem.content.text) showMenu.value = false }) + if (cItem.content.msgContent is MsgContent.MCImage) { + val filePath = getStoredFilePath(context, cItem.file) + if (filePath != null) { + ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = { + saveFileLauncher.launch(cItem.file?.fileName) + showMenu.value = false + }) + } + } if (cItem.chatDir.sent && cItem.meta.editable) { ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = { composeState.value = ComposeState(editingItem = cItem) @@ -147,7 +166,8 @@ fun PreviewChatItemView() { ), composeState = remember { mutableStateOf(ComposeState()) }, cxt = LocalContext.current, - deleteMessage = { _, _ -> } + deleteMessage = { _, _ -> }, + receiveFile = {} ) } } @@ -161,7 +181,8 @@ fun PreviewChatItemViewDeletedContent() { ChatItem.getDeletedContentSampleData(), composeState = remember { mutableStateOf(ComposeState()) }, cxt = LocalContext.current, - deleteMessage = { _, _ -> } + deleteMessage = { _, _ -> }, + receiveFile = {} ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt index 282b879c2c..4a184cc37d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt @@ -1,5 +1,6 @@ package chat.simplex.app.views.chat.item +import CIFileView import CIImageView import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -38,7 +39,8 @@ fun FramedItemView( ci: ChatItem, uriHandler: UriHandler? = null, showMember: Boolean = false, - showMenu: MutableState + showMenu: MutableState, + receiveFile: (Long) -> Unit ) { val sent = ci.chatDir.sent Surface( @@ -101,6 +103,12 @@ fun FramedItemView( CIMarkdownText(ci, showMember, uriHandler) } } + is MsgContent.MCFile -> { + CIFileView(ci.file, ci.meta.itemEdited, receiveFile) + if (mc.text != "") { + CIMarkdownText(ci, showMember, uriHandler) + } + } is MsgContent.MCLink -> { ChatItemLinkView(mc.preview) CIMarkdownText(ci, showMember, uriHandler) @@ -141,7 +149,8 @@ fun PreviewTextItemViewSnd(@PreviewParameter(EditedProvider::class) edited: Bool ChatItem.getSampleData( 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", itemEdited = edited, ), - showMenu = showMenu + showMenu = showMenu, + receiveFile = {} ) } } @@ -156,7 +165,8 @@ fun PreviewTextItemViewRcv(@PreviewParameter(EditedProvider::class) edited: Bool ChatItem.getSampleData( 1, CIDirection.DirectRcv(), Clock.System.now(), "hello", itemEdited = edited ), - showMenu = showMenu + showMenu = showMenu, + receiveFile = {} ) } } @@ -175,7 +185,8 @@ fun PreviewTextItemViewLong(@PreviewParameter(EditedProvider::class) edited: Boo "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", itemEdited = edited ), - showMenu = showMenu + showMenu = showMenu, + receiveFile = {} ) } } @@ -195,7 +206,8 @@ fun PreviewTextItemViewQuote(@PreviewParameter(EditedProvider::class) edited: Bo quotedItem = CIQuote.getSample(1, Clock.System.now(), "hi", chatDir = CIDirection.DirectRcv()), itemEdited = edited ), - showMenu = showMenu + showMenu = showMenu, + receiveFile = {} ) } } @@ -215,7 +227,8 @@ fun PreviewTextItemViewEmoji(@PreviewParameter(EditedProvider::class) edited: Bo quotedItem = CIQuote.getSample(1, Clock.System.now(), "Lorem ipsum dolor sit amet", chatDir = CIDirection.DirectRcv()), itemEdited = edited ), - showMenu = showMenu + showMenu = showMenu, + receiveFile = {} ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt index 5cac836f14..ad68516caa 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt @@ -1,7 +1,13 @@ package chat.simplex.app.views.helpers import android.content.* +import android.net.Uri +import android.widget.Toast import androidx.core.content.ContextCompat +import chat.simplex.app.R +import chat.simplex.app.model.CIFile +import java.io.File +import java.io.IOException fun shareText(cxt: Context, text: String) { val sendIntent: Intent = Intent().apply { @@ -17,3 +23,25 @@ fun copyText(cxt: Context, text: String) { val clipboard = ContextCompat.getSystemService(cxt, ClipboardManager::class.java) clipboard?.setPrimaryClip(ClipData.newPlainText("text", text)) } + +fun saveFile(cxt: Context, ciFile: CIFile?, destination: Uri?) { + if (destination != null) { + val filePath = getStoredFilePath(cxt, ciFile) + if (filePath != null) { + val contentResolver = cxt.contentResolver + val file = File(filePath) + try { + val outputStream = contentResolver.openOutputStream(destination) + if (outputStream != null) { + outputStream.write(file.readBytes()) + outputStream.close() + Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show() + } + } catch (e: IOException) { + Toast.makeText(cxt, generalGetString(R.string.error_saving_file), Toast.LENGTH_SHORT).show() + } + } else { + Toast.makeText(cxt, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show() + } + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt index 20a682488b..b4970f12e1 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt @@ -204,6 +204,8 @@ private fun spannableStringToAnnotatedString( // maximum image file size to be auto-accepted const val MAX_IMAGE_SIZE = 236700 +const val MAX_FILE_SIZE = 1893600 + fun getFilesDirectory(context: Context): String { return context.filesDir.toString() } diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index 5d42f5fb02..b601587f99 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -65,6 +65,7 @@ Ответить Поделиться Копировать + Сохранить Редактировать Удалить Удалить сообщение? @@ -91,7 +92,20 @@ Изображение - удалить превью изображения + Удалить превью изображения + Ожидание изображения + Ожидание изображения + Изображение будет получено, когда ваш контакт будет в сети, пожалуйста, подождите или проверьте позже! + + + Файл + Большой файл! + Ваш контакт отправил файл, размер которого превышает поддерживаемый в настоящее время максимальный размер (%1$s байта). + Ожидание файла + Файл будет получен, когда ваш контакт будет в сети, пожалуйста, подождите или проверьте позже! + Файл сохранен + Файл не найден + Ошибка сохранения файла Удалить контакт? diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 73219eff59..c2a15bee79 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -65,6 +65,7 @@ Reply Share Copy + Save Edit Delete Delete message? @@ -91,7 +92,20 @@ Image - cancel image preview + Cancel image preview + Waiting for image + Waiting for image + Image will be received when your contact is online, please wait or check later! + + + File + Large file! + Your contact sent a file that is larger than currently supported maximum size (%1$s bytes). + Waiting for file + File will be received when your contact is online, please wait or check later! + File saved + File not found + Error saving file Delete contact? diff --git a/apps/ios/Shared/FileUtils.swift b/apps/ios/Shared/FileUtils.swift index 5739cc6d59..e6cf6fa140 100644 --- a/apps/ios/Shared/FileUtils.swift +++ b/apps/ios/Shared/FileUtils.swift @@ -12,6 +12,8 @@ import SwiftUI // maximum image file size to be auto-accepted let maxImageSize = 236700 +let maxFileSize = 1893600 + func getDocumentsDirectory() -> URL { return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! } @@ -24,7 +26,7 @@ func getStoredFilePath(_ file: CIFile?) -> String? { if let file = file, file.stored, let savedFile = file.filePath { - return getAppFilesDirectory().path + "/" + savedFile + return getAppFilesDirectory().appendingPathComponent(savedFile).path } return nil } diff --git a/apps/ios/Shared/Model/Shared/APITypes.swift b/apps/ios/Shared/Model/Shared/APITypes.swift index c813ee23ff..65178c7378 100644 --- a/apps/ios/Shared/Model/Shared/APITypes.swift +++ b/apps/ios/Shared/Model/Shared/APITypes.swift @@ -167,7 +167,8 @@ enum ChatResponse: Decodable, Error { case chatItemStatusUpdated(chatItem: AChatItem) case chatItemUpdated(chatItem: AChatItem) case chatItemDeleted(deletedChatItem: AChatItem, toChatItem: AChatItem) - case rcvFileAccepted + case rcvFileAccepted(chatItem: AChatItem) + case rcvFileStart(chatItem: AChatItem) case rcvFileComplete(chatItem: AChatItem) case ntfTokenStatus(status: NtfTknStatus) case newContactConnection(connection: PendingContactConnection) @@ -216,6 +217,7 @@ enum ChatResponse: Decodable, Error { case .chatItemUpdated: return "chatItemUpdated" case .chatItemDeleted: return "chatItemDeleted" case .rcvFileAccepted: return "rcvFileAccepted" + case .rcvFileStart: return "rcvFileStart" case .rcvFileComplete: return "rcvFileComplete" case .ntfTokenStatus: return "ntfTokenStatus" case .newContactConnection: return "newContactConnection" @@ -266,7 +268,8 @@ enum ChatResponse: Decodable, Error { case let .chatItemStatusUpdated(chatItem): return String(describing: chatItem) case let .chatItemUpdated(chatItem): return String(describing: chatItem) case let .chatItemDeleted(deletedChatItem, toChatItem): return "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))" - case .rcvFileAccepted: return noDetails + case let .rcvFileAccepted(chatItem): return String(describing: chatItem) + case let .rcvFileStart(chatItem): return String(describing: chatItem) case let .rcvFileComplete(chatItem): return String(describing: chatItem) case let .ntfTokenStatus(status): return String(describing: status) case let .newContactConnection(connection): return String(describing: connection) diff --git a/apps/ios/Shared/Model/Shared/ChatTypes.swift b/apps/ios/Shared/Model/Shared/ChatTypes.swift index 0b3e90a6ec..e02586d4d9 100644 --- a/apps/ios/Shared/Model/Shared/ChatTypes.swift +++ b/apps/ios/Shared/Model/Shared/ChatTypes.swift @@ -480,6 +480,16 @@ struct ChatItem: Identifiable, Decodable { ) } + static func getFileMsgContentSample (id: Int64 = 1, text: String = "", fileName: String = "test.txt", fileSize: Int64 = 100, fileStatus: CIFileStatus = .rcvComplete) -> ChatItem { + ChatItem( + chatDir: .directRcv, + meta: CIMeta.getSample(id, .now, text, .rcvRead, false, false, false), + content: .rcvMsgContent(msgContent: .file(text)), + quotedItem: nil, + file: CIFile.getSample(fileName: fileName, fileSize: fileSize, fileStatus: fileStatus) + ) + } + static func getDeletedContentSample (_ id: Int64 = 1, dir: CIDirection = .directRcv, _ ts: Date = .now, _ text: String = "this item is deleted", _ status: CIStatus = .rcvRead) -> ChatItem { ChatItem( chatDir: dir, @@ -629,7 +639,7 @@ struct CIFile: Decodable { var filePath: String? var fileStatus: CIFileStatus - static func getSample(_ fileId: Int64, _ fileName: String, _ fileSize: Int64, filePath: String?, fileStatus: CIFileStatus = .sndStored) -> CIFile { + static func getSample(fileId: Int64 = 1, fileName: String = "test.txt", fileSize: Int64 = 100, filePath: String? = "test.txt", fileStatus: CIFileStatus = .rcvComplete) -> CIFile { CIFile(fileId: fileId, fileName: fileName, fileSize: fileSize, filePath: filePath, fileStatus: fileStatus) } @@ -649,6 +659,7 @@ enum CIFileStatus: String, Decodable { case sndStored = "snd_stored" case sndCancelled = "snd_cancelled" case rcvInvitation = "rcv_invitation" + case rcvAccepted = "rcv_accepted" case rcvTransfer = "rcv_transfer" case rcvComplete = "rcv_complete" case rcvCancelled = "rcv_cancelled" @@ -658,6 +669,7 @@ enum MsgContent { case text(String) case link(text: String, preview: LinkPreview) case image(text: String, image: String) + case file(String) // TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift case unknown(type: String, text: String) @@ -667,6 +679,7 @@ enum MsgContent { case let .text(text): return text case let .link(text, _): return text case let .image(text, _): return text + case let .file(text): return text case let .unknown(_, text): return text } } @@ -680,6 +693,7 @@ enum MsgContent { return "json {\"type\":\"link\",\"text\":\(encodeJSON(text)),\"preview\":\(encodeJSON(preview))}" case let .image(text: text, image: image): return "json {\"type\":\"image\",\"text\":\(encodeJSON(text)),\"image\":\(encodeJSON(image))}" + case let .file(text): return "json {\"type\":\"file\",\"text\":\(encodeJSON(text))}" default: return "" } } @@ -690,6 +704,7 @@ enum MsgContent { case text case preview case image + case file } } @@ -711,6 +726,9 @@ extension MsgContent: Decodable { let text = try container.decode(String.self, forKey: CodingKeys.text) let image = try container.decode(String.self, forKey: CodingKeys.image) self = .image(text: text, image: image) + case "file": + let text = try container.decode(String.self, forKey: CodingKeys.text) + self = .file(text) default: let text = try? container.decode(String.self, forKey: CodingKeys.text) self = .unknown(type: type, text: text ?? "unknown message format") diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index cd46e4c28b..d51f666b67 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -326,9 +326,18 @@ func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) async thr try await sendCommandOkResp(.apiChatRead(type: type, id: id, itemRange: itemRange)) } -func receiveFile(fileId: Int64) async throws { +func receiveFile(fileId: Int64) async { + do { + let chatItem = try await apiReceiveFile(fileId: fileId) + DispatchQueue.main.async { chatItemSimpleUpdate(chatItem) } + } catch let error { + logger.error("receiveFile error: \(responseError(error))") + } +} + +func apiReceiveFile(fileId: Int64) async throws -> AChatItem { let r = await chatSendCmd(.receiveFile(fileId: fileId)) - if case .rcvFileAccepted = r { return } + if case .rcvFileAccepted(let chatItem) = r { return chatItem } throw r } @@ -470,14 +479,11 @@ func processReceivedMsg(_ res: ChatResponse) { let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem m.addChatItem(cInfo, cItem) - if let file = cItem.file, + if case .image = cItem.content.msgContent, + let file = cItem.file, file.fileSize <= maxImageSize { Task { - do { - try await receiveFile(fileId: file.fileId) - } catch { - logger.error("receiveFile error: \(error.localizedDescription)") - } + await receiveFile(fileId: file.fileId) } } NtfManager.shared.notifyMessageReceived(cInfo, cItem) @@ -499,11 +505,7 @@ func processReceivedMsg(_ res: ChatResponse) { } } case let .chatItemUpdated(aChatItem): - let cInfo = aChatItem.chatInfo - let cItem = aChatItem.chatItem - if m.upsertChatItem(cInfo, cItem) { - NtfManager.shared.notifyMessageReceived(cInfo, cItem) - } + chatItemSimpleUpdate(aChatItem) case let .chatItemDeleted(_, toChatItem): let cInfo = toChatItem.chatInfo let cItem = toChatItem.chatItem @@ -513,18 +515,25 @@ func processReceivedMsg(_ res: ChatResponse) { // currently only broadcast deletion of rcv message can be received, and only this case should happen _ = m.upsertChatItem(cInfo, cItem) } + case let .rcvFileStart(aChatItem): + chatItemSimpleUpdate(aChatItem) case let .rcvFileComplete(aChatItem): - let cInfo = aChatItem.chatInfo - let cItem = aChatItem.chatItem - if m.upsertChatItem(cInfo, cItem) { - NtfManager.shared.notifyMessageReceived(cInfo, cItem) - } + chatItemSimpleUpdate(aChatItem) default: logger.debug("unsupported event: \(res.responseType)") } } } +func chatItemSimpleUpdate(_ aChatItem: AChatItem) { + let m = ChatModel.shared + let cInfo = aChatItem.chatInfo + let cItem = aChatItem.chatItem + if m.upsertChatItem(cInfo, cItem) { + NtfManager.shared.notifyMessageReceived(cInfo, cItem) + } +} + func updateContactsStatus(_ contactRefs: [ContactRef], status: Chat.NetworkStatus) { let m = ChatModel.shared for c in contactRefs { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 21d7f7cf9e..ecca2a577d 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -55,6 +55,11 @@ struct FramedItemView: View { } else { ciMsgContentView (chatItem, showMember) } + case let .file(text): + CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited) + if text != "" { + ciMsgContentView (chatItem, showMember) + } case let .link(_, preview): CILinkView(linkPreview: preview) ciMsgContentView (chatItem, showMember) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 708f92512b..7daa6d6c6f 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -135,6 +135,12 @@ struct ChatView: View { UIPasteboard.general.string = ci.content.text } } label: { Label("Copy", systemImage: "doc.on.doc") } + if case .image = ci.content.msgContent, + let image = getStoredImage(ci.file) { + Button { + UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) + } label: { Label("Save", systemImage: "square.and.arrow.down") } + } if ci.meta.editable { Button { withAnimation { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 43ca22a3d6..60fe848723 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -276,6 +276,8 @@ struct ComposeView: View { return checkLinkPreview() case .image(_, let image): return .image(text: composeState.message, image: image) + case .file: + return .file(composeState.message) case .unknown(let type, _): return .unknown(type: type, text: composeState.message) } diff --git a/apps/ios/Shared/Views/Helpers/CIFileView.swift b/apps/ios/Shared/Views/Helpers/CIFileView.swift new file mode 100644 index 0000000000..c5c864ada8 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/CIFileView.swift @@ -0,0 +1,177 @@ +// +// CIFileView.swift +// SimpleX +// +// Created by JRoberts on 28/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct CIFileView: View { + @Environment(\.colorScheme) var colorScheme + let file: CIFile? + let edited: Bool + + var body: some View { + let metaReserve = edited + ? " " + : " " + Button(action: fileAction) { + HStack(alignment: .bottom, spacing: 6) { + fileIndicator() + .padding(.top, 5) + .padding(.bottom, 3) + if let file = file { + VStack(alignment: .leading, spacing: 2) { + Text(file.fileName) + .lineLimit(1) + .multilineTextAlignment(.leading) + .foregroundColor(.primary) + Text(formatBytes(bytes: file.fileSize) + metaReserve) + .font(.caption) + .lineLimit(1) + .multilineTextAlignment(.leading) + .foregroundColor(.secondary) + } + } else { + Text(metaReserve) + } + } + .padding(.top, 4) + .padding(.bottom, 6) + .padding(.leading, 10) + .padding(.trailing, 12) + } + .disabled(file == nil || (file?.fileStatus != .rcvInvitation && file?.fileStatus != .rcvAccepted && file?.fileStatus != .rcvComplete)) + } + + func fileSizeValid() -> Bool { + if let file = file { + return file.fileSize <= maxFileSize + } + return false + } + + func fileAction() { + logger.debug("CIFileView processFile") + if let file = file { + switch (file.fileStatus) { + case .rcvInvitation: + if fileSizeValid() { + Task { + logger.debug("CIFileView processFile - in .rcvInvitation, in Task") + await receiveFile(fileId: file.fileId) + } + } else { + AlertManager.shared.showAlertMsg( + title: "Large file!", + message: "Your contact sent a file that is larger than currently supported maximum size (\(maxFileSize) bytes)." + ) + } + case .rcvAccepted: + AlertManager.shared.showAlertMsg( + title: "Waiting for file", + message: "File will be received when your contact is online, please wait or check later!" + ) + case .rcvComplete: + logger.debug("CIFileView processFile - in .rcvComplete") + if let filePath = getStoredFilePath(file){ + let url = URL(fileURLWithPath: filePath) + showShareSheet(items: [url]) + } + default: break + } + } + } + + @ViewBuilder func fileIndicator() -> some View { + if let file = file { + switch file.fileStatus { + case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) + case .rcvInvitation: + if fileSizeValid() { + fileIcon("arrow.down.doc.fill", color: .accentColor) + } else { + fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12) + } + case .rcvAccepted: fileIcon("doc.fill", innerIcon: "ellipsis", innerIconSize: 12) + case .rcvTransfer: ProgressView().frame(width: 30, height: 30) + case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) + default: fileIcon("doc.fill") + } + } else { + fileIcon("doc.fill") + } + } + + func fileIcon(_ icon: String, color: Color = .secondary, innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View { + ZStack(alignment: .center) { + Image(systemName: icon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 30, height: 30) + .foregroundColor(color) + if let innerIcon = innerIcon, + let innerIconSize = innerIconSize { + Image(systemName: innerIcon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxHeight: 16) + .frame(width: innerIconSize, height: innerIconSize) + .foregroundColor(.white) + .padding(.top, 12) + } + } + } + + func formatBytes(bytes: Int64) -> String { + if (bytes == 0) { return "0 bytes" } + + let bytesDouble = Double(bytes) + let k: Double = 1000 + let units = ["bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] + + let i = floor(log2(bytesDouble) / log2(k)) + let size = bytesDouble / pow(k, i) + let unit = units[Int(i)] + + if (i <= 1) { + return String(format: "%.0f \(unit)", size) + } else { + return String(format: "%.2f \(unit)", size) + } + } +} + +struct CIFileView_Previews: PreviewProvider { + static var previews: some View { + let sentFile = ChatItem( + chatDir: .directSnd, + meta: CIMeta.getSample(1, .now, "", .sndSent, false, true, false), + content: .sndMsgContent(msgContent: .file("")), + quotedItem: nil, + file: CIFile.getSample(fileStatus: .sndStored) + ) + let fileChatItemWtFile = ChatItem( + chatDir: .directRcv, + meta: CIMeta.getSample(1, .now, "", .rcvRead, false, false, false), + content: .rcvMsgContent(msgContent: .file("")), + quotedItem: nil, + file: nil + ) + Group{ + ChatItemView(chatItem: sentFile) + ChatItemView(chatItem: ChatItem.getFileMsgContentSample()) + ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation)) + ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted)) + ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer)) + ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled)) + ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileSize: 2000000, fileStatus: .rcvInvitation)) + ChatItemView(chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation)) + ChatItemView(chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation)) + ChatItemView(chatItem: fileChatItemWtFile) + } + .previewLayout(.fixed(width: 360, height: 360)) + } +} diff --git a/apps/ios/Shared/Views/Helpers/CIImageView.swift b/apps/ios/Shared/Views/Helpers/CIImageView.swift index 13d3f6e1a9..61518f0438 100644 --- a/apps/ios/Shared/Views/Helpers/CIImageView.swift +++ b/apps/ios/Shared/Views/Helpers/CIImageView.swift @@ -41,6 +41,14 @@ struct CIImageView: View { } else if let data = Data(base64Encoded: dropImagePrefix(image)), let uiImage = UIImage(data: data) { imageView(uiImage) + .onTapGesture { + if case .rcvAccepted = file?.fileStatus { + AlertManager.shared.showAlertMsg( + title: "Waiting for image", + message: "Image will be received when your contact is online, please wait or check later!" + ) + } + } } } } @@ -48,9 +56,32 @@ struct CIImageView: View { private func imageView(_ img: UIImage) -> some View { let w = img.size.width > img.size.height ? .infinity : maxWidth * 0.75 DispatchQueue.main.async { imgWidth = w } - return Image(uiImage: img) - .resizable() - .scaledToFit() - .frame(maxWidth: w) + return ZStack(alignment: .topTrailing) { + Image(uiImage: img) + .resizable() + .scaledToFit() + .frame(maxWidth: w) + loadingIndicator() + .padding(8) + } + } + + @ViewBuilder private func loadingIndicator() -> some View { + if let file = file { + switch file.fileStatus { + case .rcvAccepted: + Image(systemName: "ellipsis") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 20, height: 20) + .foregroundColor(.white) + case .rcvTransfer: + ProgressView() + .progressViewStyle(.circular) + .frame(width: 20, height: 20) + .tint(.white) + default: EmptyView() + } + } } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index e5b00c54bc..24c3cc472c 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -90,6 +90,7 @@ 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; }; 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; }; 640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; }; + 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; @@ -207,6 +208,7 @@ 5CEACCE227DE9246000BD591 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = ""; }; 640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = ""; }; + 648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; @@ -331,6 +333,7 @@ 3CDBCF4727FF621E00354CDD /* CILinkView.swift */, 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */, 649BCDA12805D6EF00C3A862 /* CIImageView.swift */, + 648010AA281ADD15009009B9 /* CIFileView.swift */, ); path = Helpers; sourceTree = ""; @@ -625,6 +628,7 @@ 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */, 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */, 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */, + 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */, 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */, 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */, 5C764E80279C7276000C6508 /* dummy.m in Sources */, @@ -864,6 +868,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "SimpleX--iOS--Info.plist"; INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other app users"; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Access to Photo Library is needed for saving captured and received media"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -905,6 +910,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "SimpleX--iOS--Info.plist"; INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other app users"; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Access to Photo Library is needed for saving captured and received media"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; From 70ea803a49e1329f44519fe4cac4f6e718787d4b Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Wed, 4 May 2022 16:08:40 +0400 Subject: [PATCH 48/95] ios: move image utils to FileUtils (#598) --- apps/ios/Shared/FileUtils.swift | 75 +++++++++++++++++++ .../Shared/Views/Helpers/ImagePicker.swift | 74 ------------------ 2 files changed, 75 insertions(+), 74 deletions(-) diff --git a/apps/ios/Shared/FileUtils.swift b/apps/ios/Shared/FileUtils.swift index e6cf6fa140..b3346293a8 100644 --- a/apps/ios/Shared/FileUtils.swift +++ b/apps/ios/Shared/FileUtils.swift @@ -37,3 +37,78 @@ func getStoredImage(_ file: CIFile?) -> UIImage? { } return nil } + +// image utils + +func dropImagePrefix(_ s: String) -> String { + dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,") +} + +private func dropPrefix(_ s: String, _ prefix: String) -> String { + s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s +} + +func cropToSquare(_ image: UIImage) -> UIImage { + let size = image.size + let side = min(size.width, size.height) + let newSize = CGSize(width: side, height: side) + var origin = CGPoint.zero + if size.width > side { + origin.x -= (size.width - side) / 2 + } else if size.height > side { + origin.y -= (size.height - side) / 2 + } + return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size)) +} + +func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int) -> Data? { + var img = image + var data = img.jpegData(compressionQuality: 0.85) + var dataSize = data?.count ?? 0 + while dataSize != 0 && dataSize > maxDataSize { + let ratio = sqrt(Double(dataSize) / Double(maxDataSize)) + let clippedRatio = min(ratio, 2.0) + img = reduceSize(img, ratio: clippedRatio) + data = img.jpegData(compressionQuality: 0.85) + dataSize = data?.count ?? 0 + } + logger.debug("resizeImageToDataSize final \(dataSize)") + return data +} + +func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int) -> String? { + var img = image + var str = compressImageStr(img) + var dataSize = str?.count ?? 0 + while dataSize != 0 && dataSize > maxDataSize { + let ratio = sqrt(Double(dataSize) / Double(maxDataSize)) + let clippedRatio = min(ratio, 2.0) + img = reduceSize(img, ratio: clippedRatio) + str = compressImageStr(img) + dataSize = str?.count ?? 0 + } + logger.debug("resizeImageToStrSize final \(dataSize)") + return str +} + +func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85) -> String? { + if let data = image.jpegData(compressionQuality: compressionQuality) { + return "data:image/jpg;base64,\(data.base64EncodedString())" + } + return nil +} + +private func reduceSize(_ image: UIImage, ratio: CGFloat) -> UIImage { + let newSize = CGSize(width: floor(image.size.width / ratio), height: floor(image.size.height / ratio)) + let bounds = CGRect(origin: .zero, size: newSize) + return resizeImage(image, newBounds: bounds, drawIn: bounds) +} + +private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect) -> UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = 1.0 + format.opaque = true + return UIGraphicsImageRenderer(bounds: newBounds, format: format).image { _ in + image.draw(in: drawIn) + } +} diff --git a/apps/ios/Shared/Views/Helpers/ImagePicker.swift b/apps/ios/Shared/Views/Helpers/ImagePicker.swift index 06c4825c6a..8c2b68b8bc 100644 --- a/apps/ios/Shared/Views/Helpers/ImagePicker.swift +++ b/apps/ios/Shared/Views/Helpers/ImagePicker.swift @@ -9,80 +9,6 @@ import SwiftUI import PhotosUI -func dropPrefix(_ s: String, _ prefix: String) -> String { - s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s -} - -func dropImagePrefix(_ s: String) -> String { - dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,") -} - -private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect) -> UIImage { - let format = UIGraphicsImageRendererFormat() - format.scale = 1.0 - format.opaque = true - return UIGraphicsImageRenderer(bounds: newBounds, format: format).image { _ in - image.draw(in: drawIn) - } -} - -func cropToSquare(_ image: UIImage) -> UIImage { - let size = image.size - let side = min(size.width, size.height) - let newSize = CGSize(width: side, height: side) - var origin = CGPoint.zero - if size.width > side { - origin.x -= (size.width - side) / 2 - } else if size.height > side { - origin.y -= (size.height - side) / 2 - } - return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size)) -} - - -func reduceSize(_ image: UIImage, ratio: CGFloat) -> UIImage { - let newSize = CGSize(width: floor(image.size.width / ratio), height: floor(image.size.height / ratio)) - let bounds = CGRect(origin: .zero, size: newSize) - return resizeImage(image, newBounds: bounds, drawIn: bounds) -} - -func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int) -> String? { - var img = image - var str = compressImageStr(img) - var dataSize = str?.count ?? 0 - while dataSize != 0 && dataSize > maxDataSize { - let ratio = sqrt(Double(dataSize) / Double(maxDataSize)) - let clippedRatio = min(ratio, 2.0) - img = reduceSize(img, ratio: clippedRatio) - str = compressImageStr(img) - dataSize = str?.count ?? 0 - } - logger.debug("resizeImageToStrSize final \(dataSize)") - return str -} - -func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85) -> String? { - if let data = image.jpegData(compressionQuality: compressionQuality) { - return "data:image/jpg;base64,\(data.base64EncodedString())" - } - return nil -} - -func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int) -> Data? { - var img = image - var data = img.jpegData(compressionQuality: 0.85) - var dataSize = data?.count ?? 0 - while dataSize != 0 && dataSize > maxDataSize { - let ratio = sqrt(Double(dataSize) / Double(maxDataSize)) - let clippedRatio = min(ratio, 2.0) - img = reduceSize(img, ratio: clippedRatio) - data = img.jpegData(compressionQuality: 0.85) - dataSize = data?.count ?? 0 - } - logger.debug("resizeImageToDataSize final \(dataSize)") - return data -} - enum ImageSource { case imageLibrary case camera From 1ddd17839bb5c81e8a36ce7254090c1ab7f45e6b Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 4 May 2022 13:31:00 +0100 Subject: [PATCH 49/95] core: calls api - support multiple calls, process status updates from webview, refactor, tests (#595) * core: tests for call api (WIP, test fails) * fix test * add APICallStatus, tests * update call status based on webview events, refactor --- src/Simplex/Chat.hs | 194 +++++++++++++++++++-------------- src/Simplex/Chat/Call.hs | 20 ++++ src/Simplex/Chat/Controller.hs | 16 +-- src/Simplex/Chat/Messages.hs | 14 ++- src/Simplex/Chat/Store.hs | 99 +++++++++-------- src/Simplex/Chat/Types.hs | 12 +- src/Simplex/Chat/View.hs | 10 +- tests/ChatTests.hs | 68 +++++++++++- 8 files changed, 280 insertions(+), 153 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 3a7f790174..c9b5e843bf 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -27,6 +27,7 @@ import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Char (isSpace) +import Data.Fixed (div') import Data.Functor (($>)) import Data.Int (Int64) import Data.List (find) @@ -37,7 +38,7 @@ import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isJust, mapMaybe) import Data.Text (Text) import qualified Data.Text as T -import Data.Time.Clock (UTCTime, getCurrentTime) +import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDiffTimeToSeconds) import Data.Time.LocalTime (getCurrentTimeZone, getZonedTime) import Data.Word (Word32) import Simplex.Chat.Call @@ -60,6 +61,7 @@ import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), PushProvider import Simplex.Messaging.Parsers (base64P, parseAll) import Simplex.Messaging.Protocol (ErrorType (..), MsgBody) import qualified Simplex.Messaging.Protocol as SMP +import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Util (tryError, (<$?>)) import System.Exit (exitFailure, exitSuccess) import System.FilePath (combine, splitExtensions, takeFileName) @@ -124,9 +126,9 @@ newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize} Ch chatLock <- newTMVarIO () sndFiles <- newTVarIO M.empty rcvFiles <- newTVarIO M.empty - currentCall <- newTVarIO Nothing + currentCalls <- atomically TM.empty filesFolder <- newTVarIO Nothing - pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCall, config, sendNotification, filesFolder} + pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, filesFolder} where resolveServers :: IO (NonEmpty SMPServer) resolveServers = case user of @@ -290,7 +292,7 @@ processChatCommand = \case case (ciContent, itemSharedMsgId) of (CISndMsgContent _, Just itemSharedMId) -> do SndMessage {msgId} <- sendDirectContactMessage ct (XMsgUpdate itemSharedMId mc) - updCi <- withStore $ \st -> updateDirectChatItem st userId contactId itemId (CISndMsgContent mc) msgId + updCi <- withStore $ \st -> updateDirectChatItem st userId contactId itemId (CISndMsgContent mc) $ Just msgId setActive $ ActiveC c pure . CRChatItemUpdated $ AChatItem SCTDirect SMDSnd (DirectChat ct) updCi _ -> throwChatError CEInvalidChatItemUpdate @@ -393,27 +395,25 @@ processChatCommand = \case APISendCallInvitation contactId callType@CallType {capabilities = CallCapabilities {encryption}} -> withUser $ \user@User {userId} -> do -- party initiating call ct <- withStore $ \st -> getContact st userId contactId - call <- asks currentCall - withChatLock $ - readTVarIO call >>= \case - Just _ -> throwChatError CEHasCurrentCall - _ -> do - callId <- CallId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16)) - dhKeyPair <- if encryption then Just <$> liftIO C.generateKeyPair' else pure Nothing - let invitation = CallInvitation {callType, callDhPubKey = fst <$> dhKeyPair} - callState = CallInvitationSent {localCallType = callType, localDhPrivKey = snd <$> dhKeyPair} - msg <- sendDirectContactMessage ct (XCallInv callId invitation) - ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndCall CISCallPending 0) Nothing Nothing - atomically . writeTVar call $ Just Call {contactId, callId, chatItemId = chatItemId' ci, callState} - toView . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci - pure CRCmdOk + calls <- asks currentCalls + withChatLock $ do + callId <- CallId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16)) + dhKeyPair <- if encryption then Just <$> liftIO C.generateKeyPair' else pure Nothing + let invitation = CallInvitation {callType, callDhPubKey = fst <$> dhKeyPair} + callState = CallInvitationSent {localCallType = callType, localDhPrivKey = snd <$> dhKeyPair} + msg@SndMessage {msgId} <- sendDirectContactMessage ct (XCallInv callId invitation) + ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndCall CISCallPending 0) Nothing Nothing + let call' = Call {contactId, callId, chatItemId = chatItemId' ci, callState} + call_ <- atomically $ TM.lookupInsert contactId call' calls + forM_ call_ $ \call -> updateCallItemStatus userId ct call WCSDisconnected $ Just msgId + toView . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci + pure CRCmdOk APIRejectCall contactId -> -- party accepting call withCurrentCall contactId $ \userId ct Call {chatItemId, callState} -> case callState of - CallInvitationReceived {} -> do - updCi <- withStore $ \st -> updateDirectChatItemNoMsg st userId contactId chatItemId (CIRcvCall CISCallRejected 0) - toView . CRChatItemUpdated $ AChatItem SCTDirect SMDRcv (DirectChat ct) updCi - pure Nothing + CallInvitationReceived {} -> + let aciContent = ACIContent SMDRcv $ CIRcvCall CISCallRejected 0 + in updateDirectChatItemView userId ct chatItemId aciContent Nothing $> Nothing _ -> throwChatError . CECallState $ callStateTag callState APISendCallOffer contactId WebRTCCallOffer {callType, rtcSession} -> -- party accepting call @@ -422,19 +422,19 @@ processChatCommand = \case -- TODO check that call type matches peerCallType let offer = CallOffer {callType, rtcSession, callDhPubKey = localDhPubKey} callState' = CallOfferSent {localCallType = callType, peerCallType, localCallSession = rtcSession, sharedKey} + aciContent = ACIContent SMDRcv $ CIRcvCall CISCallAccepted 0 SndMessage {msgId} <- sendDirectContactMessage ct (XCallOffer callId offer) - updCi <- withStore $ \st -> updateDirectChatItem st userId contactId chatItemId (CIRcvCall CISCallAccepted 0) msgId - toView . CRChatItemUpdated $ AChatItem SCTDirect SMDRcv (DirectChat ct) updCi + updateDirectChatItemView userId ct chatItemId aciContent $ Just msgId pure $ Just call {callState = callState'} _ -> throwChatError . CECallState $ callStateTag callState APISendCallAnswer contactId rtcSession -> -- party initiating call withCurrentCall contactId $ \userId ct call@Call {callId, chatItemId, callState} -> case callState of CallOfferReceived {localCallType, peerCallType, peerCallSession, sharedKey} -> do - SndMessage {msgId} <- sendDirectContactMessage ct (XCallAnswer callId CallAnswer {rtcSession}) - updCi <- withStore $ \st -> updateDirectChatItem st userId contactId chatItemId (CISndCall CISCallNegotiated 0) msgId - toView . CRChatItemUpdated $ AChatItem SCTDirect SMDSnd (DirectChat ct) updCi let callState' = CallNegotiated {localCallType, peerCallType, localCallSession = rtcSession, peerCallSession, sharedKey} + aciContent = ACIContent SMDSnd $ CISndCall CISCallNegotiated 0 + SndMessage {msgId} <- sendDirectContactMessage ct (XCallAnswer callId CallAnswer {rtcSession}) + updateDirectChatItemView userId ct chatItemId aciContent $ Just msgId pure $ Just call {callState = callState'} _ -> throwChatError . CECallState $ callStateTag callState APISendCallExtraInfo contactId rtcExtraInfo -> @@ -448,17 +448,13 @@ processChatCommand = \case _ -> throwChatError . CECallState $ callStateTag callState APIEndCall contactId -> -- any call party - withCurrentCall contactId $ \userId ct Call {callId, chatItemId} -> do + withCurrentCall contactId $ \userId ct call@Call {callId} -> do SndMessage {msgId} <- sendDirectContactMessage ct (XCallEnd callId) - CChatItem msgDir _ <- withStore $ \st -> getDirectChatItem st userId contactId chatItemId - let aciContent = case msgDir of - SMDSnd -> ACIContent SMDSnd $ CISndCall CISCallEnded 0 - SMDRcv -> ACIContent SMDRcv $ CIRcvCall CISCallEnded 0 - case aciContent of - ACIContent msgDir' ciContent -> do - updCi <- withStore $ \st -> updateDirectChatItem st userId contactId chatItemId ciContent msgId - toView . CRChatItemUpdated $ AChatItem SCTDirect msgDir' (DirectChat ct) updCi + updateCallItemStatus userId ct call WCSDisconnected $ Just msgId pure Nothing + APICallStatus contactId receivedStatus -> + withCurrentCall contactId $ \userId ct call -> + updateCallItemStatus userId ct call receivedStatus Nothing $> Just call APIUpdateProfile profile -> withUser (`updateProfile` profile) APIParseMarkdown text -> pure . CRApiParsedMarkdown $ parseMaybeMarkdownList text APIRegisterToken token -> CRNtfTokenStatus <$> withUser (\_ -> withAgent (`registerNtfToken` token)) @@ -757,20 +753,59 @@ processChatCommand = \case withStore $ \st -> do updateFileCancelled st userId fileId updateCIFileStatus st userId fileId ciFileStatus - withCurrentCall :: Int64 -> (UserId -> Contact -> Call -> m (Maybe Call)) -> m ChatResponse + withCurrentCall :: ContactId -> (UserId -> Contact -> Call -> m (Maybe Call)) -> m ChatResponse withCurrentCall ctId action = withUser $ \User {userId} -> do ct <- withStore $ \st -> getContact st userId ctId - callVar <- asks currentCall + calls <- asks currentCalls withChatLock $ - readTVarIO callVar >>= \case + atomically (TM.lookup ctId calls) >>= \case Nothing -> throwChatError CENoCurrentCall Just call@Call {contactId} | ctId == contactId -> do call_ <- action userId ct call - atomically $ writeTVar callVar call_ + atomically $ case call_ of + Just call' -> TM.insert ctId call' calls + _ -> TM.delete ctId calls pure CRCmdOk | otherwise -> throwChatError $ CECallContact contactId +updateCallItemStatus :: ChatMonad m => UserId -> Contact -> Call -> WebRTCCallStatus -> Maybe MessageId -> m () +updateCallItemStatus userId ct Call {chatItemId} receivedStatus msgId_ = do + aciContent_ <- callStatusItemContent userId ct chatItemId receivedStatus + forM_ aciContent_ $ \aciContent -> updateDirectChatItemView userId ct chatItemId aciContent msgId_ + +updateDirectChatItemView :: ChatMonad m => UserId -> Contact -> ChatItemId -> ACIContent -> Maybe MessageId -> m () +updateDirectChatItemView userId ct@Contact {contactId} chatItemId (ACIContent msgDir ciContent) msgId_ = do + updCi <- withStore $ \st -> updateDirectChatItem st userId contactId chatItemId ciContent msgId_ + toView . CRChatItemUpdated $ AChatItem SCTDirect msgDir (DirectChat ct) updCi + +callStatusItemContent :: ChatMonad m => UserId -> Contact -> ChatItemId -> WebRTCCallStatus -> m (Maybe ACIContent) +callStatusItemContent userId Contact {contactId} chatItemId receivedStatus = do + CChatItem msgDir ChatItem {meta = CIMeta {updatedAt}, content} <- + withStore $ \st -> getDirectChatItem st userId contactId chatItemId + ts <- liftIO getCurrentTime + let callDuration :: Int = nominalDiffTimeToSeconds (ts `diffUTCTime` updatedAt) `div'` 1 + callStatus = case content of + CISndCall st _ -> Just st + CIRcvCall st _ -> Just st + _ -> Nothing + newState_ = case (callStatus, receivedStatus) of + (Just CISCallProgress, WCSConnected) -> Nothing -- if call in-progress received connected -> no change + (Just CISCallProgress, WCSDisconnected) -> Just (CISCallEnded, callDuration) -- calculate in-progress duration + (Just CISCallProgress, WCSFailed) -> Just (CISCallEnded, callDuration) -- whether call disconnected or failed + (Just CISCallEnded, _) -> Nothing -- if call already ended or failed -> no change + (Just CISCallError, _) -> Nothing + (Just _, WCSConnected) -> Just (CISCallProgress, 0) -- if call ended that was never connected, duration = 0 + (Just _, WCSDisconnected) -> Just (CISCallEnded, 0) + (Just _, WCSFailed) -> Just (CISCallError, 0) + (Nothing, _) -> Nothing -- some other content - we should never get here, but no exception is thrown + pure $ aciContent msgDir <$> newState_ + where + aciContent :: forall d. SMsgDirection d -> (CICallStatus, Int) -> ACIContent + aciContent msgDir (callStatus', duration) = case msgDir of + SMDSnd -> ACIContent SMDSnd $ CISndCall callStatus' duration + SMDRcv -> ACIContent SMDRcv $ CIRcvCall callStatus' duration + -- mobile clients use file paths relative to app directory (e.g. for the reason ios app directory changes on updates), -- so we have to differentiate between the file path stored in db and communicated with frontend, and the file path -- used during file transfer for actual operations with file system @@ -1064,12 +1099,11 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage when (memberCategory m == GCPreMember) $ probeMatchingContacts ct SENT msgId -> do sentMsgDeliveryEvent conn msgId - chatItemId_ <- withStore $ \st -> getChatItemIdByAgentMsgId st connId msgId - case chatItemId_ of - Nothing -> pure () - Just chatItemId -> do - chatItem <- withStore $ \st -> updateDirectChatItemStatus st userId contactId chatItemId CISSndSent + withStore (\st -> getDirectChatItemByAgentMsgId st userId contactId connId msgId) >>= \case + Just (CChatItem SMDSnd ci) -> do + chatItem <- withStore $ \st -> updateDirectChatItemStatus st userId contactId (chatItemId' ci) CISSndSent toView $ CRChatItemStatusUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) + _ -> pure () END -> do toView $ CRContactAnotherClient ct showToast (c <> "> ") "connected to another client" @@ -1365,9 +1399,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage checkIntegrity msgMeta $ toView . CRMsgIntegrityError CChatItem msgDir ChatItem {meta = CIMeta {itemId}} <- withStore $ \st -> getDirectChatItemBySharedMsgId st userId contactId sharedMsgId case msgDir of - SMDRcv -> do - updCi <- withStore $ \st -> updateDirectChatItem st userId contactId itemId (CIRcvMsgContent mc) msgId - toView . CRChatItemUpdated $ AChatItem SCTDirect SMDRcv (DirectChat ct) updCi + SMDRcv -> updateDirectChatItemView userId ct itemId (ACIContent SMDRcv $ CIRcvMsgContent mc) $ Just msgId SMDSnd -> messageError "x.msg.update: contact attempted invalid message update" messageDelete :: Contact -> SharedMsgId -> RcvMessage -> MsgMeta -> m () @@ -1523,23 +1555,21 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage -- to party accepting call xCallInv :: Contact -> CallId -> CallInvitation -> RcvMessage -> MsgMeta -> m () - xCallInv ct@Contact {contactId} callId CallInvitation {callType, callDhPubKey} msg msgMeta = do + xCallInv ct@Contact {contactId} callId CallInvitation {callType, callDhPubKey} msg@RcvMessage {msgId} msgMeta = do checkIntegrity msgMeta $ toView . CRMsgIntegrityError let CallType {capabilities = CallCapabilities {encryption}} = callType - call <- asks currentCall - ci <- - readTVarIO call >>= \case - Just _ -> saveCallItem CISCallMissed - -- showMsgToast (c <> "> ") content formattedText - -- setActive $ ActiveC c - _ -> do - ci <- saveCallItem CISCallPending - dhKeyPair <- if encryption then Just <$> liftIO C.generateKeyPair' else pure Nothing - let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> (snd <$> dhKeyPair)) - callState = CallInvitationReceived {peerCallType = callType, localDhPubKey = fst <$> dhKeyPair, sharedKey} - toView $ CRCallInvitation ct callType sharedKey - atomically . writeTVar call $ Just Call {contactId, callId, chatItemId = chatItemId' ci, callState} - pure ci + dhKeyPair <- if encryption then Just <$> liftIO C.generateKeyPair' else pure Nothing + ci <- saveCallItem CISCallPending + let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> (snd <$> dhKeyPair)) + callState = CallInvitationReceived {peerCallType = callType, localDhPubKey = fst <$> dhKeyPair, sharedKey} + call' = Call {contactId, callId, chatItemId = chatItemId' ci, callState} + calls <- asks currentCalls + -- theoretically, the new call invitation for the current contant can mark the in-progress call as ended + -- (and replace it in ChatController) + -- practically, this should not happen + call_ <- atomically (TM.lookupInsert contactId call' calls) + forM_ call_ $ \call -> updateCallItemStatus userId ct call WCSDisconnected $ Just msgId + toView $ CRCallInvitation ct callType sharedKey toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci where saveCallItem status = saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvCall status 0) Nothing @@ -1583,34 +1613,34 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage toView $ CRCallExtraInfo ct rtcExtraInfo pure (Just call {callState = callState'}, Nothing) _ -> do - msgCallStateError "x.call.answer" call + msgCallStateError "x.call.extra" call pure (Just call, Nothing) -- to any call party xCallEnd :: Contact -> CallId -> RcvMessage -> MsgMeta -> m () - xCallEnd ct@Contact {contactId} callId msg msgMeta = do - msgCurrentCall ct callId "x.call.end" msg msgMeta $ - \Call {chatItemId} -> do - toView $ CRCallEnded ct - CChatItem msgDir _ <- withStore $ \st -> getDirectChatItem st userId contactId chatItemId - pure $ case msgDir of - SMDSnd -> (Nothing, Just . ACIContent SMDSnd $ CISndCall CISCallEnded 0) - SMDRcv -> (Nothing, Just . ACIContent SMDRcv $ CIRcvCall CISCallEnded 0) + xCallEnd ct@Contact {contactId} callId msg msgMeta = + msgCurrentCall ct callId "x.call.end" msg msgMeta $ \Call {chatItemId} -> do + toView $ CRCallEnded ct + CChatItem msgDir _ <- withStore $ \st -> getDirectChatItem st userId contactId chatItemId + pure $ case msgDir of + SMDSnd -> (Nothing, Just . ACIContent SMDSnd $ CISndCall CISCallEnded 0) + SMDRcv -> (Nothing, Just . ACIContent SMDRcv $ CIRcvCall CISCallEnded 0) msgCurrentCall :: Contact -> CallId -> Text -> RcvMessage -> MsgMeta -> (Call -> m (Maybe Call, Maybe ACIContent)) -> m () msgCurrentCall ct@Contact {contactId = ctId'} callId' eventName RcvMessage {msgId} msgMeta action = do checkIntegrity msgMeta $ toView . CRMsgIntegrityError - callVar <- asks currentCall - readTVarIO callVar >>= \case + calls <- asks currentCalls + atomically (TM.lookup ctId' calls) >>= \case Nothing -> messageError $ eventName <> ": no current call" Just call@Call {contactId, callId, chatItemId} | contactId /= ctId' || callId /= callId' -> messageError $ eventName <> ": wrong contact or callId" | otherwise -> do - (call', aciContent_) <- action call - atomically $ writeTVar callVar call' - forM_ aciContent_ $ \(ACIContent msgDir ciContent) -> do - updCi <- withStore $ \st -> updateDirectChatItem st userId contactId chatItemId ciContent msgId - toView . CRChatItemUpdated $ AChatItem SCTDirect msgDir (DirectChat ct) updCi + (call_, aciContent_) <- action call + atomically $ case call_ of + Just call' -> TM.insert ctId' call' calls + _ -> TM.delete ctId' calls + forM_ aciContent_ $ \aciContent -> + updateDirectChatItemView userId ct chatItemId aciContent $ Just msgId msgCallStateError :: Text -> Call -> m () msgCallStateError eventName Call {callState} = @@ -1929,11 +1959,10 @@ saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} MsgMeta {broker = (_, brok liftIO $ mkChatItem cd ciId content ciFile quotedItem sharedMsgId_ brokerTs createdAt mkChatItem :: MsgDirectionI d => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> ChatItemTs -> UTCTime -> IO (ChatItem c d) -mkChatItem cd ciId content file quotedItem sharedMsgId itemTs createdAt = do +mkChatItem cd ciId content file quotedItem sharedMsgId itemTs currentTs = do tz <- getCurrentTimeZone - currentTs <- liftIO getCurrentTime let itemText = ciContentToText content - meta = mkCIMeta ciId content itemText ciStatusNew sharedMsgId False False tz currentTs itemTs createdAt + meta = mkCIMeta ciId content itemText ciStatusNew sharedMsgId False False tz currentTs itemTs currentTs currentTs pure ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem, file} allowAgentConnection :: ChatMonad m => Connection -> ConfirmationId -> ChatMsgEvent -> m () @@ -2067,6 +2096,7 @@ chatCommandP = <|> "/_call answer @" *> (APISendCallAnswer <$> A.decimal <* A.space <*> jsonP) <|> "/_call extra @" *> (APISendCallExtraInfo <$> A.decimal <* A.space <*> jsonP) <|> "/_call end @" *> (APIEndCall <$> A.decimal) + <|> "/_call status @" *> (APICallStatus <$> A.decimal <* A.space <*> strP) <|> "/_profile " *> (APIUpdateProfile <$> jsonP) <|> "/_parse " *> (APIParseMarkdown . safeDecodeUtf8 <$> A.takeByteString) <|> "/_ntf register " *> (APIRegisterToken <$> tokenP) diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index 4559cf1e6e..827953a249 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -9,6 +9,7 @@ module Simplex.Chat.Call where import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J +import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import GHC.Generics (Generic) @@ -152,6 +153,10 @@ data WebRTCCallOffer = WebRTCCallOffer instance FromJSON WebRTCCallOffer where parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} +instance ToJSON WebRTCCallOffer where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + data CallAnswer = CallAnswer { rtcSession :: WebRTCSession } @@ -188,3 +193,18 @@ data WebRTCExtraInfo = WebRTCExtraInfo instance ToJSON WebRTCExtraInfo where toJSON = J.genericToJSON J.defaultOptions toEncoding = J.genericToEncoding J.defaultOptions + +data WebRTCCallStatus = WCSConnected | WCSDisconnected | WCSFailed + deriving (Show) + +instance StrEncoding WebRTCCallStatus where + strEncode = \case + WCSConnected -> "connected" + WCSDisconnected -> "disconnected" + WCSFailed -> "failed" + strP = + A.takeTill (== ' ') >>= \case + "connected" -> pure WCSConnected + "disconnected" -> pure WCSDisconnected + "failed" -> pure WCSFailed + _ -> fail "bad WebRTCCallStatus" diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 89a2228f27..bf6e815ef8 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -40,6 +40,7 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) import Simplex.Messaging.Parsers (dropPrefix, enumJSON, sumTypeJSON) import Simplex.Messaging.Protocol (CorrId) +import Simplex.Messaging.TMap (TMap) import System.IO (Handle) import UnliftIO.STM @@ -81,7 +82,7 @@ data ChatController = ChatController chatLock :: TMVar (), sndFiles :: TVar (Map Int64 Handle), rcvFiles :: TVar (Map Int64 Handle), - currentCall :: TVar (Maybe Call), + currentCalls :: TMap ContactId Call, config :: ChatConfig, filesFolder :: TVar (Maybe FilePath) -- path to files folder for mobile apps } @@ -110,12 +111,13 @@ data ChatCommand | APIDeleteChat ChatRef | APIAcceptContact Int64 | APIRejectContact Int64 - | APISendCallInvitation Int64 CallType - | APIRejectCall Int64 - | APISendCallOffer Int64 WebRTCCallOffer - | APISendCallAnswer Int64 WebRTCSession - | APISendCallExtraInfo Int64 WebRTCExtraInfo - | APIEndCall Int64 + | APISendCallInvitation ContactId CallType + | APIRejectCall ContactId + | APISendCallOffer ContactId WebRTCCallOffer + | APISendCallAnswer ContactId WebRTCSession + | APISendCallExtraInfo ContactId WebRTCExtraInfo + | APIEndCall ContactId + | APICallStatus ContactId WebRTCCallStatus | APIUpdateProfile Profile | APIParseMarkdown Text | APIRegisterToken DeviceToken diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index b46bf67f15..6a1097d065 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -224,17 +224,18 @@ data CIMeta (d :: MsgDirection) = CIMeta itemEdited :: Bool, editable :: Bool, localItemTs :: ZonedTime, - createdAt :: UTCTime + createdAt :: UTCTime, + updatedAt :: UTCTime } deriving (Show, Generic) -mkCIMeta :: ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe SharedMsgId -> Bool -> Bool -> TimeZone -> UTCTime -> ChatItemTs -> UTCTime -> CIMeta d -mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted itemEdited tz currentTs itemTs createdAt = +mkCIMeta :: ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe SharedMsgId -> Bool -> Bool -> TimeZone -> UTCTime -> ChatItemTs -> UTCTime -> UTCTime -> CIMeta d +mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted itemEdited tz currentTs itemTs createdAt updatedAt = let localItemTs = utcToZonedTime tz itemTs editable = case itemContent of CISndMsgContent _ -> diffUTCTime currentTs itemTs < nominalDay _ -> False - in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, editable, localItemTs, createdAt} + in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, editable, localItemTs, createdAt, updatedAt} instance ToJSON (CIMeta d) where toEncoding = J.genericToEncoding J.defaultOptions @@ -578,7 +579,10 @@ ciCallInfoText status duration = case status of CISCallEnded -> "ended " <> d CISCallError -> "error" where - d = let (mins, secs) = duration `divMod` 60 in T.pack $ "(" <> show mins <> ":" <> show secs <> ")" + d = let (mins, secs) = duration `divMod` 60 in T.pack $ "(" <> with0 mins <> ":" <> with0 secs <> ")" + with0 n + | n < 9 = '0' : show n + | otherwise = show n data SChatType (c :: ChatType) where SCTDirect :: SChatType 'CTDirect diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 15e04bf194..00db6f444e 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -135,6 +135,7 @@ module Simplex.Chat.Store getChatItemIdByAgentMsgId, getDirectChatItem, getDirectChatItemBySharedMsgId, + getDirectChatItemByAgentMsgId, getGroupChatItem, getGroupChatItemBySharedMsgId, getDirectChatItemIdByText, @@ -142,7 +143,6 @@ module Simplex.Chat.Store getChatItemByFileId, updateDirectChatItemStatus, updateDirectChatItem, - updateDirectChatItemNoMsg, deleteDirectChatItemInternal, deleteDirectChatItemRcvBroadcast, deleteDirectChatItemSndBroadcast, @@ -2576,7 +2576,7 @@ getDirectChatPreviews_ db User {userId} = do -- ChatStats COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- DirectQuote @@ -2641,7 +2641,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do -- ChatStats COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- Maybe GroupMember - sender @@ -2794,7 +2794,7 @@ getDirectChatLast_ db User {userId} contactId count = do [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- DirectQuote @@ -2825,7 +2825,7 @@ getDirectChatAfter_ db User {userId} contactId afterChatItemId count = do [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- DirectQuote @@ -2856,7 +2856,7 @@ getDirectChatBefore_ db User {userId} contactId beforeChatItemId count = do [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- DirectQuote @@ -2959,7 +2959,7 @@ getGroupChatLast_ db user@User {userId, userContactId} groupId count = do [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- GroupMember @@ -3002,7 +3002,7 @@ getGroupChatAfter_ db user@User {userId, userContactId} groupId afterChatItemId [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- GroupMember @@ -3045,7 +3045,7 @@ getGroupChatBefore_ db user@User {userId, userContactId} groupId beforeChatItemI [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- GroupMember @@ -3150,24 +3150,27 @@ getGroupIdByName_ db User {userId} gName = getChatItemIdByAgentMsgId :: StoreMonad m => SQLiteStore -> Int64 -> AgentMsgId -> m (Maybe ChatItemId) getChatItemIdByAgentMsgId st connId msgId = - liftIO . withTransaction st $ \db -> - join . listToMaybe . map fromOnly - <$> DB.query - db - [sql| - SELECT chat_item_id - FROM chat_item_messages - WHERE message_id = ( - SELECT message_id - FROM msg_deliveries - WHERE connection_id = ? AND agent_msg_id = ? - LIMIT 1 - ) - |] - (connId, msgId) + liftIO . withTransaction st $ \db -> getChatItemIdByAgentMsgId_ db connId msgId + +getChatItemIdByAgentMsgId_ :: DB.Connection -> Int64 -> AgentMsgId -> IO (Maybe ChatItemId) +getChatItemIdByAgentMsgId_ db connId msgId = + join . listToMaybe . map fromOnly + <$> DB.query + db + [sql| + SELECT chat_item_id + FROM chat_item_messages + WHERE message_id = ( + SELECT message_id + FROM msg_deliveries + WHERE connection_id = ? AND agent_msg_id = ? + LIMIT 1 + ) + |] + (connId, msgId) updateDirectChatItemStatus :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> UserId -> Int64 -> ChatItemId -> CIStatus d -> m (ChatItem 'CTDirect d) -updateDirectChatItemStatus st userId contactId itemId itemStatus = +updateDirectChatItemStatus st userId contactId itemId itemStatus = do liftIOEither . withTransaction st $ \db -> runExceptT $ do ci <- ExceptT $ (correctDir =<<) <$> getDirectChatItem_ db userId contactId itemId currentTs <- liftIO getCurrentTime @@ -3177,20 +3180,14 @@ updateDirectChatItemStatus st userId contactId itemId itemStatus = correctDir :: CChatItem c -> Either StoreError (ChatItem c d) correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci -updateDirectChatItem :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> UserId -> Int64 -> ChatItemId -> CIContent d -> MessageId -> m (ChatItem 'CTDirect d) -updateDirectChatItem st userId contactId itemId newContent msgId = +updateDirectChatItem :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> UserId -> Int64 -> ChatItemId -> CIContent d -> Maybe MessageId -> m (ChatItem 'CTDirect d) +updateDirectChatItem st userId contactId itemId newContent msgId_ = liftIOEither . withTransaction st $ \db -> do currentTs <- liftIO getCurrentTime ci <- updateDirectChatItem_ db userId contactId itemId newContent currentTs - when (isRight ci) . liftIO $ insertChatItemMessage_ db itemId msgId currentTs + when (isRight ci) . forM_ msgId_ $ \msgId -> liftIO $ insertChatItemMessage_ db itemId msgId currentTs pure ci -updateDirectChatItemNoMsg :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> UserId -> Int64 -> ChatItemId -> CIContent d -> m (ChatItem 'CTDirect d) -updateDirectChatItemNoMsg st userId contactId itemId newContent = - liftIOEither . withTransaction st $ \db -> do - currentTs <- liftIO getCurrentTime - updateDirectChatItem_ db userId contactId itemId newContent currentTs - updateDirectChatItem_ :: forall d. (MsgDirectionI d) => DB.Connection -> UserId -> Int64 -> ChatItemId -> CIContent d -> UTCTime -> IO (Either StoreError (ChatItem 'CTDirect d)) updateDirectChatItem_ db userId contactId itemId newContent currentTs = runExceptT $ do ci <- ExceptT $ (correctDir =<<) <$> getDirectChatItem_ db userId contactId itemId @@ -3280,16 +3277,22 @@ deleteQuote_ db itemId = |] (Only itemId) -getDirectChatItem :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> ChatItemId -> m (CChatItem 'CTDirect) +getDirectChatItem :: StoreMonad m => SQLiteStore -> UserId -> ContactId -> ChatItemId -> m (CChatItem 'CTDirect) getDirectChatItem st userId contactId itemId = liftIOEither . withTransaction st $ \db -> getDirectChatItem_ db userId contactId itemId -getDirectChatItemBySharedMsgId :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> SharedMsgId -> m (CChatItem 'CTDirect) +getDirectChatItemBySharedMsgId :: StoreMonad m => SQLiteStore -> UserId -> ContactId -> SharedMsgId -> m (CChatItem 'CTDirect) getDirectChatItemBySharedMsgId st userId contactId sharedMsgId = liftIOEither . withTransaction st $ \db -> runExceptT $ do itemId <- ExceptT $ getDirectChatItemIdBySharedMsgId_ db userId contactId sharedMsgId liftIOEither $ getDirectChatItem_ db userId contactId itemId +getDirectChatItemByAgentMsgId :: MonadUnliftIO m => SQLiteStore -> UserId -> ContactId -> Int64 -> AgentMsgId -> m (Maybe (CChatItem 'CTDirect)) +getDirectChatItemByAgentMsgId st userId contactId connId msgId = + liftIO . withTransaction st $ \db -> do + itemId_ <- getChatItemIdByAgentMsgId_ db connId msgId + maybe (pure Nothing) (fmap eitherToMaybe . getDirectChatItem_ db userId contactId) itemId_ + getDirectChatItemIdBySharedMsgId_ :: DB.Connection -> UserId -> Int64 -> SharedMsgId -> IO (Either StoreError Int64) getDirectChatItemIdBySharedMsgId_ db userId contactId sharedMsgId = firstRow fromOnly (SEChatItemSharedMsgIdNotFound sharedMsgId) $ @@ -3316,7 +3319,7 @@ getDirectChatItem_ db userId contactId itemId = do [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- DirectQuote @@ -3446,7 +3449,7 @@ getGroupChatItem_ db User {userId, userContactId} groupId itemId = do [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- GroupMember @@ -3588,9 +3591,9 @@ toChatStats (unreadCount, minUnreadItemId) = ChatStats {unreadCount, minUnreadIt type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, Maybe ACIFileStatus) -type ChatItemRow = (Int64, ChatItemTs, ACIContent, Text, ACIStatus, Maybe SharedMsgId, Bool, Maybe Bool, UTCTime) :. MaybeCIFIleRow +type ChatItemRow = (Int64, ChatItemTs, ACIContent, Text, ACIStatus, Maybe SharedMsgId, Bool, Maybe Bool, UTCTime, UTCTime) :. MaybeCIFIleRow -type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe ACIContent, Maybe Text, Maybe ACIStatus, Maybe SharedMsgId, Maybe Bool, Maybe Bool, Maybe UTCTime) :. MaybeCIFIleRow +type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe ACIContent, Maybe Text, Maybe ACIStatus, Maybe SharedMsgId, Maybe Bool, Maybe Bool, Maybe UTCTime, Maybe UTCTime) :. MaybeCIFIleRow type QuoteRow = (Maybe ChatItemId, Maybe SharedMsgId, Maybe UTCTime, Maybe MsgContent, Maybe Bool) @@ -3604,7 +3607,7 @@ toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _) dir CIQuote <$> dir <*> pure quotedItemId <*> pure quotedSharedMsgId <*> quotedSentAt <*> quotedMsgContent <*> (parseMaybeMarkdownList . msgContentText <$> quotedMsgContent) toDirectChatItem :: TimeZone -> UTCTime -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect) -toDirectChatItem tz currentTs (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. (fileId_, fileName_, fileSize_, filePath, fileStatus_)) :. quoteRow) = +toDirectChatItem tz currentTs (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt, updatedAt) :. (fileId_, fileName_, fileSize_, filePath, fileStatus_)) :. quoteRow) = case (itemContent, itemStatus, fileStatus_) of (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, Just (AFS SMDSnd fileStatus)) -> Right $ cItem SMDSnd CIDirectSnd ciStatus ciContent (maybeCIFile fileStatus) @@ -3626,11 +3629,11 @@ toDirectChatItem tz currentTs (((itemId, itemTs, itemContent, itemText, itemStat CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow, file} badItem = Left $ SEBadChatItem itemId ciMeta :: CIContent d -> CIStatus d -> CIMeta d - ciMeta content status = mkCIMeta itemId content itemText status sharedMsgId itemDeleted (fromMaybe False itemEdited) tz currentTs itemTs createdAt + ciMeta content status = mkCIMeta itemId content itemText status sharedMsgId itemDeleted (fromMaybe False itemEdited) tz currentTs itemTs createdAt updatedAt toDirectChatItemList :: TimeZone -> UTCTime -> MaybeChatItemRow :. QuoteRow -> [CChatItem 'CTDirect] -toDirectChatItemList tz currentTs (((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just itemDeleted, itemEdited, Just createdAt) :. fileRow) :. quoteRow) = - either (const []) (: []) $ toDirectChatItem tz currentTs (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. fileRow) :. quoteRow) +toDirectChatItemList tz currentTs (((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just itemDeleted, itemEdited, Just createdAt, Just updatedAt) :. fileRow) :. quoteRow) = + either (const []) (: []) $ toDirectChatItem tz currentTs (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt, updatedAt) :. fileRow) :. quoteRow) toDirectChatItemList _ _ _ = [] type GroupQuoteRow = QuoteRow :. MaybeGroupMemberRow @@ -3646,7 +3649,7 @@ toGroupQuote qr@(_, _, _, _, quotedSent) quotedMember_ = toQuote qr $ direction direction _ _ = Nothing toGroupChatItem :: TimeZone -> UTCTime -> Int64 -> ChatItemRow :. MaybeGroupMemberRow :. GroupQuoteRow -> Either StoreError (CChatItem 'CTGroup) -toGroupChatItem tz currentTs userContactId (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. (fileId_, fileName_, fileSize_, filePath, fileStatus_)) :. memberRow_ :. quoteRow :. quotedMemberRow_) = do +toGroupChatItem tz currentTs userContactId (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt, updatedAt) :. (fileId_, fileName_, fileSize_, filePath, fileStatus_)) :. memberRow_ :. quoteRow :. quotedMemberRow_) = do let member_ = toMaybeGroupMember userContactId memberRow_ let quotedMember_ = toMaybeGroupMember userContactId quotedMemberRow_ case (itemContent, itemStatus, member_, fileStatus_) of @@ -3670,11 +3673,11 @@ toGroupChatItem tz currentTs userContactId (((itemId, itemTs, itemContent, itemT CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_, file} badItem = Left $ SEBadChatItem itemId ciMeta :: CIContent d -> CIStatus d -> CIMeta d - ciMeta content status = mkCIMeta itemId content itemText status sharedMsgId itemDeleted (fromMaybe False itemEdited) tz currentTs itemTs createdAt + ciMeta content status = mkCIMeta itemId content itemText status sharedMsgId itemDeleted (fromMaybe False itemEdited) tz currentTs itemTs createdAt updatedAt toGroupChatItemList :: TimeZone -> UTCTime -> Int64 -> MaybeGroupChatItemRow -> [CChatItem 'CTGroup] -toGroupChatItemList tz currentTs userContactId (((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just itemDeleted, itemEdited, Just createdAt) :. fileRow) :. memberRow_ :. quoteRow :. quotedMemberRow_) = - either (const []) (: []) $ toGroupChatItem tz currentTs userContactId (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. fileRow) :. memberRow_ :. quoteRow :. quotedMemberRow_) +toGroupChatItemList tz currentTs userContactId (((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just itemDeleted, itemEdited, Just createdAt, Just updatedAt) :. fileRow) :. memberRow_ :. quoteRow :. quotedMemberRow_) = + either (const []) (: []) $ toGroupChatItem tz currentTs userContactId (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt, updatedAt) :. fileRow) :. memberRow_ :. quoteRow :. quotedMemberRow_) toGroupChatItemList _ _ _ _ = [] getSMPServers :: MonadUnliftIO m => SQLiteStore -> User -> m [SMPServer] diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index fcb11f7f5f..8c200b067e 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -37,7 +37,7 @@ import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, sumTypeJSON) import Simplex.Messaging.Util ((<$?>)) class IsContact a where - contactId' :: a -> Int64 + contactId' :: a -> ContactId profile' :: a -> Profile localDisplayName' :: a -> ContactName @@ -53,7 +53,7 @@ instance IsContact Contact where data User = User { userId :: UserId, - userContactId :: Int64, + userContactId :: ContactId, localDisplayName :: ContactName, profile :: Profile, activeUser :: Bool @@ -62,10 +62,12 @@ data User = User instance ToJSON User where toEncoding = J.genericToEncoding J.defaultOptions -type UserId = Int64 +type UserId = ContactId + +type ContactId = Int64 data Contact = Contact - { contactId :: Int64, + { contactId :: ContactId, localDisplayName :: ContactName, profile :: Profile, activeConn :: Connection, @@ -85,7 +87,7 @@ contactConnId :: Contact -> ConnId contactConnId Contact {activeConn} = aConnId activeConn data ContactRef = ContactRef - { contactId :: Int64, + { contactId :: ContactId, localDisplayName :: ContactName } deriving (Eq, Show, Generic) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 1e816ee479..7a1a3dd257 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -139,11 +139,11 @@ responseToView testView = \case ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] CRRcvFileSubError RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e -> ["received file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] - CRCallInvitation {} -> [] - CRCallOffer {} -> [] - CRCallAnswer {} -> [] - CRCallExtraInfo {} -> [] - CRCallEnded {} -> [] + CRCallInvitation {contact} -> ["call invitation from " <> ttyContact' contact] + CRCallOffer {contact} -> ["call offer from " <> ttyContact' contact] + CRCallAnswer {contact} -> ["call answer from " <> ttyContact' contact] + CRCallExtraInfo {contact} -> ["call extra info from " <> ttyContact' contact] + CRCallEnded {contact} -> ["call with " <> ttyContact' contact <> " ended"] CRUserContactLinkSubscribed -> ["Your address is active! To show: " <> highlight' "/sa"] CRUserContactLinkSubError e -> ["user address error: " <> sShow e, "to delete your address: " <> highlight' "/da"] CRNewContactConnection _ -> [] diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 05e0ea7b6c..fcf52e9772 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -9,9 +9,13 @@ import ChatClient import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) import Control.Concurrent.STM -import qualified Data.ByteString as B +import Data.Aeson (ToJSON, (.=)) +import qualified Data.Aeson as J +import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char (isDigit) import qualified Data.Text as T +import Simplex.Chat.Call import Simplex.Chat.Controller (ChatController (..)) import Simplex.Chat.Types (ConnStatus (..), ImageData (..), Profile (..), User (..)) import Simplex.Chat.Util (unlessM) @@ -82,6 +86,8 @@ chatTests = do xdescribe "async sending and receiving files" $ do it "send and receive file, fully asynchronous" testAsyncFileTransfer it "send and receive file to group, fully asynchronous" testAsyncGroupFileTransfer + describe "webrtc calls api" $ do + it "negotiate call" testNegotiateCall testAddContact :: IO () testAddContact = @@ -1762,6 +1768,66 @@ testAsyncGroupFileTransfer = withTmpFiles $ do dest2 <- B.readFile "./tests/tmp/test_1.jpg" dest2 `shouldBe` src +testCallType :: CallType +testCallType = CallType {media = CMVideo, capabilities = CallCapabilities {encryption = True}} + +testWebRTCSession :: WebRTCSession +testWebRTCSession = + WebRTCSession + { rtcSession = J.object ["test" .= (123 :: Int)], + rtcIceCandidates = [] + } + +testWebRTCCallOffer :: WebRTCCallOffer +testWebRTCCallOffer = + WebRTCCallOffer + { callType = testCallType, + rtcSession = testWebRTCSession + } + +serialize :: ToJSON a => a -> String +serialize = B.unpack . LB.toStrict . J.encode + +testNegotiateCall :: IO () +testNegotiateCall = + testChat2 aliceProfile bobProfile $ \alice bob -> do + connectUsers alice bob + -- alice invite bob to call + alice ##> ("/_call invite @2 " <> serialize testCallType) + alice <## "ok" + alice #$> ("/_get chat @2 count=100", chat, [(1, "outgoing call: calling...")]) + bob <## "call invitation from alice" + bob #$> ("/_get chat @2 count=100", chat, [(0, "incoming call: calling...")]) + -- bob accepts call by sending WebRTC offer + bob ##> ("/_call offer @2 " <> serialize testWebRTCCallOffer) + bob <## "ok" + bob #$> ("/_get chat @2 count=100", chat, [(0, "incoming call: accepted")]) + alice <## "call offer from bob" + alice <## "message updated" -- call chat item updated + alice #$> ("/_get chat @2 count=100", chat, [(1, "outgoing call: accepted")]) + -- alice confirms call by sending WebRTC answer + alice ##> ("/_call answer @2 " <> serialize testWebRTCSession) + alice <## "ok" + alice <## "message updated" + alice #$> ("/_get chat @2 count=100", chat, [(1, "outgoing call: connecting...")]) + bob <## "call answer from alice" + bob #$> ("/_get chat @2 count=100", chat, [(0, "incoming call: connecting...")]) + -- participants can update calls as connected + alice ##> "/_call status @2 connected" + alice <## "ok" + alice <## "message updated" + alice #$> ("/_get chat @2 count=100", chat, [(1, "outgoing call: in progress (00:00)")]) + bob ##> "/_call status @2 connected" + bob <## "ok" + bob #$> ("/_get chat @2 count=100", chat, [(0, "incoming call: in progress (00:00)")]) + -- either party can end the call + bob ##> "/_call end @2" + bob <## "ok" + bob #$> ("/_get chat @2 count=100", chat, [(0, "incoming call: ended (00:00)")]) + alice <## "call with bob ended" + alice <## "message updated" + alice #$> ("/_get chat @2 count=100", chat, [(1, "outgoing call: ended (00:00)")]) + withTestChatContactConnected :: String -> (TestCC -> IO a) -> IO a withTestChatContactConnected dbPrefix action = withTestChat dbPrefix $ \cc -> do From f2036236f68463659df07941ff05489f34d602c6 Mon Sep 17 00:00:00 2001 From: IanRDavies Date: Wed, 4 May 2022 23:07:26 +0100 Subject: [PATCH 50/95] ios: webrtc calls started (#594) * building towards ios calls * minor ios changes * linting * RPC calls with webview * tidy up * more types * ios: webrtc types, call works * remove comment * prettier * add prettier to build Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../android/app/src/main/assets/www/README.md | 4 +- apps/android/app/src/main/assets/www/call.js | 229 +++++++------ .../android/app/src/main/assets/www/style.css | 31 +- apps/ios/Shared/Views/Call/CallView.swift | 188 ++++++++--- apps/ios/Shared/Views/Call/WebRTC.swift | 229 +++++++++++++ apps/ios/SimpleX.xcodeproj/project.pbxproj | 12 +- packages/simplex-chat-webrtc/.prettierignore | 1 + packages/simplex-chat-webrtc/README.md | 6 + packages/simplex-chat-webrtc/copy | 10 + packages/simplex-chat-webrtc/package.json | 3 +- packages/simplex-chat-webrtc/src/call.ts | 318 ++++++++++-------- packages/simplex-chat-webrtc/src/style.css | 31 +- 12 files changed, 715 insertions(+), 347 deletions(-) create mode 100644 apps/ios/Shared/Views/Call/WebRTC.swift create mode 100644 packages/simplex-chat-webrtc/.prettierignore create mode 100644 packages/simplex-chat-webrtc/README.md create mode 100755 packages/simplex-chat-webrtc/copy diff --git a/apps/android/app/src/main/assets/www/README.md b/apps/android/app/src/main/assets/www/README.md index 272433c4c9..cf012e4d58 100644 --- a/apps/android/app/src/main/assets/www/README.md +++ b/apps/android/app/src/main/assets/www/README.md @@ -1 +1,3 @@ -Do NOT edit call.js here, it is compiled from call.ts in packages/simplex-chat-webrtc +# WebView for WebRTC calls in SimpleX Chat + +Do NOT edit call.js here, it is compiled abd copied here from call.ts in packages/simplex-chat-webrtc diff --git a/apps/android/app/src/main/assets/www/call.js b/apps/android/app/src/main/assets/www/call.js index 4e7f21a2c8..94ef42c09f 100644 --- a/apps/android/app/src/main/assets/www/call.js +++ b/apps/android/app/src/main/assets/www/call.js @@ -6,15 +6,9 @@ var CallMediaType; CallMediaType["Audio"] = "audio"; CallMediaType["Video"] = "video"; })(CallMediaType || (CallMediaType = {})); -// STUN servers -const peerConnectionConfig = { - iceServers: [{ urls: ["stun:stun.l.google.com:19302"] }], - iceCandidatePoolSize: 10, - encodedInsertableStreams: true, -}; const keyAlgorithm = { name: "AES-GCM", - length: 256 + length: 256, }; const keyUsages = ["encrypt", "decrypt"]; let pc; @@ -24,17 +18,24 @@ const initialPlainTextRequired = { delta: 3, undefined: 1, }; -const defaultCallConfig = { - iceCandidates: { - delay: 2000, - extrasInterval: 2000, - extrasTimeout: 8000 - } -}; +function defaultCallConfig(encodedInsertableStreams) { + return { + peerConnectionConfig: { + iceServers: [{ urls: ["stun:stun.l.google.com:19302"] }], + iceCandidatePoolSize: 10, + encodedInsertableStreams, + }, + iceCandidates: { + delay: 2000, + extrasInterval: 2000, + extrasTimeout: 8000, + }, + }; +} async function initializeCall(config, mediaType, aesKey) { - const conn = new RTCPeerConnection(peerConnectionConfig); + const conn = new RTCPeerConnection(config.peerConnectionConfig); const remoteStream = new MediaStream(); - const localStream = await navigator.mediaDevices.getUserMedia(callMediaContraints(mediaType)); + const localStream = await navigator.mediaDevices.getUserMedia(callMediaConstraints(mediaType)); await setUpMediaStreams(conn, localStream, remoteStream, aesKey); conn.addEventListener("connectionstatechange", connectionStateChange); const iceCandidates = new Promise((resolve, _) => { @@ -73,141 +74,144 @@ async function initializeCall(config, mediaType, aesKey) { if (delay) clearTimeout(delay); resolved = true; - const iceCandidates = candidates.slice(); + const iceCandidates = candidates.map((c) => JSON.stringify(c)); candidates = []; resolve(iceCandidates); } function sendIceCandidates() { if (candidates.length === 0) return; - const iceCandidates = candidates.slice(); + const iceCandidates = candidates.map((c) => JSON.stringify(c)); candidates = []; - sendMessageToNative({ type: "ice", iceCandidates }); + sendMessageToNative({ resp: { type: "ice", iceCandidates } }); } }); return { connection: conn, iceCandidates }; function connectionStateChange() { sendMessageToNative({ - type: "connection", - state: { - connectionState: conn.connectionState, - iceConnectionState: conn.iceConnectionState, - iceGatheringState: conn.iceGatheringState, - signalingState: conn.signalingState, - } + resp: { + type: "connection", + state: { + connectionState: conn.connectionState, + iceConnectionState: conn.iceConnectionState, + iceGatheringState: conn.iceGatheringState, + signalingState: conn.signalingState, + }, + }, }); if (conn.connectionState == "disconnected" || conn.connectionState == "failed") { conn.removeEventListener("connectionstatechange", connectionStateChange); - sendMessageToNative({ type: "ended" }); + sendMessageToNative({ resp: { type: "ended" } }); conn.close(); pc = undefined; resetVideoElements(); } } } -// TODO remove WCallCommand from parameter type function sendMessageToNative(msg) { console.log(JSON.stringify(msg)); } // TODO remove WCallCommand from result type -async function processCommand(command) { +async function processCommand(body) { + const { command, corrId } = body; let resp; - switch (command.type) { - case "capabilities": - const encryption = supportsInsertableStreams(); - resp = { type: "capabilities", capabilities: { encryption } }; - break; - case "start": - console.log("starting call"); - if (pc) { - resp = { type: "error", message: "start: call already started" }; - } - else if (!supportsInsertableStreams() && command.aesKey) { - resp = { type: "error", message: "start: encryption is not supported" }; - } - else { - try { + try { + switch (command.type) { + case "capabilities": + const encryption = supportsInsertableStreams(); + resp = { type: "capabilities", capabilities: { encryption } }; + break; + case "start": + console.log("starting call"); + if (pc) { + resp = { type: "error", message: "start: call already started" }; + } + else if (!supportsInsertableStreams() && command.aesKey) { + resp = { type: "error", message: "start: encryption is not supported" }; + } + else { const { media, aesKey } = command; - const call = await initializeCall(defaultCallConfig, media, aesKey); + const call = await initializeCall(defaultCallConfig(!!aesKey), media, aesKey); const { connection, iceCandidates } = call; pc = connection; const offer = await pc.createOffer(); await pc.setLocalDescription(offer); // for debugging, returning the command for callee to use - resp = { type: "accept", offer, iceCandidates: await iceCandidates, media, aesKey }; + resp = { type: "accept", offer: JSON.stringify(offer), iceCandidates: await iceCandidates, media, aesKey }; // resp = {type: "offer", offer, iceCandidates: await iceCandidates} } - catch (e) { - resp = { type: "error", message: e.message }; + break; + case "accept": + if (pc) { + resp = { type: "error", message: "accept: call already started" }; } - } - break; - case "accept": - if (pc) { - resp = { type: "error", message: "accept: call already started" }; - } - else if (!supportsInsertableStreams() && command.aesKey) { - resp = { type: "error", message: "accept: encryption is not supported" }; - } - else { - try { - const call = await initializeCall(defaultCallConfig, command.media, command.aesKey); + else if (!supportsInsertableStreams() && command.aesKey) { + resp = { type: "error", message: "accept: encryption is not supported" }; + } + else { + const offer = JSON.parse(command.offer); + const remoteIceCandidates = command.iceCandidates.map((c) => JSON.parse(c)); + const call = await initializeCall(defaultCallConfig(!!command.aesKey), command.media, command.aesKey); const { connection, iceCandidates } = call; pc = connection; - await pc.setRemoteDescription(new RTCSessionDescription(command.offer)); + await pc.setRemoteDescription(new RTCSessionDescription(offer)); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); - addIceCandidates(pc, command.iceCandidates); + addIceCandidates(pc, remoteIceCandidates); // same as command for caller to use - resp = { type: "answer", answer, iceCandidates: await iceCandidates }; + resp = { type: "answer", answer: JSON.stringify(answer), iceCandidates: await iceCandidates }; } - catch (e) { - resp = { type: "error", message: e.message }; + break; + case "answer": + if (!pc) { + resp = { type: "error", message: "answer: call not started" }; } - } - break; - case "answer": - if (!pc) { - resp = { type: "error", message: "answer: call not started" }; - } - else if (!pc.localDescription) { - resp = { type: "error", message: "answer: local description is not set" }; - } - else if (pc.currentRemoteDescription) { - resp = { type: "error", message: "answer: remote description already set" }; - } - else { - await pc.setRemoteDescription(new RTCSessionDescription(command.answer)); - addIceCandidates(pc, command.iceCandidates); - resp = { type: "ok" }; - } - break; - case "ice": - if (pc) { - addIceCandidates(pc, command.iceCandidates); - resp = { type: "ok" }; - } - else { - resp = { type: "error", message: "ice: call not started" }; - } - break; - case "end": - if (pc) { - pc.close(); - pc = undefined; - resetVideoElements(); - resp = { type: "ok" }; - } - else { - resp = { type: "error", message: "end: call not started" }; - } - break; - default: - resp = { type: "error", message: "unknown command" }; - break; + else if (!pc.localDescription) { + resp = { type: "error", message: "answer: local description is not set" }; + } + else if (pc.currentRemoteDescription) { + resp = { type: "error", message: "answer: remote description already set" }; + } + else { + const answer = JSON.parse(command.answer); + const remoteIceCandidates = command.iceCandidates.map((c) => JSON.parse(c)); + await pc.setRemoteDescription(new RTCSessionDescription(answer)); + addIceCandidates(pc, remoteIceCandidates); + resp = { type: "ok" }; + } + break; + case "ice": + if (pc) { + const remoteIceCandidates = command.iceCandidates.map((c) => JSON.parse(c)); + addIceCandidates(pc, remoteIceCandidates); + resp = { type: "ok" }; + } + else { + resp = { type: "error", message: "ice: call not started" }; + } + break; + case "end": + if (pc) { + pc.close(); + pc = undefined; + resetVideoElements(); + resp = { type: "ok" }; + } + else { + resp = { type: "error", message: "end: call not started" }; + } + break; + default: + resp = { type: "error", message: "unknown command" }; + break; + } } - sendMessageToNative(resp); - return resp; + catch (e) { + resp = { type: "error", message: e.message }; + } + const apiResp = { resp, corrId }; + sendMessageToNative(apiResp); + return apiResp; } function addIceCandidates(conn, iceCandidates) { for (const c of iceCandidates) { @@ -274,7 +278,7 @@ async function setUpMediaStreams(pc, localStream, remoteStream, aesKey) { videos.local.srcObject = localStream; videos.remote.srcObject = remoteStream; } -function callMediaContraints(mediaType) { +function callMediaConstraints(mediaType) { switch (mediaType) { case CallMediaType.Audio: return { audio: true, video: false }; @@ -294,8 +298,7 @@ function callMediaContraints(mediaType) { } } function supportsInsertableStreams() { - return ("createEncodedStreams" in RTCRtpSender.prototype) - && ("createEncodedStreams" in RTCRtpReceiver.prototype); + return "createEncodedStreams" in RTCRtpSender.prototype && "createEncodedStreams" in RTCRtpReceiver.prototype; } function resetVideoElements() { const videos = getVideoElements(); @@ -326,10 +329,6 @@ function getVideoElements() { // localStream.getVideoTracks()[0].enabled = false // } // } -function f() { - console.log("Debug Function"); - return "Debugging Return"; -} /* Stream Transforms */ function setupPeerTransform(peer, transform) { const streams = peer.createEncodedStreams(); diff --git a/apps/android/app/src/main/assets/www/style.css b/apps/android/app/src/main/assets/www/style.css index 6debc26285..624e96dfe7 100644 --- a/apps/android/app/src/main/assets/www/style.css +++ b/apps/android/app/src/main/assets/www/style.css @@ -1,24 +1,25 @@ video::-webkit-media-controls { display: none; } -html, body { - padding: 0; - margin: 0; +html, +body { + padding: 0; + margin: 0; } #remote-video-stream { - position: absolute; - width: 100%; - height: 100%; - object-fit: cover; + position: absolute; + width: 100%; + height: 100%; + object-fit: cover; } #local-video-stream { - position: absolute; - width: 30%; - max-width: 30%; - object-fit: cover; - margin: 16px; - border-radius: 16px; - bottom: 0; - right: 0; + position: absolute; + width: 30%; + max-width: 30%; + object-fit: cover; + margin: 16px; + border-radius: 16px; + bottom: 0; + right: 0; } diff --git a/apps/ios/Shared/Views/Call/CallView.swift b/apps/ios/Shared/Views/Call/CallView.swift index 383558be8a..569f481429 100644 --- a/apps/ios/Shared/Views/Call/CallView.swift +++ b/apps/ios/Shared/Views/Call/CallView.swift @@ -9,83 +9,177 @@ import SwiftUI import WebKit -struct WebView: UIViewRepresentable { - class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { - var webView: WKWebView! +class WebRTCCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { + var webView: WKWebView! + var corrId = 0 + var pendingCommands: Dictionary> = [:] - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - webView.allowsBackForwardNavigationGestures = false - self.webView = webView - } + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + webView.allowsBackForwardNavigationGestures = false + self.webView = webView + } - // receive message from wkwebview - func userContentController( - _ userContentController: WKUserContentController, - didReceive message: WKScriptMessage - ) { - print(message.body) -// let date = Date() -// DispatchQueue.main.asyncAfter(deadline: .now() + 1) { -// self.messageToWebview(msg: "hello, I got your messsage: \(message.body) at \(date)") -// } - } - - func messageToWebview(msg: String) { - self.webView?.evaluateJavaScript("webkit.messageHandlers.bridge.onMessage('\(msg)')") + // receive message from WKWebView + func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + logger.debug("WebRTCCoordinator.userContentController") + if let data = (message.body as? String)?.data(using: .utf8), + let msg = try? jsonDecoder.decode(WVAPIMessage.self, from: data) { + if let corrId = msg.corrId, let cont = pendingCommands.removeValue(forKey: corrId) { + cont.resume(returning: msg.resp) + } else { + // TODO pass messages to call view via binding + // print(msg.resp) + } + } else { + logger.error("WebRTCCoordinator.userContentController: invalid message \(String(describing: message.body))") } } - func makeCoordinator() -> Coordinator { - return Coordinator() + func messageToWebview(msg: String) { + logger.debug("WebRTCCoordinator.messageToWebview") + self.webView.evaluateJavaScript("webkit.messageHandlers.logHandler.postMessage('\(msg)')") + } + + func processCommand(command: WCallCommand) async -> WCallResponse { + await withCheckedContinuation { cont in + logger.debug("WebRTCCoordinator.processCommand") + let corrId_ = corrId + corrId = corrId + 1 + pendingCommands[corrId_] = cont + do { + let apiData = try jsonEncoder.encode(WVAPICall(corrId: corrId_, command: command)) + DispatchQueue.main.async { + logger.debug("WebRTCCoordinator.processCommand DispatchQueue.main.async") + let js = "processCommand(\(String(decoding: apiData, as: UTF8.self)))" + self.webView.evaluateJavaScript(js) + } + } catch { + logger.error("WebRTCCoordinator.processCommand: error encoding command \(error.localizedDescription)") + } + } + } +} + +struct WebRTCView: UIViewRepresentable { + @Binding var coordinator: WebRTCCoordinator? + + func makeCoordinator() -> WebRTCCoordinator { + WebRTCCoordinator() } func makeUIView(context: Context) -> WKWebView { - let coordinator = makeCoordinator() - let userContentController = WKUserContentController() - userContentController.add(coordinator, name: "bridge") + let _coordinator = makeCoordinator() + DispatchQueue.main.async { + coordinator = _coordinator + } - let configuration = WKWebViewConfiguration() - configuration.userContentController = userContentController - configuration.mediaTypesRequiringUserActionForPlayback = [] - configuration.allowsInlineMediaPlayback = true + let userContentController = WKUserContentController() + + let cfg = WKWebViewConfiguration() + cfg.userContentController = userContentController + cfg.mediaTypesRequiringUserActionForPlayback = [] + cfg.allowsInlineMediaPlayback = true // Enable us to capture calls to console.log in the xcode logs - // Print actually happens on line 29 let source = "console.log = (msg) => webkit.messageHandlers.logHandler.postMessage(msg)" let script = WKUserScript(source: source, injectionTime: .atDocumentStart, forMainFrameOnly: false) - configuration.userContentController.addUserScript(script) - configuration.userContentController.add(coordinator, name: "logHandler") - - let _wkwebview = WKWebView(frame: .zero, configuration: configuration) - _wkwebview.navigationDelegate = coordinator + cfg.userContentController.addUserScript(script) + cfg.userContentController.add(_coordinator, name: "logHandler") + let _wkwebview = WKWebView(frame: .zero, configuration: cfg) + _wkwebview.navigationDelegate = _coordinator + guard let path: String = Bundle.main.path(forResource: "call", ofType: "html", inDirectory: "www") else { + logger.error("WebRTCView.makeUIView call.html not found") + return _wkwebview + } + let localHTMLUrl = URL(fileURLWithPath: path, isDirectory: false) + _wkwebview.loadFileURL(localHTMLUrl, allowingReadAccessTo: localHTMLUrl) return _wkwebview } func updateUIView(_ webView: WKWebView, context: Context) { - guard let path: String = Bundle.main.path(forResource: "call", ofType: "html", inDirectory: "www") else { - print("page not found") - return - } - let localHTMLUrl = URL(fileURLWithPath: path, isDirectory: false) - webView.loadFileURL(localHTMLUrl, allowingReadAccessTo: localHTMLUrl) + logger.debug("WebRTCView.updateUIView") } } struct CallView: View { + @State var coordinator: WebRTCCoordinator? = nil + @State var commandStr = "" + @FocusState private var keyboardVisible: Bool + var body: some View { - VStack { - WebView() + VStack(spacing: 30) { + WebRTCView(coordinator: $coordinator).frame(maxHeight: 260) + TextEditor(text: $commandStr) + .focused($keyboardVisible) + .disableAutocorrection(true) + .textInputAutocapitalization(.never) + .padding(.horizontal, 5) + .padding(.top, 2) + .frame(height: 112) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true) + ) + HStack(spacing: 20) { + Button("Copy") { + UIPasteboard.general.string = commandStr + } + Button("Paste") { + commandStr = UIPasteboard.general.string ?? "" + } + Button("Clear") { + commandStr = "" + } + Button("Send") { + do { + let command = try jsonDecoder.decode(WCallCommand.self, from: commandStr.data(using: .utf8)!) + if let c = coordinator { + Task { + let resp = await c.processCommand(command: command) + print(String(decoding: try! jsonEncoder.encode(resp), as: UTF8.self)) + } + } + } catch { + print(error) + } + } + } + HStack(spacing: 20) { + Button("Capabilities") { + + } + Button("Start") { + if let c = coordinator { + Task { + let resp = await c.processCommand(command: .start(media: .video)) + print(String(decoding: try! jsonEncoder.encode(resp), as: UTF8.self)) + } + } + } + Button("Accept") { + + } + Button("Answer") { + + } + Button("ICE") { + + } + Button("End") { + + } + } } } } - - - struct CallView_Previews: PreviewProvider { static var previews: some View { CallView() diff --git a/apps/ios/Shared/Views/Call/WebRTC.swift b/apps/ios/Shared/Views/Call/WebRTC.swift new file mode 100644 index 0000000000..7af5b90cca --- /dev/null +++ b/apps/ios/Shared/Views/Call/WebRTC.swift @@ -0,0 +1,229 @@ +// +// WebRTC.swift +// SimpleX (iOS) +// +// Created by Evgeny on 03/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation + +struct WVAPICall: Encodable { + var corrId: Int + var command: WCallCommand +} + +struct WVAPIMessage: Decodable { + var corrId: Int? + var resp: WCallResponse +} + +enum WCallCommand { + case capabilities + case start(media: CallMediaType, aesKey: String? = nil) + case accept(offer: String, iceCandidates: [String], media: CallMediaType, aesKey: String? = nil) + case answer(answer: String, iceCandidates: [String]) + case ice(iceCandidates: [String]) + case end + + enum CodingKeys: String, CodingKey { + case type + case media + case aesKey + case offer + case answer + case iceCandidates + } +} + +extension WCallCommand: Encodable { + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .capabilities: + try container.encode("capabilities", forKey: .type) + case let .start(media, aesKey): + try container.encode("start", forKey: .type) + try container.encode(media, forKey: .media) + try container.encode(aesKey, forKey: .aesKey) + case let .accept(offer, iceCandidates, media, aesKey): + try container.encode("accept", forKey: .type) + try container.encode(offer, forKey: .offer) + try container.encode(iceCandidates, forKey: .iceCandidates) + try container.encode(media, forKey: .media) + try container.encode(aesKey, forKey: .aesKey) + case let .answer(answer, iceCandidates): + try container.encode("answer", forKey: .type) + try container.encode(answer, forKey: .answer) + try container.encode(iceCandidates, forKey: .iceCandidates) + case let .ice(iceCandidates): + try container.encode("ice", forKey: .type) + try container.encode(iceCandidates, forKey: .iceCandidates) + case .end: + try container.encode("end", forKey: .type) + } + } +} + +// This protocol is only needed for debugging +extension WCallCommand: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: CodingKeys.type) + switch type { + case "capabilities": + self = .capabilities + case "start": + let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media) + let aesKey = try? container.decode(String.self, forKey: CodingKeys.aesKey) + self = .start(media: media, aesKey: aesKey) + case "accept": + let offer = try container.decode(String.self, forKey: CodingKeys.offer) + let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates) + let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media) + let aesKey = try? container.decode(String.self, forKey: CodingKeys.aesKey) + self = .accept(offer: offer, iceCandidates: iceCandidates, media: media, aesKey: aesKey) + case "answer": + let answer = try container.decode(String.self, forKey: CodingKeys.answer) + let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates) + self = .answer(answer: answer, iceCandidates: iceCandidates) + case "ice": + let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates) + self = .ice(iceCandidates: iceCandidates) + case "end": + self = .end + default: + throw DecodingError.typeMismatch(WCallCommand.self, DecodingError.Context(codingPath: [CodingKeys.type], debugDescription: "cannot decode WCallCommand, unknown type \(type)")) + } + } +} + + +enum WCallResponse { + case capabilities(capabilities: CallCapabilities) + case offer(offer: String, iceCandidates: [String]) + // TODO remove accept, it is needed for debugging + case accept(offer: String, iceCandidates: [String], media: CallMediaType, aesKey: String? = nil) + case answer(answer: String, iceCandidates: [String]) + case ice(iceCandidates: [String]) + case connection(state: ConnectionState) + case ended + case ok + case error(message: String) + case invalid(type: String) + + enum CodingKeys: String, CodingKey { + case type + case capabilities + case offer + case answer + case iceCandidates + case state + case message + // TODO remove media, aesKey + case media + case aesKey + } +} + +extension WCallResponse: Decodable { + init(from decoder: Decoder) throws { + do { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: CodingKeys.type) + switch type { + case "capabilities": + let capabilities = try container.decode(CallCapabilities.self, forKey: CodingKeys.capabilities) + self = .capabilities(capabilities: capabilities) + case "offer": + let offer = try container.decode(String.self, forKey: CodingKeys.offer) + let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates) + self = .offer(offer: offer, iceCandidates: iceCandidates) + // TODO remove accept + case "accept": + let offer = try container.decode(String.self, forKey: CodingKeys.offer) + let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates) + let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media) + let aesKey = try? container.decode(String.self, forKey: CodingKeys.aesKey) + self = .accept(offer: offer, iceCandidates: iceCandidates, media: media, aesKey: aesKey) + case "answer": + let answer = try container.decode(String.self, forKey: CodingKeys.answer) + let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates) + self = .answer(answer: answer, iceCandidates: iceCandidates) + case "ice": + let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates) + self = .ice(iceCandidates: iceCandidates) + case "connection": + let state = try container.decode(ConnectionState.self, forKey: CodingKeys.state) + self = .connection(state: state) + case "ended": + self = .ended + case "ok": + self = .ok + case "error": + let message = try container.decode(String.self, forKey: CodingKeys.message) + self = .error(message: message) + default: + self = .invalid(type: type) + } + } catch { + self = .invalid(type: "unknown") + } + } +} + +// This protocol is only needed for debugging +extension WCallResponse: Encodable { + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .capabilities: + try container.encode("capabilities", forKey: .type) + case let .offer(offer, iceCandidates): + try container.encode("offer", forKey: .type) + try container.encode(offer, forKey: .offer) + try container.encode(iceCandidates, forKey: .iceCandidates) + case let .accept(offer, iceCandidates, media, aesKey): + try container.encode("accept", forKey: .type) + try container.encode(offer, forKey: .offer) + try container.encode(iceCandidates, forKey: .iceCandidates) + try container.encode(media, forKey: .media) + try container.encode(aesKey, forKey: .aesKey) + case let .answer(answer, iceCandidates): + try container.encode("answer", forKey: .type) + try container.encode(answer, forKey: .answer) + try container.encode(iceCandidates, forKey: .iceCandidates) + case let .ice(iceCandidates): + try container.encode("ice", forKey: .type) + try container.encode(iceCandidates, forKey: .iceCandidates) + case let .connection(state): + try container.encode("connection", forKey: .type) + try container.encode(state, forKey: .state) + case .ended: + try container.encode("ended", forKey: .type) + case .ok: + try container.encode("ok", forKey: .type) + case let .error(message): + try container.encode("error", forKey: .type) + try container.encode(message, forKey: .message) + case let .invalid(type): + try container.encode(type, forKey: .type) + } + } +} + +enum CallMediaType: String, Codable { + case video = "video" + case audio = "audio" +} + +struct CallCapabilities: Codable { + var encryption: Bool +} + +struct ConnectionState: Codable { + var connectionState: String + var iceConnectionState: String + var iceGatheringState: String + var signalingState: String +} diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index a3264abec2..db4127fcf2 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -45,6 +45,7 @@ 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; }; 5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; }; 5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */; }; + 5C9D13A3282187BB00AB8B43 /* WebRTC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9D13A2282187BB00AB8B43 /* WebRTC.swift */; }; 5C9FD96B27A56D4D0075386C /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96A27A56D4D0075386C /* JSON.swift */; }; 5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */; }; 5CA059DC279559F40002BEB4 /* Tests_iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059DB279559F40002BEB4 /* Tests_iOS.swift */; }; @@ -172,6 +173,7 @@ 5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = ""; }; 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoView.swift; sourceTree = ""; }; 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoImage.swift; sourceTree = ""; }; + 5C9D13A2282187BB00AB8B43 /* WebRTC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTC.swift; sourceTree = ""; }; 5C9FD96A27A56D4D0075386C /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSON.swift; sourceTree = ""; }; 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageView.swift; sourceTree = ""; }; 5CA059C3279559F40002BEB4 /* SimpleXApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXApp.swift; sourceTree = ""; }; @@ -265,6 +267,7 @@ isa = PBXGroup; children = ( 3C714776281C081000CB4D4B /* CallView.swift */, + 5C9D13A2282187BB00AB8B43 /* WebRTC.swift */, ); path = Call; sourceTree = ""; @@ -654,6 +657,7 @@ 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */, 5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */, 3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */, + 5C9D13A3282187BB00AB8B43 /* WebRTC.swift in Sources */, 5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */, 5CDCAD7628188D3600503DA2 /* APITypes.swift in Sources */, 5C9FD96B27A56D4D0075386C /* JSON.swift in Sources */, @@ -1001,7 +1005,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 39; + CURRENT_PROJECT_VERSION = 40; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1022,7 +1026,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 1.6; + MARKETING_VERSION = 1.7; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -1041,7 +1045,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 39; + CURRENT_PROJECT_VERSION = 40; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1062,7 +1066,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 1.6; + MARKETING_VERSION = 1.7; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/packages/simplex-chat-webrtc/.prettierignore b/packages/simplex-chat-webrtc/.prettierignore new file mode 100644 index 0000000000..1521c8b765 --- /dev/null +++ b/packages/simplex-chat-webrtc/.prettierignore @@ -0,0 +1 @@ +dist diff --git a/packages/simplex-chat-webrtc/README.md b/packages/simplex-chat-webrtc/README.md new file mode 100644 index 0000000000..b8db78dbb1 --- /dev/null +++ b/packages/simplex-chat-webrtc/README.md @@ -0,0 +1,6 @@ +# WebView for WebRTC calls in SimpleX Chat + +``` +npm i +npm run build +``` diff --git a/packages/simplex-chat-webrtc/copy b/packages/simplex-chat-webrtc/copy new file mode 100755 index 0000000000..9175480911 --- /dev/null +++ b/packages/simplex-chat-webrtc/copy @@ -0,0 +1,10 @@ +#!/bin/sh + +# it can be tested in the browser from dist folder +cp ./src/call.html ./dist/call.html +cp ./src/style.css ./dist/style.css + +# copy to android app +cp ./src/call.html ../../apps/android/app/src/main/assets/www/call.html +cp ./src/style.css ../../apps/android/app/src/main/assets/www/style.css +cp ./dist/call.js ../../apps/android/app/src/main/assets/www/call.js diff --git a/packages/simplex-chat-webrtc/package.json b/packages/simplex-chat-webrtc/package.json index b6f8315595..222c0c0a56 100644 --- a/packages/simplex-chat-webrtc/package.json +++ b/packages/simplex-chat-webrtc/package.json @@ -4,7 +4,8 @@ "description": "WebRTC call in browser and webview", "main": "dist/call.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "build": "prettier --write --ignore-unknown . && tsc && ./copy" }, "keywords": [ "SimpleX", diff --git a/packages/simplex-chat-webrtc/src/call.ts b/packages/simplex-chat-webrtc/src/call.ts index fddab1ebc3..9dee8a612b 100644 --- a/packages/simplex-chat-webrtc/src/call.ts +++ b/packages/simplex-chat-webrtc/src/call.ts @@ -3,73 +3,78 @@ // type WCallMessage = WCallCommand | WCallResponse -type WCallCommand = WCCapabilities | WCStartCall | WCAcceptOffer | WCEndCall | WCallCommandResponse +interface WebViewAPICall { + corrId: number + command: WCallCommand +} -type WCallResponse = WRCapabilities | WRConnection | WRCallEnded | WROk | WRError | WCallCommandResponse +// TODO remove WCallCommand from resp type +interface WebViewMessage { + corrId?: number + resp: WCallResponse | WCallCommand +} -type WCallCommandResponse = WCallOffer | WCallAnswer | WCallIceCandidates +type WCallCommand = WCCapabilities | WCStartCall | WCAcceptOffer | WCallAnswer | WCallIceCandidates | WCEndCall -type WCallMessageTag = "capabilities" | "connection" | "start" | "offer" | "accept" | "answer" | "ice" | "end" | "ended" | "ok" | "error" +type WCallResponse = WRCapabilities | WCallOffer | WCallAnswer | WCallIceCandidates | WRConnection | WRCallEnded | WROk | WRError + +type WCallCommandTag = "capabilities" | "start" | "accept" | "answer" | "ice" | "end" + +type WCallResponseTag = "capabilities" | "offer" | "answer" | "ice" | "connection" | "ended" | "ok" | "error" enum CallMediaType { Audio = "audio", Video = "video", } -interface IWebCallMessage { - type: WCallMessageTag +interface IWCallCommand { + type: WCallCommandTag } -interface WCCapabilities extends IWebCallMessage { +interface IWCallResponse { + type: WCallResponseTag +} + +interface WCCapabilities extends IWCallCommand { type: "capabilities" } -interface WRConnection extends IWebCallMessage { - type: "connection", - state: { - connectionState: string - iceConnectionState: string - iceGatheringState: string - signalingState: string - } -} - -interface WCStartCall extends IWebCallMessage { +interface WCStartCall extends IWCallCommand { type: "start" media: CallMediaType aesKey?: string } -interface WCEndCall extends IWebCallMessage { +interface WCEndCall extends IWCallCommand { type: "end" } -interface WCAcceptOffer extends IWebCallMessage { +interface WCAcceptOffer extends IWCallCommand { type: "accept" - offer: RTCSessionDescriptionInit - iceCandidates: RTCIceCandidateInit[] + offer: string // JSON string for RTCSessionDescriptionInit + iceCandidates: string[] // JSON strings for RTCIceCandidateInit media: CallMediaType aesKey?: string } -interface WCallOffer extends IWebCallMessage { +interface WCallOffer extends IWCallResponse { type: "offer" - offer: RTCSessionDescriptionInit - iceCandidates: RTCIceCandidateInit[] + offer: string // JSON string for RTCSessionDescriptionInit + iceCandidates: string[] // JSON strings for RTCIceCandidateInit } -interface WCallAnswer extends IWebCallMessage { +interface WCallAnswer extends IWCallCommand, IWCallResponse { type: "answer" - answer: RTCSessionDescriptionInit - iceCandidates: RTCIceCandidateInit[] + answer: string // JSON string for RTCSessionDescriptionInit + iceCandidates: string[] // JSON strings for RTCIceCandidateInit } -interface WCallIceCandidates extends IWebCallMessage { +interface WCallIceCandidates extends IWCallCommand, IWCallResponse { type: "ice" - iceCandidates: RTCIceCandidateInit[] + iceCandidates: string[] // JSON strings for RTCIceCandidateInit } -interface WRCapabilities { +interface WRCapabilities extends IWCallResponse { type: "capabilities" capabilities: CallCapabilities } @@ -78,15 +83,25 @@ interface CallCapabilities { encryption: boolean } -interface WRCallEnded extends IWebCallMessage { +interface WRConnection extends IWCallResponse { + type: "connection" + state: { + connectionState: string + iceConnectionState: string + iceGatheringState: string + signalingState: string + } +} + +interface WRCallEnded extends IWCallResponse { type: "ended" } -interface WROk extends IWebCallMessage { +interface WROk extends IWCallResponse { type: "ok" } -interface WRError extends IWebCallMessage { +interface WRError extends IWCallResponse { type: "error" message: string } @@ -103,16 +118,9 @@ type RTCConfigurationWithEncryption = RTCConfiguration & { encodedInsertableStreams: boolean } -// STUN servers -const peerConnectionConfig: RTCConfigurationWithEncryption = { - iceServers: [{urls: ["stun:stun.l.google.com:19302"]}], - iceCandidatePoolSize: 10, - encodedInsertableStreams: true, -} - const keyAlgorithm: AesKeyAlgorithm = { name: "AES-GCM", - length: 256 + length: 256, } const keyUsages: KeyUsage[] = ["encrypt", "decrypt"] @@ -129,10 +137,11 @@ const initialPlainTextRequired = { interface Call { connection: RTCPeerConnection - iceCandidates: Promise + iceCandidates: Promise // JSON strings for RTCIceCandidate } interface CallConfig { + peerConnectionConfig: RTCConfigurationWithEncryption iceCandidates: { delay: number extrasInterval: number @@ -140,21 +149,28 @@ interface CallConfig { } } -const defaultCallConfig: CallConfig = { - iceCandidates: { - delay: 2000, - extrasInterval: 2000, - extrasTimeout: 8000 +function defaultCallConfig(encodedInsertableStreams: boolean): CallConfig { + return { + peerConnectionConfig: { + iceServers: [{urls: ["stun:stun.l.google.com:19302"]}], + iceCandidatePoolSize: 10, + encodedInsertableStreams, + }, + iceCandidates: { + delay: 2000, + extrasInterval: 2000, + extrasTimeout: 8000, + }, } } async function initializeCall(config: CallConfig, mediaType: CallMediaType, aesKey?: string): Promise { - const conn = new RTCPeerConnection(peerConnectionConfig) + const conn = new RTCPeerConnection(config.peerConnectionConfig) const remoteStream = new MediaStream() - const localStream = await navigator.mediaDevices.getUserMedia(callMediaContraints(mediaType)) + const localStream = await navigator.mediaDevices.getUserMedia(callMediaConstraints(mediaType)) await setUpMediaStreams(conn, localStream, remoteStream, aesKey) conn.addEventListener("connectionstatechange", connectionStateChange) - const iceCandidates = new Promise((resolve, _) => { + const iceCandidates = new Promise((resolve, _) => { let candidates: RTCIceCandidate[] = [] let resolved = false let extrasInterval: number | undefined @@ -188,16 +204,16 @@ async function initializeCall(config: CallConfig, mediaType: CallMediaType, aesK function resolveIceCandidates() { if (delay) clearTimeout(delay) resolved = true - const iceCandidates = candidates.slice() + const iceCandidates = candidates.map((c) => JSON.stringify(c)) candidates = [] resolve(iceCandidates) - } + } function sendIceCandidates() { if (candidates.length === 0) return - const iceCandidates = candidates.slice() + const iceCandidates = candidates.map((c) => JSON.stringify(c)) candidates = [] - sendMessageToNative({type: "ice", iceCandidates}) + sendMessageToNative({resp: {type: "ice", iceCandidates}}) } }) @@ -205,17 +221,19 @@ async function initializeCall(config: CallConfig, mediaType: CallMediaType, aesK function connectionStateChange() { sendMessageToNative({ - type: "connection", - state: { - connectionState: conn.connectionState, - iceConnectionState: conn.iceConnectionState, - iceGatheringState: conn.iceGatheringState, - signalingState: conn.signalingState, - } + resp: { + type: "connection", + state: { + connectionState: conn.connectionState, + iceConnectionState: conn.iceConnectionState, + iceGatheringState: conn.iceGatheringState, + signalingState: conn.signalingState, + }, + }, }) if (conn.connectionState == "disconnected" || conn.connectionState == "failed") { conn.removeEventListener("connectionstatechange", connectionStateChange) - sendMessageToNative({type: "ended"}) + sendMessageToNative({resp: {type: "ended"}}) conn.close() pc = undefined resetVideoElements() @@ -223,99 +241,101 @@ async function initializeCall(config: CallConfig, mediaType: CallMediaType, aesK } } -// TODO remove WCallCommand from parameter type -function sendMessageToNative(msg: WCallResponse | WCallCommand) { +function sendMessageToNative(msg: WebViewMessage) { console.log(JSON.stringify(msg)) } // TODO remove WCallCommand from result type -async function processCommand(command: WCallCommand): Promise { +async function processCommand(body: WebViewAPICall): Promise { + const {command, corrId} = body let resp: WCallResponse | WCallCommand - switch (command.type) { - case "capabilities": - const encryption = supportsInsertableStreams() - resp = {type: "capabilities", capabilities: {encryption}} - break - case "start": - console.log("starting call") - if (pc) { - resp = {type: "error", message: "start: call already started"} - } else if (!supportsInsertableStreams() && command.aesKey) { - resp = {type: "error", message: "start: encryption is not supported"} - } else { - try { + try { + switch (command.type) { + case "capabilities": + const encryption = supportsInsertableStreams() + resp = {type: "capabilities", capabilities: {encryption}} + break + case "start": + console.log("starting call") + if (pc) { + resp = {type: "error", message: "start: call already started"} + } else if (!supportsInsertableStreams() && command.aesKey) { + resp = {type: "error", message: "start: encryption is not supported"} + } else { const {media, aesKey} = command - const call = await initializeCall(defaultCallConfig, media, aesKey) + const call = await initializeCall(defaultCallConfig(!!aesKey), media, aesKey) const {connection, iceCandidates} = call pc = connection const offer = await pc.createOffer() await pc.setLocalDescription(offer) // for debugging, returning the command for callee to use - resp = {type: "accept", offer, iceCandidates: await iceCandidates, media, aesKey} + resp = {type: "accept", offer: JSON.stringify(offer), iceCandidates: await iceCandidates, media, aesKey} // resp = {type: "offer", offer, iceCandidates: await iceCandidates} - } catch (e) { - resp = {type: "error", message: (e as Error).message} } - } - break - case "accept": - if (pc) { - resp = {type: "error", message: "accept: call already started"} - } else if (!supportsInsertableStreams() && command.aesKey) { - resp = {type: "error", message: "accept: encryption is not supported"} - } else { - try { - const call = await initializeCall(defaultCallConfig, command.media, command.aesKey) + break + case "accept": + if (pc) { + resp = {type: "error", message: "accept: call already started"} + } else if (!supportsInsertableStreams() && command.aesKey) { + resp = {type: "error", message: "accept: encryption is not supported"} + } else { + const offer = JSON.parse(command.offer) + const remoteIceCandidates = command.iceCandidates.map((c) => JSON.parse(c)) + const call = await initializeCall(defaultCallConfig(!!command.aesKey), command.media, command.aesKey) const {connection, iceCandidates} = call pc = connection - await pc.setRemoteDescription(new RTCSessionDescription(command.offer)) + await pc.setRemoteDescription(new RTCSessionDescription(offer)) const answer = await pc.createAnswer() await pc.setLocalDescription(answer) - addIceCandidates(pc, command.iceCandidates) + addIceCandidates(pc, remoteIceCandidates) // same as command for caller to use - resp = {type: "answer", answer, iceCandidates: await iceCandidates} - } catch (e) { - resp = {type: "error", message: (e as Error).message} + resp = {type: "answer", answer: JSON.stringify(answer), iceCandidates: await iceCandidates} } - } - break - case "answer": - if (!pc) { - resp = {type: "error", message: "answer: call not started"} - } else if (!pc.localDescription) { - resp = {type: "error", message: "answer: local description is not set"} - } else if (pc.currentRemoteDescription) { - resp = {type: "error", message: "answer: remote description already set"} - } else { - await pc.setRemoteDescription(new RTCSessionDescription(command.answer)) - addIceCandidates(pc, command.iceCandidates) - resp = {type: "ok"} - } - break - case "ice": - if (pc) { - addIceCandidates(pc, command.iceCandidates) - resp = {type: "ok"} - } else { - resp = {type: "error", message: "ice: call not started"} - } - break - case "end": - if (pc) { - pc.close() - pc = undefined - resetVideoElements() - resp = {type: "ok"} - } else { - resp = {type: "error", message: "end: call not started"} - } - break - default: - resp = {type: "error", message: "unknown command"} - break + break + case "answer": + if (!pc) { + resp = {type: "error", message: "answer: call not started"} + } else if (!pc.localDescription) { + resp = {type: "error", message: "answer: local description is not set"} + } else if (pc.currentRemoteDescription) { + resp = {type: "error", message: "answer: remote description already set"} + } else { + const answer = JSON.parse(command.answer) + const remoteIceCandidates = command.iceCandidates.map((c) => JSON.parse(c)) + await pc.setRemoteDescription(new RTCSessionDescription(answer)) + addIceCandidates(pc, remoteIceCandidates) + resp = {type: "ok"} + } + break + case "ice": + if (pc) { + const remoteIceCandidates = command.iceCandidates.map((c) => JSON.parse(c)) + addIceCandidates(pc, remoteIceCandidates) + resp = {type: "ok"} + } else { + resp = {type: "error", message: "ice: call not started"} + } + break + case "end": + if (pc) { + pc.close() + pc = undefined + resetVideoElements() + resp = {type: "ok"} + } else { + resp = {type: "error", message: "end: call not started"} + } + break + default: + resp = {type: "error", message: "unknown command"} + break + } + } catch (e) { + resp = {type: "error", message: (e as Error).message} } - sendMessageToNative(resp) - return resp + const apiResp = {resp, corrId} + sendMessageToNative(apiResp) + return apiResp } function addIceCandidates(conn: RTCPeerConnection, iceCandidates: RTCIceCandidateInit[]) { @@ -324,7 +344,12 @@ function addIceCandidates(conn: RTCPeerConnection, iceCandidates: RTCIceCandidat } } -async function setUpMediaStreams(pc: RTCPeerConnection, localStream: MediaStream, remoteStream: MediaStream, aesKey?: string): Promise { +async function setUpMediaStreams( + pc: RTCPeerConnection, + localStream: MediaStream, + remoteStream: MediaStream, + aesKey?: string +): Promise { const videos = getVideoElements() if (!videos) throw Error("no video elements") @@ -384,7 +409,7 @@ async function setUpMediaStreams(pc: RTCPeerConnection, localStream: MediaStream videos.remote.srcObject = remoteStream } -function callMediaContraints(mediaType: CallMediaType): MediaStreamConstraints { +function callMediaConstraints(mediaType: CallMediaType): MediaStreamConstraints { switch (mediaType) { case CallMediaType.Audio: return {audio: true, video: false} @@ -405,8 +430,7 @@ function callMediaContraints(mediaType: CallMediaType): MediaStreamConstraints { } function supportsInsertableStreams(): boolean { - return ("createEncodedStreams" in RTCRtpSender.prototype) - && ("createEncodedStreams" in RTCRtpReceiver.prototype) + return "createEncodedStreams" in RTCRtpSender.prototype && "createEncodedStreams" in RTCRtpReceiver.prototype } interface VideoElements { @@ -445,13 +469,11 @@ function getVideoElements(): VideoElements | undefined { // } // } -function f() { - console.log("Debug Function") - return "Debugging Return" -} - /* Stream Transforms */ -function setupPeerTransform(peer: RTCRtpSenderWithEncryption | RTCRtpReceiverWithEncryption, transform: (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => void) { +function setupPeerTransform( + peer: RTCRtpSenderWithEncryption | RTCRtpReceiverWithEncryption, + transform: (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => void +) { const streams = peer.createEncodedStreams() streams.readable.pipeThrough(new TransformStream({transform})).pipeTo(streams.writable) } @@ -525,9 +547,7 @@ function decodeAscii(a: Uint8Array): string { return s } -const base64chars = new Uint8Array( - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map((c) => c.charCodeAt(0)) -) +const base64chars = new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map((c) => c.charCodeAt(0))) const base64lookup = new Array(256) as (number | undefined)[] base64chars.forEach((c, i) => (base64lookup[c] = i)) diff --git a/packages/simplex-chat-webrtc/src/style.css b/packages/simplex-chat-webrtc/src/style.css index 6debc26285..624e96dfe7 100644 --- a/packages/simplex-chat-webrtc/src/style.css +++ b/packages/simplex-chat-webrtc/src/style.css @@ -1,24 +1,25 @@ video::-webkit-media-controls { display: none; } -html, body { - padding: 0; - margin: 0; +html, +body { + padding: 0; + margin: 0; } #remote-video-stream { - position: absolute; - width: 100%; - height: 100%; - object-fit: cover; + position: absolute; + width: 100%; + height: 100%; + object-fit: cover; } #local-video-stream { - position: absolute; - width: 30%; - max-width: 30%; - object-fit: cover; - margin: 16px; - border-radius: 16px; - bottom: 0; - right: 0; + position: absolute; + width: 30%; + max-width: 30%; + object-fit: cover; + margin: 16px; + border-radius: 16px; + bottom: 0; + right: 0; } From 96b514af456a158755853a2965fe202137df7217 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 4 May 2022 23:32:46 +0100 Subject: [PATCH 51/95] core: update webrtc types (#599) --- src/Simplex/Chat/Call.hs | 7 ++++--- tests/ChatTests.hs | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index 827953a249..a0f3707dd4 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -12,6 +12,7 @@ import qualified Data.Aeson as J import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) +import Data.Text (Text) import GHC.Generics (Generic) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String @@ -176,8 +177,8 @@ instance ToJSON CallExtraInfo where toEncoding = J.genericToEncoding J.defaultOptions data WebRTCSession = WebRTCSession - { rtcSession :: J.Value, - rtcIceCandidates :: [J.Value] + { rtcSession :: Text, + rtcIceCandidates :: [Text] } deriving (Eq, Show, Generic, FromJSON) @@ -186,7 +187,7 @@ instance ToJSON WebRTCSession where toEncoding = J.genericToEncoding J.defaultOptions data WebRTCExtraInfo = WebRTCExtraInfo - { rtcIceCandidates :: [J.Value] + { rtcIceCandidates :: [Text] } deriving (Eq, Show, Generic, FromJSON) diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 58edeac5f1..068b8551ec 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -9,7 +9,7 @@ import ChatClient import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) import Control.Concurrent.STM -import Data.Aeson (ToJSON, (.=)) +import Data.Aeson (ToJSON) import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB @@ -1774,8 +1774,8 @@ testCallType = CallType {media = CMVideo, capabilities = CallCapabilities {encry testWebRTCSession :: WebRTCSession testWebRTCSession = WebRTCSession - { rtcSession = J.object ["test" .= (123 :: Int)], - rtcIceCandidates = [] + { rtcSession = "{}", + rtcIceCandidates = [""] } testWebRTCCallOffer :: WebRTCCallOffer From 80c5a151a26d38df8c71d5f06d24173db7e5f9d3 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 5 May 2022 07:37:33 +0100 Subject: [PATCH 52/95] core: fix mac tests (#600) --- tests/ChatClient.hs | 4 ++-- tests/MobileTests.hs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index d4a419623e..688ccabf77 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -30,7 +30,7 @@ import Simplex.Messaging.Agent.RetryInterval import Simplex.Messaging.Server (runSMPServerBlocking) import Simplex.Messaging.Server.Env.STM import Simplex.Messaging.Transport -import System.Directory (createDirectoryIfMissing, removeDirectoryRecursive) +import System.Directory (createDirectoryIfMissing, removePathForcibly) import qualified System.Terminal as C import System.Terminal.Internal (VirtualTerminal (..), VirtualTerminalSettings (..), withVirtualTerminal) import System.Timeout (timeout) @@ -144,7 +144,7 @@ withTmpFiles :: IO () -> IO () withTmpFiles = bracket_ (createDirectoryIfMissing False "tests/tmp") - (removeDirectoryRecursive "tests/tmp") + (removePathForcibly "tests/tmp") testChatN :: [Profile] -> ([TestCC] -> IO ()) -> IO () testChatN ps test = withTmpFiles $ do diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 9e73060f9e..0ca78fa798 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -53,10 +53,10 @@ testChatApiNoUser = withTmpFiles $ do testChatApi :: IO () testChatApi = withTmpFiles $ do - let f = chatStoreFile $ testDBPrefix <> "1" + let f = chatStoreFile testDBPrefix st <- createStore f 1 True Right _ <- runExceptT $ createUser st aliceProfile True - cc <- chatInit $ testDBPrefix <> "1" + cc <- chatInit testDBPrefix chatSendCmd cc "/u" `shouldReturn` activeUser chatSendCmd cc "/u alice Alice" `shouldReturn` activeUserExists chatSendCmd cc "/_start" `shouldReturn` chatStarted From cf04a9fed36e3bce15fa7297d0140d54e315281d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 5 May 2022 10:37:53 +0100 Subject: [PATCH 53/95] add AChatItem to snd file events (#601) --- .../java/chat/simplex/app/model/ChatModel.kt | 6 ++ .../java/chat/simplex/app/model/SimpleXAPI.kt | 15 +++++ apps/ios/Shared/Model/Shared/APITypes.swift | 17 ++++++ apps/ios/Shared/Model/Shared/ChatTypes.swift | 8 +++ src/Simplex/Chat.hs | 60 ++++++++++--------- src/Simplex/Chat/Controller.hs | 10 ++-- src/Simplex/Chat/View.hs | 10 ++-- tests/MobileTests.hs | 4 +- 8 files changed, 91 insertions(+), 39 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index b4ed65d1f5..a726161269 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -1063,3 +1063,9 @@ enum class FormatColor(val color: String) { white -> MaterialTheme.colors.onBackground } } + +@Serializable +class SndFileTransfer() {} + +@Serializable +class FileTransferMeta() {} diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index c7381861f4..4ec5f17cdf 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -689,6 +689,11 @@ sealed class CR { @Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val chatItem: AChatItem): CR() @Serializable @SerialName("rcvFileStart") class RcvFileStart(val chatItem: AChatItem): CR() @Serializable @SerialName("rcvFileComplete") class RcvFileComplete(val chatItem: AChatItem): CR() + @Serializable @SerialName("sndFileStart") class SndFileStart(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() + @Serializable @SerialName("sndFileComplete") class SndFileComplete(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() + @Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() + @Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() + @Serializable @SerialName("sndGroupFileCancelled") class SndGroupFileCancelled(val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List): CR() @Serializable @SerialName("newContactConnection") class NewContactConnection(val connection: PendingContactConnection): CR() @Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val connection: PendingContactConnection): CR() @Serializable @SerialName("cmdOk") class CmdOk: CR() @@ -736,6 +741,11 @@ sealed class CR { is RcvFileAccepted -> "rcvFileAccepted" is RcvFileStart -> "rcvFileStart" is RcvFileComplete -> "rcvFileComplete" + is SndFileCancelled -> "sndFileCancelled" + is SndFileComplete -> "sndFileComplete" + is SndFileRcvCancelled -> "sndFileRcvCancelled" + is SndFileStart -> "sndFileStart" + is SndGroupFileCancelled -> "sndGroupFileCancelled" is NewContactConnection -> "newContactConnection" is ContactConnectionDeleted -> "contactConnectionDeleted" is CmdOk -> "cmdOk" @@ -784,6 +794,11 @@ sealed class CR { is RcvFileAccepted -> json.encodeToString(chatItem) is RcvFileStart -> json.encodeToString(chatItem) is RcvFileComplete -> json.encodeToString(chatItem) + is SndFileCancelled -> json.encodeToString(chatItem) + is SndFileComplete -> json.encodeToString(chatItem) + is SndFileRcvCancelled -> json.encodeToString(chatItem) + is SndFileStart -> json.encodeToString(chatItem) + is SndGroupFileCancelled -> json.encodeToString(chatItem) is NewContactConnection -> json.encodeToString(connection) is ContactConnectionDeleted -> json.encodeToString(connection) is CmdOk -> noDetails() diff --git a/apps/ios/Shared/Model/Shared/APITypes.swift b/apps/ios/Shared/Model/Shared/APITypes.swift index 65178c7378..3eb9f60e7f 100644 --- a/apps/ios/Shared/Model/Shared/APITypes.swift +++ b/apps/ios/Shared/Model/Shared/APITypes.swift @@ -167,9 +167,16 @@ enum ChatResponse: Decodable, Error { case chatItemStatusUpdated(chatItem: AChatItem) case chatItemUpdated(chatItem: AChatItem) case chatItemDeleted(deletedChatItem: AChatItem, toChatItem: AChatItem) + // receiving file events case rcvFileAccepted(chatItem: AChatItem) case rcvFileStart(chatItem: AChatItem) case rcvFileComplete(chatItem: AChatItem) + // sending file events + case sndFileStart(chatItem: AChatItem, sndFileTransfer: SndFileTransfer) + case sndFileComplete(chatItem: AChatItem, sndFileTransfer: SndFileTransfer) + case sndFileCancelled(chatItem: AChatItem, sndFileTransfer: SndFileTransfer) + case sndFileRcvCancelled(chatItem: AChatItem, sndFileTransfer: SndFileTransfer) + case sndGroupFileCancelled(chatItem: AChatItem, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer]) case ntfTokenStatus(status: NtfTknStatus) case newContactConnection(connection: PendingContactConnection) case contactConnectionDeleted(connection: PendingContactConnection) @@ -219,6 +226,11 @@ enum ChatResponse: Decodable, Error { case .rcvFileAccepted: return "rcvFileAccepted" case .rcvFileStart: return "rcvFileStart" case .rcvFileComplete: return "rcvFileComplete" + case .sndFileStart: return "sndFileStart" + case .sndFileComplete: return "sndFileComplete" + case .sndFileCancelled: return "sndFileCancelled" + case .sndFileRcvCancelled: return "sndFileRcvCancelled" + case .sndGroupFileCancelled: return "sndGroupFileCancelled" case .ntfTokenStatus: return "ntfTokenStatus" case .newContactConnection: return "newContactConnection" case .contactConnectionDeleted: return "contactConnectionDeleted" @@ -271,6 +283,11 @@ enum ChatResponse: Decodable, Error { case let .rcvFileAccepted(chatItem): return String(describing: chatItem) case let .rcvFileStart(chatItem): return String(describing: chatItem) case let .rcvFileComplete(chatItem): return String(describing: chatItem) + case let .sndFileStart(chatItem, _): return String(describing: chatItem) + case let .sndFileComplete(chatItem, _): return String(describing: chatItem) + case let .sndFileCancelled(chatItem, _): return String(describing: chatItem) + case let .sndFileRcvCancelled(chatItem, _): return String(describing: chatItem) + case let .sndGroupFileCancelled(chatItem, _, _): return String(describing: chatItem) case let .ntfTokenStatus(status): return String(describing: status) case let .newContactConnection(connection): return String(describing: connection) case let .contactConnectionDeleted(connection): return String(describing: connection) diff --git a/apps/ios/Shared/Model/Shared/ChatTypes.swift b/apps/ios/Shared/Model/Shared/ChatTypes.swift index e02586d4d9..cb73fe8bf9 100644 --- a/apps/ios/Shared/Model/Shared/ChatTypes.swift +++ b/apps/ios/Shared/Model/Shared/ChatTypes.swift @@ -799,3 +799,11 @@ enum NtfTknStatus: String, Decodable { case active = "ACTIVE" case expired = "EXPIRED" } + +struct SndFileTransfer: Decodable { + +} + +struct FileTransferMeta: Decodable { + +} diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index c9b5e843bf..8874a4efca 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -318,12 +318,12 @@ processChatCommand = \case (ct@Contact {localDisplayName = c}, CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}, file}) <- withStore $ \st -> (,) <$> getContact st userId chatId <*> getDirectChatItem st userId chatId itemId case (mode, msgDir, itemSharedMsgId) of (CIDMInternal, _, _) -> do - deleteFile userId file + deleteFile user file toCi <- withStore $ \st -> deleteDirectChatItemInternal st userId ct itemId pure $ CRChatItemDeleted (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) toCi (CIDMBroadcast, SMDSnd, Just itemSharedMId) -> do SndMessage {msgId} <- sendDirectContactMessage ct (XMsgDel itemSharedMId) - deleteFile userId file + deleteFile user file toCi <- withStore $ \st -> deleteDirectChatItemSndBroadcast st userId ct itemId msgId setActive $ ActiveC c pure $ CRChatItemDeleted (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) toCi @@ -334,12 +334,12 @@ processChatCommand = \case CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}, file} <- withStore $ \st -> getGroupChatItem st user chatId itemId case (mode, msgDir, itemSharedMsgId) of (CIDMInternal, _, _) -> do - deleteFile userId file + deleteFile user file toCi <- withStore $ \st -> deleteGroupChatItemInternal st user gInfo itemId pure $ CRChatItemDeleted (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) toCi (CIDMBroadcast, SMDSnd, Just itemSharedMId) -> do SndMessage {msgId} <- sendGroupMessage gInfo ms (XMsgDel itemSharedMId) - deleteFile userId file + deleteFile user file toCi <- withStore $ \st -> deleteGroupChatItemSndBroadcast st user gInfo itemId msgId setActive $ ActiveG gName pure $ CRChatItemDeleted (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) toCi @@ -347,10 +347,10 @@ processChatCommand = \case CTContactRequest -> pure $ chatCmdError "not supported" CTContactConnection -> pure $ chatCmdError "not supported" where - deleteFile :: MsgDirectionI d => UserId -> Maybe (CIFile d) -> m () - deleteFile userId file = + deleteFile :: MsgDirectionI d => User -> Maybe (CIFile d) -> m () + deleteFile user file = forM_ file $ \CIFile {fileId, filePath, fileStatus} -> do - cancelFiles userId [(fileId, AFS msgDirection fileStatus)] + cancelFiles user [(fileId, AFS msgDirection fileStatus)] withFilesFolder $ \filesFolder -> deleteFiles filesFolder [filePath] APIChatRead (ChatRef cType chatId) fromToIds -> withChatLock $ case cType of @@ -358,7 +358,7 @@ processChatCommand = \case CTGroup -> withStore (\st -> updateGroupChatItemsRead st chatId fromToIds) $> CRCmdOk CTContactRequest -> pure $ chatCmdError "not supported" CTContactConnection -> pure $ chatCmdError "not supported" - APIDeleteChat (ChatRef cType chatId) -> withUser $ \User {userId} -> case cType of + APIDeleteChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of CTDirect -> do ct@Contact {localDisplayName} <- withStore $ \st -> getContact st userId chatId withStore (\st -> getContactGroupNames st userId ct) >>= \case @@ -366,7 +366,7 @@ processChatCommand = \case files <- withStore $ \st -> getContactFiles st userId ct conns <- withStore $ \st -> getContactConnections st userId ct withChatLock . procCmd $ do - cancelFiles userId (map (\(fId, fStatus, _) -> (fId, fStatus)) files) + cancelFiles user (map (\(fId, fStatus, _) -> (fId, fStatus)) files) withFilesFolder $ \filesFolder -> do deleteFiles filesFolder (map (\(_, _, fPath) -> fPath) files) withAgent $ \a -> forM_ conns $ \conn -> @@ -633,9 +633,9 @@ processChatCommand = \case ChatErrorAgent (SMP SMP.AUTH) -> pure $ CRRcvFileAcceptedSndCancelled ft ChatErrorAgent (CONN DUPLICATE) -> pure $ CRRcvFileAcceptedSndCancelled ft e -> throwError e - CancelFile fileId -> withUser $ \User {userId} -> do + CancelFile fileId -> withUser $ \user@User {userId} -> do ft <- withStore (\st -> getFileTransfer st userId fileId) - withChatLock . procCmd $ cancelFile userId fileId ft + withChatLock . procCmd $ cancelFile user fileId ft FileStatus fileId -> CRFileTransferStatus <$> withUser (\User {userId} -> withStore $ \st -> getFileTransferProgress st userId fileId) ShowProfile -> withUser $ \User {profile} -> pure $ CRUserProfile profile @@ -722,8 +722,8 @@ processChatCommand = \case let fsFilePath = filesFolder <> "/" <> filePath removeFile fsFilePath `E.catch` \(_ :: E.SomeException) -> removePathForcibly fsFilePath `E.catch` \(_ :: E.SomeException) -> pure () - cancelFiles :: UserId -> [(Int64, ACIFileStatus)] -> m () - cancelFiles userId files = + cancelFiles :: User -> [(Int64, ACIFileStatus)] -> m () + cancelFiles user@User {userId} files = forM_ files $ \(fileId, status) -> do case status of AFS _ CIFSSndStored -> cancelById fileId @@ -734,14 +734,15 @@ processChatCommand = \case where cancelById fileId = do ft <- withStore (\st -> getFileTransfer st userId fileId) - void $ cancelFile userId fileId ft - cancelFile :: UserId -> Int64 -> FileTransfer -> m ChatResponse - cancelFile userId fileId ft = + void $ cancelFile user fileId ft + cancelFile :: User -> Int64 -> FileTransfer -> m ChatResponse + cancelFile user@User {userId} fileId ft = case ft of FTSnd ftm fts -> do cancelFileTransfer CIFSSndCancelled forM_ fts $ \ft' -> cancelSndFileTransfer ft' - pure $ CRSndGroupFileCancelled ftm fts + ci <- withStore $ \st -> getChatItemByFileId st user fileId + pure $ CRSndGroupFileCancelled ci ftm fts FTRcv ftr -> do cancelFileTransfer CIFSRcvCancelled cancelRcvFileTransfer ftr @@ -957,7 +958,7 @@ subscribeUserConnections agentSubscribe user@User {userId} = do a <- asks smpAgent when (fileStatus == FSConnected) . unlessM (isFileActive fileId sndFiles) $ withAgentLock a . withLock l $ - sendFileChunk ft + sendFileChunk user ft subscribeRcvFile ft@RcvFileTransfer {fileStatus} = case fileStatus of RFSAccepted fInfo -> resume fInfo @@ -1226,16 +1227,20 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage | otherwise -> messageError "x.file.acpt: fileName is different from expected" _ -> messageError "CONF from file connection must have x.file.acpt" CON -> do - withStore $ \st -> updateSndFileStatus st ft FSConnected - toView $ CRSndFileStart ft - sendFileChunk ft + ci <- withStore $ \st -> do + updateSndFileStatus st ft FSConnected + getChatItemByFileId st user fileId + toView $ CRSndFileStart ci ft + sendFileChunk user ft SENT msgId -> do withStore $ \st -> updateSndFileChunkSent st ft msgId - unless (fileStatus == FSCancelled) $ sendFileChunk ft + unless (fileStatus == FSCancelled) $ sendFileChunk user ft MERR _ err -> do cancelSndFileTransfer ft case err of - SMP SMP.AUTH -> unless (fileStatus == FSCancelled) $ toView $ CRSndFileRcvCancelled ft + SMP SMP.AUTH -> unless (fileStatus == FSCancelled) $ do + ci <- withStore $ \st -> getChatItemByFileId st user fileId + toView $ CRSndFileRcvCancelled ci ft _ -> throwChatError $ CEFileSend fileId err MSG meta _ -> withAckMessage agentConnId meta $ pure () @@ -1756,16 +1761,17 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage parseChatMessage :: ByteString -> Either ChatError ChatMessage parseChatMessage = first (ChatError . CEInvalidChatMessage) . strDecode -sendFileChunk :: ChatMonad m => SndFileTransfer -> m () -sendFileChunk ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentConnId acId} = +sendFileChunk :: ChatMonad m => User -> SndFileTransfer -> m () +sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentConnId acId} = unless (fileStatus == FSComplete || fileStatus == FSCancelled) $ withStore (`createSndFileChunk` ft) >>= \case Just chunkNo -> sendFileChunkNo ft chunkNo Nothing -> do - withStore $ \st -> do + ci <- withStore $ \st -> do updateSndFileStatus st ft FSComplete deleteSndFileChunks st ft - toView $ CRSndFileComplete ft + getChatItemByFileId st user fileId + toView $ CRSndFileComplete ci ft closeFileHandle fileId sndFiles withAgent (`deleteConnection` acId) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index bf6e815ef8..d572783a39 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -219,11 +219,11 @@ data ChatResponse | CRRcvFileComplete {chatItem :: AChatItem} | CRRcvFileCancelled {rcvFileTransfer :: RcvFileTransfer} | CRRcvFileSndCancelled {rcvFileTransfer :: RcvFileTransfer} - | CRSndFileStart {sndFileTransfer :: SndFileTransfer} - | CRSndFileComplete {sndFileTransfer :: SndFileTransfer} - | CRSndFileCancelled {sndFileTransfer :: SndFileTransfer} - | CRSndFileRcvCancelled {sndFileTransfer :: SndFileTransfer} - | CRSndGroupFileCancelled {fileTransferMeta :: FileTransferMeta, sndFileTransfers :: [SndFileTransfer]} + | CRSndFileStart {chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} + | CRSndFileComplete {chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} + | CRSndFileCancelled {chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} + | CRSndFileRcvCancelled {chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} + | CRSndGroupFileCancelled {chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta, sndFileTransfers :: [SndFileTransfer]} | CRUserProfileUpdated {fromProfile :: Profile, toProfile :: Profile} | CRContactConnecting {contact :: Contact} | CRContactConnected {contact :: Contact} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 7a1a3dd257..c63dbd68b3 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -94,7 +94,7 @@ responseToView testView = \case CRGroupDeletedUser g -> [ttyGroup' g <> ": you deleted the group"] CRRcvFileAccepted ci -> savingFile' ci CRRcvFileAcceptedSndCancelled ft -> viewRcvFileSndCancelled ft - CRSndGroupFileCancelled ftm fts -> viewSndGroupFileCancelled ftm fts + CRSndGroupFileCancelled _ ftm fts -> viewSndGroupFileCancelled ftm fts CRRcvFileCancelled ft -> receivingFile_ "cancelled" ft CRUserProfileUpdated p p' -> viewUserProfileUpdated p p' CRContactUpdated c c' -> viewContactUpdated c c' @@ -103,10 +103,10 @@ responseToView testView = \case CRRcvFileStart ci -> receivingFile_' "started" ci CRRcvFileComplete ci -> receivingFile_' "completed" ci CRRcvFileSndCancelled ft -> viewRcvFileSndCancelled ft - CRSndFileStart ft -> sendingFile_ "started" ft - CRSndFileComplete ft -> sendingFile_ "completed" ft - CRSndFileCancelled ft -> sendingFile_ "cancelled" ft - CRSndFileRcvCancelled ft@SndFileTransfer {recipientDisplayName = c} -> + CRSndFileStart _ ft -> sendingFile_ "started" ft + CRSndFileComplete _ ft -> sendingFile_ "completed" ft + CRSndFileCancelled _ ft -> sendingFile_ "cancelled" ft + CRSndFileRcvCancelled _ ft@SndFileTransfer {recipientDisplayName = c} -> [ttyContact c <> " cancelled receiving " <> sndFile ft] CRContactConnecting _ -> [] CRContactConnected ct -> [ttyFullContact ct <> ": contact is connected"] diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 0ca78fa798..9e73060f9e 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -53,10 +53,10 @@ testChatApiNoUser = withTmpFiles $ do testChatApi :: IO () testChatApi = withTmpFiles $ do - let f = chatStoreFile testDBPrefix + let f = chatStoreFile $ testDBPrefix <> "1" st <- createStore f 1 True Right _ <- runExceptT $ createUser st aliceProfile True - cc <- chatInit testDBPrefix + cc <- chatInit $ testDBPrefix <> "1" chatSendCmd cc "/u" `shouldReturn` activeUser chatSendCmd cc "/u alice Alice" `shouldReturn` activeUserExists chatSendCmd cc "/_start" `shouldReturn` chatStarted From dbf6b1f673915f383d25a6c547baa935e28774c8 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 5 May 2022 11:52:32 +0100 Subject: [PATCH 54/95] core: use filename in quote when there is no message text (#602) --- src/Simplex/Chat.hs | 16 +++++++++------ src/Simplex/Chat/View.hs | 6 +++--- tests/ChatTests.hs | 42 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 53 insertions(+), 11 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 8874a4efca..c75713ed4d 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -225,11 +225,11 @@ processChatCommand = \case prepareMsg fileInvitation_ = case quotedItemId_ of Nothing -> pure (MCSimple (ExtMsgContent mc fileInvitation_), Nothing) Just quotedItemId -> do - CChatItem _ ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, content = ciContent, formattedText} <- + CChatItem _ ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, content = ciContent, formattedText, file} <- withStore $ \st -> getDirectChatItem st userId chatId quotedItemId (origQmc, qd, sent) <- quoteData ciContent let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Nothing} - qmc = quoteContent origQmc mc + qmc = quoteContent origQmc file mc quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fileInvitation_), Just quotedItem) where @@ -261,11 +261,11 @@ processChatCommand = \case prepareMsg fileInvitation_ membership = case quotedItemId_ of Nothing -> pure (MCSimple (ExtMsgContent mc fileInvitation_), Nothing) Just quotedItemId -> do - CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemSharedMsgId}, content = ciContent, formattedText} <- + CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemSharedMsgId}, content = ciContent, formattedText, file} <- withStore $ \st -> getGroupChatItem st user chatId quotedItemId (origQmc, qd, sent, GroupMember {memberId}) <- quoteData ciContent chatDir membership let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Just memberId} - qmc = quoteContent origQmc mc + qmc = quoteContent origQmc file mc quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fileInvitation_), Just quotedItem) where @@ -276,9 +276,13 @@ processChatCommand = \case CTContactRequest -> pure $ chatCmdError "not supported" CTContactConnection -> pure $ chatCmdError "not supported" where - quoteContent qmc = \case + quoteContent :: forall d. MsgContent -> Maybe (CIFile d) -> MsgContent -> MsgContent + quoteContent qmc ciFile_ = \case MCText _ -> qmc - _ -> MCText $ msgContentText qmc + _ -> + let t = msgContentText qmc + fileName' = T.pack . (fileName :: CIFile d -> String) + in MCText $ if T.null t then maybe t fileName' ciFile_ else t unzipMaybe :: Maybe (a, b) -> (Maybe a, Maybe b) unzipMaybe t = (fst <$> t, snd <$> t) -- TODO discontinue diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index c63dbd68b3..5b79a664a1 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -223,9 +223,9 @@ viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} = case cha withFile view dir l = maybe l (\f -> l <> view dir f meta) file sndMsg = msg viewSentMessage rcvMsg = msg viewReceivedMessage - msg view dir quote mc = case (msgContentText mc, file) of - ("", Just _) -> [] - -- (_, Just _) -> prependFirst " " $ ttyMsgContent mc + msg view dir quote mc = case (msgContentText mc, file, quote) of + ("", Just _, []) -> [] + ("", Just CIFile {fileName}, _) -> view dir quote (MCText $ T.pack fileName) meta _ -> view dir quote mc meta viewItemUpdate :: MsgDirectionI d => ChatInfo c -> ChatItem c d -> [StyledString] diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 068b8551ec..54509eef8e 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -1278,12 +1278,50 @@ testSendImageWithTextAndQuote = (bob <## "completed receiving file 1 (test.jpg) from alice") (alice <## "completed sending file 1 (test.jpg) to bob") src <- B.readFile "./tests/fixtures/test.jpg" - dest <- B.readFile "./tests/tmp/test.jpg" - dest `shouldBe` src + B.readFile "./tests/tmp/test.jpg" `shouldReturn` src alice #$> ("/_get chat @2 count=100", chat'', [((0, "hi alice"), Nothing, Nothing), ((1, "hey bob"), Just (0, "hi alice"), Just "./tests/fixtures/test.jpg")]) alice @@@ [("@bob", "hey bob")] bob #$> ("/_get chat @2 count=100", chat'', [((1, "hi alice"), Nothing, Nothing), ((0, "hey bob"), Just (1, "hi alice"), Just "./tests/tmp/test.jpg")]) bob @@@ [("@alice", "hey bob")] + -- quoting (file + text) with file uses quoted text + bob ##> "/_send @2 file ./tests/fixtures/test.txt quoted 2 json {\"text\":\"\",\"type\":\"file\"}" + bob <# "@alice > hey bob" + bob <## " test.txt" + bob <# "/f @alice ./tests/fixtures/test.txt" + bob <## "use /fc 2 to cancel sending" + alice <# "bob> > hey bob" + alice <## " test.txt" + alice <# "bob> sends file test.txt (11 bytes / 11 bytes)" + alice <## "use /fr 2 [/ | ] to receive it" + alice ##> "/fr 2 ./tests/tmp" + alice <## "saving file 2 from bob to ./tests/tmp/test.txt" + concurrently_ + (alice <## "started receiving file 2 (test.txt) from bob") + (bob <## "started sending file 2 (test.txt) to alice") + concurrently_ + (alice <## "completed receiving file 2 (test.txt) from bob") + (bob <## "completed sending file 2 (test.txt) to alice") + txtSrc <- B.readFile "./tests/fixtures/test.txt" + B.readFile "./tests/tmp/test.txt" `shouldReturn` txtSrc + -- quoting (file without text) with file uses file name + alice ##> "/_send @2 file ./tests/fixtures/test.jpg quoted 3 json {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice <# "@bob > test.txt" + alice <## " test.jpg" + alice <# "/f @bob ./tests/fixtures/test.jpg" + alice <## "use /fc 3 to cancel sending" + bob <# "alice> > test.txt" + bob <## " test.jpg" + bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 3 [/ | ] to receive it" + bob ##> "/fr 3 ./tests/tmp" + bob <## "saving file 3 from alice to ./tests/tmp/test_1.jpg" + concurrently_ + (bob <## "started receiving file 3 (test.jpg) from alice") + (alice <## "started sending file 3 (test.jpg) to bob") + concurrently_ + (bob <## "completed receiving file 3 (test.jpg) from alice") + (alice <## "completed sending file 3 (test.jpg) to bob") + B.readFile "./tests/tmp/test_1.jpg" `shouldReturn` src testGroupSendImage :: IO () testGroupSendImage = From 7928cdbfb8ccc54891c82d655bed5d99ebc89c0c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 5 May 2022 13:50:19 +0100 Subject: [PATCH 55/95] core: update sent file status for direct chats when sending complete (#603) * core: update sent file status for direct chats when sending complete * update snd file status when started and completed --- src/Simplex/Chat.hs | 19 ++++++++++++------- src/Simplex/Chat/Messages.hs | 11 +++++++++++ src/Simplex/Chat/Store.hs | 23 +++++++++++++++++++---- 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index c75713ed4d..6093f0e4ce 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -727,15 +727,20 @@ processChatCommand = \case removeFile fsFilePath `E.catch` \(_ :: E.SomeException) -> removePathForcibly fsFilePath `E.catch` \(_ :: E.SomeException) -> pure () cancelFiles :: User -> [(Int64, ACIFileStatus)] -> m () - cancelFiles user@User {userId} files = - forM_ files $ \(fileId, status) -> do - case status of + cancelFiles user@User {userId} files = mapM_ maybeCancelFile files + where + maybeCancelFile :: (Int64, ACIFileStatus) -> m () + maybeCancelFile (fileId, status) = case status of AFS _ CIFSSndStored -> cancelById fileId + AFS _ CIFSSndTransfer -> cancelById fileId + AFS _ CIFSSndCancelled -> pure () + AFS _ CIFSSndComplete -> pure () AFS _ CIFSRcvInvitation -> cancelById fileId AFS _ CIFSRcvAccepted -> cancelById fileId AFS _ CIFSRcvTransfer -> cancelById fileId - _ -> pure () - where + AFS _ CIFSRcvCancelled -> pure () + AFS _ CIFSRcvComplete -> pure () + cancelById :: Int64 -> m () cancelById fileId = do ft <- withStore (\st -> getFileTransfer st userId fileId) void $ cancelFile user fileId ft @@ -1233,7 +1238,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage CON -> do ci <- withStore $ \st -> do updateSndFileStatus st ft FSConnected - getChatItemByFileId st user fileId + updateDirectCIFileStatus st user fileId CIFSSndTransfer toView $ CRSndFileStart ci ft sendFileChunk user ft SENT msgId -> do @@ -1774,7 +1779,7 @@ sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentCo ci <- withStore $ \st -> do updateSndFileStatus st ft FSComplete deleteSndFileChunks st ft - getChatItemByFileId st user fileId + updateDirectCIFileStatus st user fileId CIFSSndComplete toView $ CRSndFileComplete ci ft closeFileHandle fileId sndFiles withAgent (`deleteConnection` acId) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 6a1097d065..525d283067 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -210,6 +210,11 @@ aChatItems (AChat ct Chat {chatInfo, chatItems}) = map aChatItem chatItems where aChatItem (CChatItem md ci) = AChatItem ct md chatInfo ci +updateFileStatus :: forall c d. ChatItem c d -> CIFileStatus d -> ChatItem c d +updateFileStatus ci@ChatItem {file} status = case file of + Just f -> ci {file = Just (f :: CIFile d) {fileStatus = status}} + Nothing -> ci + instance MsgDirectionI d => ToJSON (JSONAnyChatItem c d) where toJSON = J.genericToJSON J.defaultOptions toEncoding = J.genericToEncoding J.defaultOptions @@ -295,7 +300,9 @@ instance MsgDirectionI d => ToJSON (CIFile d) where data CIFileStatus (d :: MsgDirection) where CIFSSndStored :: CIFileStatus 'MDSnd + CIFSSndTransfer :: CIFileStatus 'MDSnd CIFSSndCancelled :: CIFileStatus 'MDSnd + CIFSSndComplete :: CIFileStatus 'MDSnd CIFSRcvInvitation :: CIFileStatus 'MDRcv CIFSRcvAccepted :: CIFileStatus 'MDRcv CIFSRcvTransfer :: CIFileStatus 'MDRcv @@ -319,7 +326,9 @@ deriving instance Show ACIFileStatus instance MsgDirectionI d => StrEncoding (CIFileStatus d) where strEncode = \case CIFSSndStored -> "snd_stored" + CIFSSndTransfer -> "snd_transfer" CIFSSndCancelled -> "snd_cancelled" + CIFSSndComplete -> "snd_complete" CIFSRcvInvitation -> "rcv_invitation" CIFSRcvAccepted -> "rcv_accepted" CIFSRcvTransfer -> "rcv_transfer" @@ -332,7 +341,9 @@ instance StrEncoding ACIFileStatus where strP = A.takeTill (== ' ') >>= \case "snd_stored" -> pure $ AFS SMDSnd CIFSSndStored + "snd_transfer" -> pure $ AFS SMDSnd CIFSSndTransfer "snd_cancelled" -> pure $ AFS SMDSnd CIFSSndCancelled + "snd_complete" -> pure $ AFS SMDSnd CIFSSndComplete "rcv_invitation" -> pure $ AFS SMDRcv CIFSRcvInvitation "rcv_accepted" -> pure $ AFS SMDRcv CIFSRcvAccepted "rcv_transfer" -> pure $ AFS SMDRcv CIFSRcvTransfer diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 00db6f444e..39795d5b6d 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -142,6 +142,7 @@ module Simplex.Chat.Store getGroupChatItemIdByText, getChatItemByFileId, updateDirectChatItemStatus, + updateDirectCIFileStatus, updateDirectChatItem, deleteDirectChatItemInternal, deleteDirectChatItemRcvBroadcast, @@ -182,6 +183,7 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (UTCTime (..), getCurrentTime) import Data.Time.LocalTime (TimeZone, getCurrentTimeZone) +import Data.Type.Equality import Database.SQLite.Simple (NamedParam (..), Only (..), Query (..), SQLError, (:.) (..)) import qualified Database.SQLite.Simple as DB import Database.SQLite.Simple.QQ (sql) @@ -1907,11 +1909,14 @@ updateFileCancelled st userId fileId = currentTs <- getCurrentTime DB.execute db "UPDATE files SET cancelled = 1, updated_at = ? WHERE user_id = ? AND file_id = ?" (currentTs, userId, fileId) -updateCIFileStatus :: MsgDirectionI d => MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> CIFileStatus d -> m () +updateCIFileStatus :: (MsgDirectionI d, MonadUnliftIO m) => SQLiteStore -> UserId -> Int64 -> CIFileStatus d -> m () updateCIFileStatus st userId fileId ciFileStatus = - liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - DB.execute db "UPDATE files SET ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ?" (ciFileStatus, currentTs, userId, fileId) + liftIO . withTransaction st $ \db -> updateCIFileStatus_ db userId fileId ciFileStatus + +updateCIFileStatus_ :: MsgDirectionI d => DB.Connection -> UserId -> Int64 -> CIFileStatus d -> IO () +updateCIFileStatus_ db userId fileId ciFileStatus = do + currentTs <- getCurrentTime + DB.execute db "UPDATE files SET ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ?" (ciFileStatus, currentTs, userId, fileId) getSharedMsgIdByFileId :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m SharedMsgId getSharedMsgIdByFileId st userId fileId = @@ -3554,6 +3559,16 @@ getChatItemIdByFileId_ db userId fileId = |] (userId, fileId) +updateDirectCIFileStatus :: forall d m. (MsgDirectionI d, StoreMonad m) => SQLiteStore -> User -> Int64 -> CIFileStatus d -> m AChatItem +updateDirectCIFileStatus st user@User {userId} fileId fileStatus = + liftIOEither . withTransaction st $ \db -> runExceptT $ do + aci@(AChatItem cType d cInfo ci) <- ExceptT $ getChatItemByFileId_ db user fileId + case (cType, testEquality d $ msgDirection @d) of + (SCTDirect, Just Refl) -> do + liftIO $ updateCIFileStatus_ db userId fileId fileStatus + pure $ AChatItem SCTDirect d cInfo $ updateFileStatus ci fileStatus + _ -> pure aci + toChatItemRef :: (ChatItemId, Maybe Int64, Maybe Int64) -> Either StoreError (ChatItemId, ChatRef) toChatItemRef = \case (itemId, Just contactId, Nothing) -> Right (itemId, ChatRef CTDirect contactId) From e80f617840c733b2440bd7c39e26ce3eef2b599d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 5 May 2022 14:04:03 +0100 Subject: [PATCH 56/95] core, ios: send_v2 api to send messages as JSON (to support filenames with spaces) (#604) --- apps/ios/Shared/Model/Shared/APITypes.swift | 9 ++++++ apps/ios/Shared/Model/Shared/ChatTypes.swift | 34 ++++++++++++++++---- src/Simplex/Chat.hs | 16 +++++---- src/Simplex/Chat/Controller.hs | 11 +++++-- 4 files changed, 54 insertions(+), 16 deletions(-) diff --git a/apps/ios/Shared/Model/Shared/APITypes.swift b/apps/ios/Shared/Model/Shared/APITypes.swift index 3eb9f60e7f..bec9aa4dd5 100644 --- a/apps/ios/Shared/Model/Shared/APITypes.swift +++ b/apps/ios/Shared/Model/Shared/APITypes.swift @@ -50,6 +50,7 @@ enum ChatCommand { case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)" case .apiGetChats: return "/_get chats pcc=on" case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100" + // TODO replace with /_send_v2 case let .apiSendMessage(type, id, file, quotedItemId, mc): switch (file, quotedItemId) { case (nil, nil): return "/_send \(ref(type, id)) \(mc.cmdString)" @@ -57,6 +58,8 @@ enum ChatCommand { case let (nil, .some(quotedItemId)): return "/_send \(ref(type, id)) quoted \(quotedItemId) \(mc.cmdString)" case let (.some(file), .some(quotedItemId)): return "/_send \(ref(type, id)) file \(file) quoted \(quotedItemId) \(mc.cmdString)" } +// case let .apiSendMessage(type, id, file, quotedItemId, mc): +// return "/_send_v2 \(ref(type, id)) \(try! jsonEncoder.encode(ComposedMessage(filePath: file, quotedItemId: quotedItemId, msgContent: mc)))" case let .apiUpdateChatItem(type, id, itemId, mc): return "/_update item \(ref(type, id)) \(itemId) \(mc.cmdString)" case let .apiDeleteChatItem(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)" case let .apiRegisterToken(token): return "/_ntf register apns \(token)" @@ -301,6 +304,12 @@ enum ChatResponse: Decodable, Error { private var noDetails: String { get { "\(responseType): no details" } } } +struct ComposedMessage: Encodable { + var filePath: String? + var quotedItemId: Int64? + var msgContent: MsgContent +} + private func decodeCJSON(_ cjson: UnsafePointer) -> T? { let s = String.init(cString: cjson) let d = s.data(using: .utf8)! diff --git a/apps/ios/Shared/Model/Shared/ChatTypes.swift b/apps/ios/Shared/Model/Shared/ChatTypes.swift index cb73fe8bf9..adcfe61e45 100644 --- a/apps/ios/Shared/Model/Shared/ChatTypes.swift +++ b/apps/ios/Shared/Model/Shared/ChatTypes.swift @@ -689,12 +689,7 @@ enum MsgContent { get { switch self { case let .text(text): return "text \(text)" - case let .link(text: text, preview: preview): - return "json {\"type\":\"link\",\"text\":\(encodeJSON(text)),\"preview\":\(encodeJSON(preview))}" - case let .image(text: text, image: image): - return "json {\"type\":\"image\",\"text\":\(encodeJSON(text)),\"image\":\(encodeJSON(image))}" - case let .file(text): return "json {\"type\":\"file\",\"text\":\(encodeJSON(text))}" - default: return "" + default: return "json \(encodeJSON(self))" } } } @@ -704,7 +699,6 @@ enum MsgContent { case text case preview case image - case file } } @@ -739,6 +733,32 @@ extension MsgContent: Decodable { } } +extension MsgContent: Encodable { + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .text(text): + try container.encode("text", forKey: .type) + try container.encode(text, forKey: .text) + case let .link(text, preview): + try container.encode("link", forKey: .type) + try container.encode(text, forKey: .text) + try container.encode(preview, forKey: .preview) + case let .image(text, image): + try container.encode("image", forKey: .type) + try container.encode(text, forKey: .text) + try container.encode(image, forKey: .image) + case let .file(text): + try container.encode("file", forKey: .type) + try container.encode(text, forKey: .text) + // TODO use original JSON and type + case let .unknown(_, text): + try container.encode("text", forKey: .type) + try container.encode(text, forKey: .text) + } + } +} + struct FormattedText: Decodable { var text: String var format: Format? diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 6093f0e4ce..4c9e07e33e 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -200,7 +200,7 @@ processChatCommand = \case CTContactRequest -> pure $ chatCmdError "not implemented" CTContactConnection -> pure $ chatCmdError "not supported" APIGetChatItems _pagination -> pure $ chatCmdError "not implemented" - APISendMessage (ChatRef cType chatId) file_ quotedItemId_ mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of + APISendMessage (ChatRef cType chatId) (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user@User {userId} -> withChatLock $ case cType of CTDirect -> do ct@Contact {localDisplayName = c} <- withStore $ \st -> getContact st userId chatId (fileInvitation_, ciFile_) <- unzipMaybe <$> setupSndFileTransfer ct @@ -287,7 +287,7 @@ processChatCommand = \case unzipMaybe t = (fst <$> t, snd <$> t) -- TODO discontinue APISendMessageQuote chatId quotedItemId mc -> - processChatCommand $ APISendMessage chatId Nothing (Just quotedItemId) mc + processChatCommand . APISendMessage chatId $ ComposedMessage Nothing (Just quotedItemId) mc APIUpdateChatItem (ChatRef cType chatId) itemId mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of CTDirect -> do (ct@Contact {contactId, localDisplayName = c}, ci) <- withStore $ \st -> (,) <$> getContact st userId chatId <*> getDirectChatItem st userId chatId itemId @@ -515,7 +515,7 @@ processChatCommand = \case SendMessage chatName msg -> withUser $ \user -> do chatRef <- getChatRef user chatName let mc = MCText $ safeDecodeUtf8 msg - processChatCommand $ APISendMessage chatRef Nothing Nothing mc + processChatCommand . APISendMessage chatRef $ ComposedMessage Nothing Nothing mc SendMessageBroadcast msg -> withUser $ \user -> do contacts <- withStore (`getUserContacts` user) withChatLock . procCmd $ do @@ -533,7 +533,7 @@ processChatCommand = \case contactId <- withStore $ \st -> getContactIdByName st userId cName quotedItemId <- withStore $ \st -> getDirectChatItemIdByText st userId contactId msgDir (safeDecodeUtf8 quotedMsg) let mc = MCText $ safeDecodeUtf8 msg - processChatCommand $ APISendMessage (ChatRef CTDirect contactId) Nothing (Just quotedItemId) mc + processChatCommand . APISendMessage (ChatRef CTDirect contactId) $ ComposedMessage Nothing (Just quotedItemId) mc DeleteMessage chatName deletedMsg -> withUser $ \user -> do chatRef <- getChatRef user chatName deletedItemId <- getSentChatItemIdByText user chatRef deletedMsg @@ -618,7 +618,7 @@ processChatCommand = \case groupId <- withStore $ \st -> getGroupIdByName st user gName quotedItemId <- withStore $ \st -> getGroupChatItemIdByText st user groupId cName (safeDecodeUtf8 quotedMsg) let mc = MCText $ safeDecodeUtf8 msg - processChatCommand $ APISendMessage (ChatRef CTGroup groupId) Nothing (Just quotedItemId) mc + processChatCommand . APISendMessage (ChatRef CTGroup groupId) $ ComposedMessage Nothing (Just quotedItemId) mc LastMessages (Just chatName) count -> withUser $ \user -> do chatRef <- getChatRef user chatName CRLastMessages . aChatItems . chat <$> (processChatCommand . APIGetChat chatRef $ CPLast count) @@ -626,7 +626,7 @@ processChatCommand = \case CRLastMessages <$> getAllChatItems st user (CPLast count) SendFile chatName f -> withUser $ \user -> do chatRef <- getChatRef user chatName - processChatCommand $ APISendMessage chatRef (Just f) Nothing (MCFile "") + processChatCommand . APISendMessage chatRef $ ComposedMessage (Just f) Nothing (MCFile "") ReceiveFile fileId filePath_ -> withUser $ \user@User {userId} -> withChatLock . procCmd $ do ft <- withStore $ \st -> getRcvFileTransfer st userId fileId @@ -2097,7 +2097,8 @@ chatCommandP = <|> "/_get chats" *> (APIGetChats <$> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False)) <|> "/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP) <|> "/_get items count=" *> (APIGetChatItems <$> A.decimal) - <|> "/_send " *> (APISendMessage <$> chatRefP <*> optional filePathTagged <*> optional quotedItemIdTagged <* A.space <*> msgContentP) + <|> "/_send " *> (APISendMessage <$> chatRefP <*> composedMsgP) + <|> "/_send_v2 " *> (APISendMessage <$> chatRefP <*> jsonP) <|> "/_send_quote " *> (APISendMessageQuote <$> chatRefP <* A.space <*> A.decimal <* A.space <*> msgContentP) <|> "/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> msgContentP) <|> "/_delete item " *> (APIDeleteChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> ciDeleteMode) @@ -2203,6 +2204,7 @@ chatCommandP = fullNameP name = do n <- (A.space *> A.takeByteString) <|> pure "" pure $ if B.null n then name else safeDecodeUtf8 n + composedMsgP = ComposedMessage <$> optional filePathTagged <*> optional quotedItemIdTagged <* A.space <*> msgContentP filePath = T.unpack . safeDecodeUtf8 <$> A.takeByteString filePathTagged = " file " *> (T.unpack . safeDecodeUtf8 <$> A.takeTill (== ' ')) quotedItemIdTagged = " quoted " *> A.decimal diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index d572783a39..440dc64e5e 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -14,7 +14,7 @@ import Control.Monad.Except import Control.Monad.IO.Unlift import Control.Monad.Reader import Crypto.Random (ChaChaDRG) -import Data.Aeson (ToJSON) +import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) @@ -103,7 +103,7 @@ data ChatCommand | APIGetChats {pendingConnections :: Bool} | APIGetChat ChatRef ChatPagination | APIGetChatItems Int - | APISendMessage ChatRef (Maybe FilePath) (Maybe ChatItemId) MsgContent + | APISendMessage ChatRef ComposedMessage | APISendMessageQuote ChatRef ChatItemId MsgContent -- TODO discontinue | APIUpdateChatItem ChatRef ChatItemId MsgContent | APIDeleteChatItem ChatRef ChatItemId CIDeleteMode @@ -298,6 +298,13 @@ instance ToJSON PendingSubStatus where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +data ComposedMessage = ComposedMessage + { filePath :: Maybe FilePath, + quotedItemId :: Maybe ChatItemId, + msgContent :: MsgContent + } + deriving (Show, Generic, FromJSON) + data ChatError = ChatError {errorType :: ChatErrorType} | ChatErrorAgent {agentError :: AgentErrorType} From c8aa8db973939bc7fd2027b617da5c401ae11f91 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 5 May 2022 15:44:48 +0100 Subject: [PATCH 57/95] mobile: switch to send_v2 in ios, add to android (#605) --- .../java/chat/simplex/app/model/ChatModel.kt | 2 ++ .../java/chat/simplex/app/model/SimpleXAPI.kt | 5 +++++ apps/ios/Shared/Model/Shared/APITypes.swift | 11 ++--------- apps/ios/Shared/Model/Shared/ChatTypes.swift | 2 ++ apps/ios/Shared/Views/Call/CallView.swift | 18 +++++++----------- 5 files changed, 18 insertions(+), 20 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index a726161269..cf2931a0dd 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -887,6 +887,8 @@ class CIFile( @Serializable enum class CIFileStatus { @SerialName("snd_stored") SndStored, + @SerialName("snd_transfer") SndTransfer, + @SerialName("snd_complete") SndComplete, @SerialName("snd_cancelled") SndCancelled, @SerialName("rcv_invitation") RcvInvitation, @SerialName("rcv_accepted") RcvAccepted, diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 4ec5f17cdf..6f5148106c 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -567,6 +567,8 @@ sealed class CC { file != null && quotedItemId != null -> "/_send ${chatRef(type, id)} file $file quoted $quotedItemId ${mc.cmdString}" else -> throw Exception() } + // TODO use below + // is ApiSendMessage -> "/_send_v2 ${chatRef(type, id)} ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}" is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}" is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}" is GetUserSMPServers -> "/smp_servers" @@ -621,6 +623,9 @@ sealed class CC { } } +@Serializable +class ComposedMessage(val filePath: String?, val quotedItemId: Long?, val msgContent: MsgContent) + val json = Json { prettyPrint = true ignoreUnknownKeys = true diff --git a/apps/ios/Shared/Model/Shared/APITypes.swift b/apps/ios/Shared/Model/Shared/APITypes.swift index bec9aa4dd5..2c019a834b 100644 --- a/apps/ios/Shared/Model/Shared/APITypes.swift +++ b/apps/ios/Shared/Model/Shared/APITypes.swift @@ -50,16 +50,9 @@ enum ChatCommand { case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)" case .apiGetChats: return "/_get chats pcc=on" case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100" - // TODO replace with /_send_v2 case let .apiSendMessage(type, id, file, quotedItemId, mc): - switch (file, quotedItemId) { - case (nil, nil): return "/_send \(ref(type, id)) \(mc.cmdString)" - case let (.some(file), nil): return "/_send \(ref(type, id)) file \(file) \(mc.cmdString)" - case let (nil, .some(quotedItemId)): return "/_send \(ref(type, id)) quoted \(quotedItemId) \(mc.cmdString)" - case let (.some(file), .some(quotedItemId)): return "/_send \(ref(type, id)) file \(file) quoted \(quotedItemId) \(mc.cmdString)" - } -// case let .apiSendMessage(type, id, file, quotedItemId, mc): -// return "/_send_v2 \(ref(type, id)) \(try! jsonEncoder.encode(ComposedMessage(filePath: file, quotedItemId: quotedItemId, msgContent: mc)))" + let msg = encodeJSON(ComposedMessage(filePath: file, quotedItemId: quotedItemId, msgContent: mc)) + return "/_send_v2 \(ref(type, id)) \(msg)" case let .apiUpdateChatItem(type, id, itemId, mc): return "/_update item \(ref(type, id)) \(itemId) \(mc.cmdString)" case let .apiDeleteChatItem(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)" case let .apiRegisterToken(token): return "/_ntf register apns \(token)" diff --git a/apps/ios/Shared/Model/Shared/ChatTypes.swift b/apps/ios/Shared/Model/Shared/ChatTypes.swift index adcfe61e45..6e34a62dd9 100644 --- a/apps/ios/Shared/Model/Shared/ChatTypes.swift +++ b/apps/ios/Shared/Model/Shared/ChatTypes.swift @@ -657,6 +657,8 @@ struct CIFile: Decodable { enum CIFileStatus: String, Decodable { case sndStored = "snd_stored" + case sndTransfer = "snd_transfer" + case sndComplete = "snd_complete" case sndCancelled = "snd_cancelled" case rcvInvitation = "rcv_invitation" case rcvAccepted = "rcv_accepted" diff --git a/apps/ios/Shared/Views/Call/CallView.swift b/apps/ios/Shared/Views/Call/CallView.swift index 569f481429..ff67b1e165 100644 --- a/apps/ios/Shared/Views/Call/CallView.swift +++ b/apps/ios/Shared/Views/Call/CallView.swift @@ -50,15 +50,11 @@ class WebRTCCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler let corrId_ = corrId corrId = corrId + 1 pendingCommands[corrId_] = cont - do { - let apiData = try jsonEncoder.encode(WVAPICall(corrId: corrId_, command: command)) - DispatchQueue.main.async { - logger.debug("WebRTCCoordinator.processCommand DispatchQueue.main.async") - let js = "processCommand(\(String(decoding: apiData, as: UTF8.self)))" - self.webView.evaluateJavaScript(js) - } - } catch { - logger.error("WebRTCCoordinator.processCommand: error encoding command \(error.localizedDescription)") + let apiCmd = encodeJSON(WVAPICall(corrId: corrId_, command: command)) + DispatchQueue.main.async { + logger.debug("WebRTCCoordinator.processCommand DispatchQueue.main.async") + let js = "processCommand(\(apiCmd))" + self.webView.evaluateJavaScript(js) } } } @@ -143,7 +139,7 @@ struct CallView: View { if let c = coordinator { Task { let resp = await c.processCommand(command: command) - print(String(decoding: try! jsonEncoder.encode(resp), as: UTF8.self)) + print(encodeJSON(resp)) } } } catch { @@ -159,7 +155,7 @@ struct CallView: View { if let c = coordinator { Task { let resp = await c.processCommand(command: .start(media: .video)) - print(String(decoding: try! jsonEncoder.encode(resp), as: UTF8.self)) + print(encodeJSON(resp)) } } } From 9d47c8a3d4c9c4f29e9e3dfbbf27234a14fb08cc Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Fri, 6 May 2022 12:04:53 +0400 Subject: [PATCH 58/95] core: use filename in quote when there is no message text (MCText) (#607) Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- src/Simplex/Chat.hs | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 4c9e07e33e..ee7c94c5b8 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -229,7 +229,7 @@ processChatCommand = \case withStore $ \st -> getDirectChatItem st userId chatId quotedItemId (origQmc, qd, sent) <- quoteData ciContent let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Nothing} - qmc = quoteContent origQmc file mc + qmc = quoteContent origQmc file quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fileInvitation_), Just quotedItem) where @@ -265,7 +265,7 @@ processChatCommand = \case withStore $ \st -> getGroupChatItem st user chatId quotedItemId (origQmc, qd, sent, GroupMember {memberId}) <- quoteData ciContent chatDir membership let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Just memberId} - qmc = quoteContent origQmc file mc + qmc = quoteContent origQmc file quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fileInvitation_), Just quotedItem) where @@ -276,13 +276,25 @@ processChatCommand = \case CTContactRequest -> pure $ chatCmdError "not supported" CTContactConnection -> pure $ chatCmdError "not supported" where - quoteContent :: forall d. MsgContent -> Maybe (CIFile d) -> MsgContent -> MsgContent - quoteContent qmc ciFile_ = \case - MCText _ -> qmc - _ -> - let t = msgContentText qmc - fileName' = T.pack . (fileName :: CIFile d -> String) - in MCText $ if T.null t then maybe t fileName' ciFile_ else t + quoteContent :: forall d. MsgContent -> Maybe (CIFile d) -> MsgContent + quoteContent qmc ciFile_ + | replaceContent = MCText qTextOrFile + | otherwise = case qmc of + MCImage _ image -> MCImage qTextOrFile image + MCFile _ -> MCFile qTextOrFile + _ -> qmc + where + -- if the message we're quoting with is one of the "large" MsgContents + -- we replace the quote's content with MCText + replaceContent = case mc of + MCText _ -> False + MCFile _ -> False + MCLink {} -> True + MCImage {} -> True + MCUnknown {} -> True + qText = msgContentText qmc + qFileName = maybe qText (T.pack . (fileName :: CIFile d -> String)) ciFile_ + qTextOrFile = if T.null qText then qFileName else qText unzipMaybe :: Maybe (a, b) -> (Maybe a, Maybe b) unzipMaybe t = (fst <$> t, snd <$> t) -- TODO discontinue From e575e97019fe875b0f5b1ea0c7ee331da93b6b27 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 6 May 2022 09:17:49 +0100 Subject: [PATCH 59/95] core: update _send api, remove _send_quote, use new api in tests (#608) --- src/Simplex/Chat.hs | 15 +++------------ src/Simplex/Chat/Controller.hs | 1 - tests/ChatTests.hs | 30 +++++++++++++++--------------- 3 files changed, 18 insertions(+), 28 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index ee7c94c5b8..9b2912657e 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -297,9 +297,6 @@ processChatCommand = \case qTextOrFile = if T.null qText then qFileName else qText unzipMaybe :: Maybe (a, b) -> (Maybe a, Maybe b) unzipMaybe t = (fst <$> t, snd <$> t) - -- TODO discontinue - APISendMessageQuote chatId quotedItemId mc -> - processChatCommand . APISendMessage chatId $ ComposedMessage Nothing (Just quotedItemId) mc APIUpdateChatItem (ChatRef cType chatId) itemId mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of CTDirect -> do (ct@Contact {contactId, localDisplayName = c}, ci) <- withStore $ \st -> (,) <$> getContact st userId chatId <*> getDirectChatItem st userId chatId itemId @@ -2109,9 +2106,7 @@ chatCommandP = <|> "/_get chats" *> (APIGetChats <$> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False)) <|> "/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP) <|> "/_get items count=" *> (APIGetChatItems <$> A.decimal) - <|> "/_send " *> (APISendMessage <$> chatRefP <*> composedMsgP) - <|> "/_send_v2 " *> (APISendMessage <$> chatRefP <*> jsonP) - <|> "/_send_quote " *> (APISendMessageQuote <$> chatRefP <* A.space <*> A.decimal <* A.space <*> msgContentP) + <|> "/_send " *> (APISendMessage <$> chatRefP <*> (" json " *> jsonP <|> " text " *> (ComposedMessage Nothing Nothing <$> mcTextP))) <|> "/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> msgContentP) <|> "/_delete item " *> (APIDeleteChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> ciDeleteMode) <|> "/_read chat " *> (APIChatRead <$> chatRefP <* A.space <*> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal))) @@ -2187,9 +2182,8 @@ chatCommandP = (CPLast <$ "count=" <*> A.decimal) <|> (CPAfter <$ "after=" <*> A.decimal <* A.space <* "count=" <*> A.decimal) <|> (CPBefore <$ "before=" <*> A.decimal <* A.space <* "count=" <*> A.decimal) - msgContentP = - "text " *> (MCText . safeDecodeUtf8 <$> A.takeByteString) - <|> "json " *> jsonP + mcTextP = MCText . safeDecodeUtf8 <$> A.takeByteString + msgContentP = "text " *> mcTextP <|> "json " *> jsonP ciDeleteMode = "broadcast" $> CIDMBroadcast <|> "internal" $> CIDMInternal tokenP = "apns " *> (DeviceToken PPApns <$> hexStringP) hexStringP = @@ -2216,10 +2210,7 @@ chatCommandP = fullNameP name = do n <- (A.space *> A.takeByteString) <|> pure "" pure $ if B.null n then name else safeDecodeUtf8 n - composedMsgP = ComposedMessage <$> optional filePathTagged <*> optional quotedItemIdTagged <* A.space <*> msgContentP filePath = T.unpack . safeDecodeUtf8 <$> A.takeByteString - filePathTagged = " file " *> (T.unpack . safeDecodeUtf8 <$> A.takeTill (== ' ')) - quotedItemIdTagged = " quoted " *> A.decimal memberRole = (" owner" $> GROwner) <|> (" admin" $> GRAdmin) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 440dc64e5e..0f0a48e5af 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -104,7 +104,6 @@ data ChatCommand | APIGetChat ChatRef ChatPagination | APIGetChatItems Int | APISendMessage ChatRef ComposedMessage - | APISendMessageQuote ChatRef ChatItemId MsgContent -- TODO discontinue | APIUpdateChatItem ChatRef ChatItemId MsgContent | APIDeleteChatItem ChatRef ChatItemId CIDeleteMode | APIChatRead ChatRef (ChatItemId, ChatItemId) diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 54509eef8e..77c5827a0e 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -156,7 +156,7 @@ testDirectMessageQuotedReply = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob - alice ##> "/_send @2 json {\"type\": \"text\", \"text\": \"hello! how are you?\"}" + alice ##> "/_send @2 text hello! how are you?" alice <# "@bob hello! how are you?" bob <# "alice> hello! how are you?" bob #> "@alice hi!" @@ -255,7 +255,7 @@ testDirectMessageDelete = alice #$> ("/_get chat @2 count=100", chat, []) alice #$> ("/_update item @2 1 text updating deleted message", id, "cannot update this item") - alice #$> ("/_send @2 quoted 1 text quoting deleted message", id, "cannot reply to this message") + alice #$> ("/_send @2 json {\"quotedItemId\": 1, \"msgContent\": {\"type\": \"text\", \"text\": \"quoting deleted message\"}}", id, "cannot reply to this message") bob #$> ("/_update item @2 2 text hey alice", id, "message updated") alice <# "bob> [edited] hey alice" @@ -867,7 +867,7 @@ testGroupMessageDelete = cath #$> ("/_get chat #1 count=100", chat, [(0, "hello!")]) alice #$> ("/_update item #1 1 text updating deleted message", id, "cannot update this item") - alice #$> ("/_send #1 quoted 1 text quoting deleted message", id, "cannot reply to this message") + alice #$> ("/_send #1 json {\"quotedItemId\": 1, \"msgContent\": {\"type\": \"text\", \"text\": \"quoting deleted message\"}}", id, "cannot reply to this message") threadDelay 1000000 -- msg id 2 @@ -1117,7 +1117,7 @@ testMessageWithFile = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob - alice ##> "/_send @2 file ./tests/fixtures/test.jpg text hi, sending a file" + alice ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi, sending a file\"}}" alice <# "@bob hi, sending a file" alice <# "/f @bob ./tests/fixtures/test.jpg" alice <## "use /fc 1 to cancel sending" @@ -1143,7 +1143,7 @@ testSendImage = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob - alice ##> "/_send @2 file ./tests/fixtures/test.jpg json {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}" alice <# "/f @bob ./tests/fixtures/test.jpg" alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" @@ -1174,7 +1174,7 @@ testFilesFoldersSendImage = connectUsers alice bob alice #$> ("/_files_folder ./tests/fixtures", id, "ok") bob #$> ("/_files_folder ./tests/tmp/app_files", id, "ok") - alice ##> "/_send @2 file test.jpg json {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice ##> "/_send @2 json {\"filePath\": \"test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}" alice <# "/f @bob test.jpg" alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" @@ -1205,7 +1205,7 @@ testFilesFoldersImageSndDelete = alice #$> ("/_files_folder ./tests/tmp/alice_app_files", id, "ok") copyFile "./tests/fixtures/test_1MB.pdf" "./tests/tmp/alice_app_files/test_1MB.pdf" bob #$> ("/_files_folder ./tests/tmp/bob_app_files", id, "ok") - alice ##> "/_send @2 file test_1MB.pdf json {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice ##> "/_send @2 json {\"filePath\": \"test_1MB.pdf\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}" alice <# "/f @bob test_1MB.pdf" alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test_1MB.pdf (1017.7 KiB / 1042157 bytes)" @@ -1234,7 +1234,7 @@ testFilesFoldersImageRcvDelete = connectUsers alice bob alice #$> ("/_files_folder ./tests/fixtures", id, "ok") bob #$> ("/_files_folder ./tests/tmp/app_files", id, "ok") - alice ##> "/_send @2 file test.jpg json {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice ##> "/_send @2 json {\"filePath\": \"test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}" alice <# "/f @bob test.jpg" alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" @@ -1260,7 +1260,7 @@ testSendImageWithTextAndQuote = connectUsers alice bob bob #> "@alice hi alice" alice <# "bob> hi alice" - alice ##> "/_send @2 file ./tests/fixtures/test.jpg quoted 1 json {\"text\":\"hey bob\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"quotedItemId\": 1, \"msgContent\": {\"text\":\"hey bob\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}" alice <# "@bob > hi alice" alice <## " hey bob" alice <# "/f @bob ./tests/fixtures/test.jpg" @@ -1284,7 +1284,7 @@ testSendImageWithTextAndQuote = bob #$> ("/_get chat @2 count=100", chat'', [((1, "hi alice"), Nothing, Nothing), ((0, "hey bob"), Just (1, "hi alice"), Just "./tests/tmp/test.jpg")]) bob @@@ [("@alice", "hey bob")] -- quoting (file + text) with file uses quoted text - bob ##> "/_send @2 file ./tests/fixtures/test.txt quoted 2 json {\"text\":\"\",\"type\":\"file\"}" + bob ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test.txt\", \"quotedItemId\": 2, \"msgContent\": {\"text\":\"\",\"type\":\"file\"}}" bob <# "@alice > hey bob" bob <## " test.txt" bob <# "/f @alice ./tests/fixtures/test.txt" @@ -1304,7 +1304,7 @@ testSendImageWithTextAndQuote = txtSrc <- B.readFile "./tests/fixtures/test.txt" B.readFile "./tests/tmp/test.txt" `shouldReturn` txtSrc -- quoting (file without text) with file uses file name - alice ##> "/_send @2 file ./tests/fixtures/test.jpg quoted 3 json {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"quotedItemId\": 3, \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}" alice <# "@bob > test.txt" alice <## " test.jpg" alice <# "/f @bob ./tests/fixtures/test.jpg" @@ -1328,7 +1328,7 @@ testGroupSendImage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath - alice ##> "/_send #1 file ./tests/fixtures/test.jpg json {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice ##> "/_send #1 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}" alice <# "/f #team ./tests/fixtures/test.jpg" alice <## "use /fc 1 to cancel sending" concurrentlyN_ @@ -1378,7 +1378,7 @@ testGroupSendImageWithTextAndQuote = (alice <# "#team bob> hi team") (cath <# "#team bob> hi team") threadDelay 1000000 - alice ##> "/_send #1 file ./tests/fixtures/test.jpg quoted 1 json {\"text\":\"hey bob\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice ##> "/_send #1 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"quotedItemId\": 1, \"msgContent\": {\"text\":\"hey bob\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}" alice <# "#team > bob hi team" alice <## " hey bob" alice <# "/f #team ./tests/fixtures/test.jpg" @@ -1734,7 +1734,7 @@ testAsyncFileTransfer = withTmpFiles $ do withNewTestChat "bob" bobProfile $ \bob -> connectUsers alice bob withTestChatContactConnected "alice" $ \alice -> do - alice ##> "/_send @2 file ./tests/fixtures/test.jpg text hi, sending a file" + alice ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\":\"text\", \"text\": \"hi, sending a file\"}}" alice <# "@bob hi, sending a file" alice <# "/f @bob ./tests/fixtures/test.jpg" alice <## "use /fc 1 to cancel sending" @@ -1765,7 +1765,7 @@ testAsyncGroupFileTransfer = withTmpFiles $ do withNewTestChat "cath" cathProfile $ \cath -> createGroup3 "team" alice bob cath withTestChatGroup3Connected "alice" $ \alice -> do - alice ##> "/_send #1 file ./tests/fixtures/test.jpg json {\"text\":\"\",\"type\":\"text\"}" + alice ##> "/_send #1 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"text\"}}" alice <# "/f #team ./tests/fixtures/test.jpg" alice <## "use /fc 1 to cancel sending" withTestChatGroup3Connected "bob" $ \bob -> do From 884231369f7c7ab3c184ccaffb994759e4ee0725 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Fri, 6 May 2022 21:10:32 +0400 Subject: [PATCH 60/95] mobile: files UI (#597) Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../java/chat/simplex/app/model/ChatModel.kt | 2 + .../java/chat/simplex/app/model/NtfManager.kt | 6 +- .../java/chat/simplex/app/model/SimpleXAPI.kt | 25 +-- .../java/chat/simplex/app/ui/theme/Color.kt | 2 + .../chat/simplex/app/views/chat/ChatView.kt | 16 ++ .../simplex/app/views/chat/ComposeFileView.kt | 60 +++++++ .../simplex/app/views/chat/ComposeView.kt | 84 +++++++--- .../simplex/app/views/chat/ContextItemView.kt | 24 ++- .../simplex/app/views/chat/SendMsgView.kt | 66 ++++++-- .../simplex/app/views/chat/item/CIFileView.kt | 79 ++++----- .../app/views/chat/item/CIImageView.kt | 14 ++ .../app/views/chat/item/ChatItemView.kt | 25 +-- .../app/views/chat/item/FramedItemView.kt | 153 ++++++++++++++---- .../simplex/app/views/helpers/GetImageView.kt | 73 +++++++-- .../chat/simplex/app/views/helpers/Share.kt | 45 +++--- .../chat/simplex/app/views/helpers/Util.kt | 109 ++++++++++++- .../app/src/main/res/values-ru/strings.xml | 17 +- .../app/src/main/res/values/strings.xml | 17 +- apps/ios/Shared/FileUtils.swift | 88 +++++++++- apps/ios/Shared/Model/Shared/APITypes.swift | 2 +- apps/ios/Shared/Model/Shared/ChatTypes.swift | 9 ++ .../Shared/Model/Shared/Notifications.swift | 22 +-- apps/ios/Shared/Model/SimpleXAPI.swift | 11 ++ .../Views/Chat/ChatItem/FramedItemView.swift | 11 ++ apps/ios/Shared/Views/Chat/ChatView.swift | 6 +- .../Chat/ComposeMessage/ComposeView.swift | 112 ++++++++++--- .../Chat/ComposeMessage/ContextItemView.swift | 13 +- .../ios/Shared/Views/Helpers/CIFileView.swift | 35 ++-- .../Shared/Views/Helpers/CIImageView.swift | 18 ++- .../Views/Helpers/ComposeFileView.swift | 40 +++++ apps/ios/SimpleX.xcodeproj/project.pbxproj | 4 + 31 files changed, 920 insertions(+), 268 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeFileView.kt create mode 100644 apps/ios/Shared/Views/Helpers/ComposeFileView.swift diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index cf2931a0dd..a3fd1ffc45 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -867,6 +867,8 @@ class CIFile( ) { val stored: Boolean = when (fileStatus) { CIFileStatus.SndStored -> true + CIFileStatus.SndTransfer -> true + CIFileStatus.SndComplete -> true CIFileStatus.SndCancelled -> true CIFileStatus.RcvComplete -> true else -> false diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt index 2d83c79d50..3e1fee40f9 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt @@ -79,7 +79,11 @@ class NtfManager(val context: Context) { private fun hideSecrets(cItem: ChatItem) : String { val md = cItem.formattedText return if (md == null) { - cItem.content.text + if (cItem.content.text != "") { + cItem.content.text + } else { + cItem.file?.fileName ?: "" + } } else { var res = "" for (ft in md) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 6f5148106c..2451a976fd 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -429,6 +429,21 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt chatItemSimpleUpdate(r.chatItem) is CR.RcvFileComplete -> chatItemSimpleUpdate(r.chatItem) + is CR.SndFileStart -> + chatItemSimpleUpdate(r.chatItem) + is CR.SndFileComplete -> { + chatItemSimpleUpdate(r.chatItem) + val cItem = r.chatItem.chatItem + val mc = cItem.content.msgContent + val fileName = cItem.file?.fileName + if ( + r.chatItem.chatInfo.chatType == ChatType.Direct + && mc is MsgContent.MCFile + && fileName != null + ) { + removeFile(appContext, fileName) + } + } else -> Log.d(TAG , "unsupported event: ${r.responseType}") } @@ -560,15 +575,7 @@ sealed class CC { is SetFilesFolder -> "/_files_folder $filesFolder" is ApiGetChats -> "/_get chats pcc=on" is ApiGetChat -> "/_get chat ${chatRef(type, id)} count=100" - is ApiSendMessage -> when { - file == null && quotedItemId == null -> "/_send ${chatRef(type, id)} ${mc.cmdString}" - file != null && quotedItemId == null -> "/_send ${chatRef(type, id)} file $file ${mc.cmdString}" - file == null && quotedItemId != null -> "/_send ${chatRef(type, id)} quoted $quotedItemId ${mc.cmdString}" - file != null && quotedItemId != null -> "/_send ${chatRef(type, id)} file $file quoted $quotedItemId ${mc.cmdString}" - else -> throw Exception() - } - // TODO use below - // is ApiSendMessage -> "/_send_v2 ${chatRef(type, id)} ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}" + is ApiSendMessage -> "/_send ${chatRef(type, id)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}" is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}" is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}" is GetUserSMPServers -> "/smp_servers" diff --git a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt index 2e8d94c47f..05e66ffb87 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt @@ -16,3 +16,5 @@ val HighOrLowlight = Color(134, 135, 139, 255) val ToolbarLight = Color(220, 220, 220, 20) val ToolbarDark = Color(80, 80, 80, 20) val WarningOrange = Color(255, 149, 0, 255) +val FileLight = Color(183, 190, 199, 255) +val FileDark = Color(101, 101, 106, 255) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index 5aa7a98237..9c091ebdfa 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -2,6 +2,7 @@ package chat.simplex.app.views.chat import android.content.res.Configuration import android.graphics.Bitmap +import android.net.Uri import android.util.Log import androidx.activity.compose.BackHandler import androidx.compose.foundation.* @@ -27,6 +28,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import chat.simplex.app.R +import chat.simplex.app.SimplexApp.Companion.context import chat.simplex.app.TAG import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* @@ -46,6 +48,7 @@ fun ChatView(chatModel: ChatModel) { val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val scope = rememberCoroutineScope() val chosenImage = remember { mutableStateOf(null) } + val chosenFile = remember { mutableStateOf(null) } if (chat == null || user == null) { chatModel.chatId.value = null @@ -77,9 +80,11 @@ fun ChatView(chatModel: ChatModel) { chat, composeState, chosenImage, + chosenFile, showAttachmentBottomSheet = { scope.launch { attachmentBottomSheetState.show() } }) }, chosenImage, + chosenFile, scope, attachmentBottomSheetState, chatModel.chatItems, @@ -124,6 +129,7 @@ fun ChatLayout( composeState: MutableState, composeView: (@Composable () -> Unit), chosenImage: MutableState, + chosenFile: MutableState, scope: CoroutineScope, attachmentBottomSheetState: ModalBottomSheetState, chatItems: List, @@ -137,6 +143,12 @@ fun ChatLayout( val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000) composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview)) } + fun onFileChange(uri: Uri) { + val fileName = getFileName(context, uri) + if (fileName != null) { + composeState.value = composeState.value.copy(preview = ComposePreview.FilePreview(fileName)) + } + } Surface( Modifier @@ -151,6 +163,8 @@ fun ChatLayout( GetImageBottomSheet( chosenImage, ::onImageChange, + chosenFile, + ::onFileChange, hideBottomSheet = { scope.launch { attachmentBottomSheetState.hide() } }) @@ -355,6 +369,7 @@ fun PreviewChatLayout() { ), composeState = remember { mutableStateOf(ComposeState()) }, composeView = {}, + chosenFile = remember { mutableStateOf(null) }, chosenImage = remember { mutableStateOf(null) }, scope = rememberCoroutineScope(), attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), @@ -400,6 +415,7 @@ fun PreviewGroupChatLayout() { composeState = remember { mutableStateOf(ComposeState()) }, composeView = {}, chosenImage = remember { mutableStateOf(null) }, + chosenFile = remember { mutableStateOf(null) }, scope = rememberCoroutineScope(), attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), chatItems = chatItems, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeFileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeFileView.kt new file mode 100644 index 0000000000..32eeba9e32 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeFileView.kt @@ -0,0 +1,60 @@ +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.InsertDriveFile +import androidx.compose.material.icons.outlined.Close +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import chat.simplex.app.R +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.chat.item.SentColorLight + +@Composable +fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boolean) { + Row( + Modifier + .height(60.dp) + .fillMaxWidth() + .padding(top = 8.dp) + .background(SentColorLight), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Filled.InsertDriveFile, + stringResource(R.string.icon_descr_file), + Modifier + .padding(start = 4.dp, end = 2.dp) + .size(36.dp), + tint = if (isSystemInDarkTheme()) FileDark else FileLight + ) + Text(fileName) + Spacer(Modifier.weight(1f)) + if (cancelEnabled) { + IconButton(onClick = cancelFile, modifier = Modifier.padding(0.dp)) { + Icon( + Icons.Outlined.Close, + contentDescription = stringResource(R.string.icon_descr_cancel_file_preview), + tint = MaterialTheme.colors.primary, + modifier = Modifier.padding(10.dp) + ) + } + } + } +} + +@Preview +@Composable +fun PreviewComposeFileView() { + SimpleXTheme { + ComposeFileView( + "test.txt", + cancelFile = {}, + cancelEnabled = true + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index 05ab4d284b..8402d4e7fa 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -1,14 +1,17 @@ package chat.simplex.app.views.chat +import ComposeFileView import ComposeImageView -import android.content.Context import android.graphics.Bitmap +import android.net.Uri import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.outlined.Reply import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -22,13 +25,12 @@ import chat.simplex.app.model.* import chat.simplex.app.views.chat.item.* import chat.simplex.app.views.helpers.* import kotlinx.coroutines.* -import java.io.File -import java.io.FileOutputStream sealed class ComposePreview { object NoPreview: ComposePreview() class CLinkPreview(val linkPreview: LinkPreview): ComposePreview() class ImagePreview(val image: String): ComposePreview() + class FilePreview(val fileName: String): ComposePreview() } sealed class ComposeContextItem { @@ -55,16 +57,20 @@ data class ComposeState( is ComposeContextItem.EditingItem -> true else -> false } - val sendEnabled: Boolean - get() = - when (preview) { + val sendEnabled: () -> Boolean + get() = { + val hasContent = when (preview) { is ComposePreview.ImagePreview -> true + is ComposePreview.FilePreview -> true else -> message.isNotEmpty() } + hasContent && !inProgress + } val linkPreviewAllowed: Boolean get() = when (preview) { is ComposePreview.ImagePreview -> false + is ComposePreview.FilePreview -> false else -> true } val linkPreview: LinkPreview? @@ -77,8 +83,13 @@ data class ComposeState( fun chatItemPreview(chatItem: ChatItem): ComposePreview { return when (val mc = chatItem.content.msgContent) { + is MsgContent.MCText -> ComposePreview.NoPreview is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview) is MsgContent.MCImage -> ComposePreview.ImagePreview(image = mc.image) + is MsgContent.MCFile -> { + val fileName = chatItem.file?.fileName ?: "" + ComposePreview.FilePreview(fileName) + } else -> ComposePreview.NoPreview } } @@ -89,6 +100,7 @@ fun ComposeView( chat: Chat, composeState: MutableState, chosenImage: MutableState, + chosenFile: MutableState, showAttachmentBottomSheet: () -> Unit ) { val context = LocalContext.current @@ -169,24 +181,19 @@ fun ComposeView( } } - fun saveImage(context: Context, image: Bitmap): String? { - return try { - val dataResized = resizeImageToDataSize(image, maxDataSize = MAX_IMAGE_SIZE) - val fileToSave = "image_${System.currentTimeMillis()}.jpg" - val file = File(getAppFilesDirectory(context) + "/" + fileToSave) - val output = FileOutputStream(file) - dataResized.writeTo(output) - output.flush() - output.close() - fileToSave - } catch (e: Exception) { - null - } + fun clearState() { + composeState.value = ComposeState() + chosenImage.value = null + chosenFile.value = null + linkUrl.value = null + prevLinkUrl.value = null + pendingLinkUrl.value = null + cancelledLinks.clear() } fun sendMessage() { withApi { - // show "in progress" + composeState.value = composeState.value.copy(inProgress = true) val cInfo = chat.chatInfo val cs = composeState.value when (val contextItem = cs.contextItem) { @@ -218,6 +225,15 @@ fun ComposeView( } } } + is ComposePreview.FilePreview -> { + val chosenFileVal = chosenFile.value + if (chosenFileVal != null) { + file = saveFileFromUri(context, chosenFileVal) + if (file != null) { + mc = MsgContent.MCFile(cs.message) + } + } + } } val quotedItemId: Long? = when (contextItem) { is ComposeContextItem.QuotedItem -> contextItem.chatItem.id @@ -236,8 +252,7 @@ fun ComposeView( } } } - // hide "in progress" - composeState.value = ComposeState() + clearState() } } @@ -263,8 +278,13 @@ fun ComposeView( } fun cancelImage() { - chosenImage.value = null composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) + chosenImage.value = null + } + + fun cancelFile() { + composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) + chosenFile.value = null } @Composable @@ -277,6 +297,11 @@ fun ComposeView( ::cancelImage, cancelEnabled = !composeState.value.editing ) + is ComposePreview.FilePreview -> ComposeFileView( + preview.fileName, + ::cancelFile, + cancelEnabled = !composeState.value.editing + ) } } @@ -284,14 +309,21 @@ fun ComposeView( fun contextItemView() { when (val contextItem = composeState.value.contextItem) { ComposeContextItem.NoContextItem -> {} - is ComposeContextItem.QuotedItem -> ContextItemView(contextItem.chatItem) { composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) } - is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem) { composeState.value = ComposeState() } + is ComposeContextItem.QuotedItem -> ContextItemView(contextItem.chatItem, Icons.Outlined.Reply) { + composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) + } + is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem, Icons.Filled.Edit) { + clearState() + } } } Column { contextItemView() - previewView() + when { + composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {} + else -> previewView() + } Row( modifier = Modifier.padding(start = 4.dp, end = 8.dp), verticalAlignment = Alignment.Bottom, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt index 23227ebbc4..496c8323b7 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt @@ -4,10 +4,12 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.outlined.Close import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle @@ -16,6 +18,7 @@ import androidx.compose.ui.unit.dp import chat.simplex.app.R import chat.simplex.app.model.CIDirection import chat.simplex.app.model.ChatItem +import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chat.item.* import kotlinx.datetime.Clock @@ -23,6 +26,7 @@ import kotlinx.datetime.Clock @Composable fun ContextItemView( contextItem: ChatItem, + contextIcon: ImageVector, cancelContextItem: () -> Unit ) { val sent = contextItem.chatDir.sent @@ -32,13 +36,22 @@ fun ContextItemView( .background(if (sent) SentColorLight else ReceivedColorLight), verticalAlignment = Alignment.CenterVertically ) { - Box( + Row( Modifier - .padding(start = 16.dp) .padding(vertical = 12.dp) .fillMaxWidth() - .weight(1F) + .weight(1F), + verticalAlignment = Alignment.CenterVertically ) { + Icon( + contextIcon, + modifier = Modifier + .padding(horizontal = 8.dp) + .height(20.dp) + .width(20.dp), + contentDescription = stringResource(R.string.icon_descr_context), + tint = HighOrLowlight, + ) ContextItemText(contextItem) } IconButton(onClick = cancelContextItem) { @@ -56,11 +69,11 @@ fun ContextItemView( private fun ContextItemText(cxtItem: ChatItem) { val member = cxtItem.memberDisplayName if (member == null) { - Text(cxtItem.content.text, maxLines = 3) + Text(cxtItem.text, maxLines = 3) } else { val annotatedText = buildAnnotatedString { withStyle(boldFont) { append(member) } - append(": ${cxtItem.content.text}") + append(": ${cxtItem.text}") } Text(annotatedText, maxLines = 3) } @@ -72,6 +85,7 @@ fun PreviewContextItemView() { SimpleXTheme { ContextItemView( contextItem = ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), "hello"), + contextIcon = Icons.Filled.Edit, cancelContextItem = {} ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index 03c21786e3..74fee47400 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -24,8 +24,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import chat.simplex.app.R import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.HighOrLowlight -import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.ui.theme.* @Composable fun SendMsgView( @@ -65,22 +64,34 @@ fun SendMsgView( innerTextField() } val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward - val color = if (cs.sendEnabled) MaterialTheme.colors.primary else Color.Gray - Icon( - icon, - stringResource(R.string.icon_descr_send_message), - tint = Color.White, - modifier = Modifier - .size(36.dp) - .padding(4.dp) - .clip(CircleShape) - .background(color) - .clickable { - if (cs.sendEnabled) { - sendMessage() + val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight + if (cs.inProgress + && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview) + ) { + CircularProgressIndicator( + Modifier + .size(36.dp) + .padding(4.dp), + color = HighOrLowlight, + strokeWidth = 4.dp + ) + } else { + Icon( + icon, + stringResource(R.string.icon_descr_send_message), + tint = Color.White, + modifier = Modifier + .size(36.dp) + .padding(4.dp) + .clip(CircleShape) + .background(color) + .clickable { + if (cs.sendEnabled()) { + sendMessage() + } } - } - ) + ) + } } } } @@ -127,3 +138,24 @@ fun PreviewSendMsgViewEditing() { ) } } + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "Dark Mode" +) +@Composable +fun PreviewSendMsgViewInProgress() { + val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) + val textStyle = remember { mutableStateOf(smallFont) } + val composeStateInProgress = ComposeState(inProgress = true) + SimpleXTheme { + SendMsgView( + composeState = remember { mutableStateOf(composeStateInProgress) }, + sendMessage = {}, + onMessageChange = { _ -> }, + textStyle = textStyle + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt index eaf02c10a5..35a0c22a75 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt @@ -1,15 +1,19 @@ +package chat.simplex.app.views.chat.item + import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.InsertDriveFile import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext @@ -20,11 +24,8 @@ import androidx.compose.ui.unit.sp import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.chat.item.FramedItemView import chat.simplex.app.views.helpers.* import kotlinx.datetime.Clock -import kotlin.math.log2 -import kotlin.math.pow @Composable fun CIFileView( @@ -33,15 +34,13 @@ fun CIFileView( receiveFile: (Long) -> Unit ) { val context = LocalContext.current - val saveFileLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument(), - onResult = { destination -> - saveFile(context, file, destination) - } - ) + val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = file) @Composable - fun fileIcon(innerIcon: ImageVector? = null, color: Color = HighOrLowlight) { + fun fileIcon( + innerIcon: ImageVector? = null, + color: Color = if (isSystemInDarkTheme()) FileDark else FileLight + ) { Box( contentAlignment = Alignment.Center ) { @@ -80,7 +79,7 @@ fun CIFileView( } else { AlertManager.shared.showAlertMsg( generalGetString(R.string.large_file), - String.format(generalGetString(R.string.contact_sent_large_file), MAX_FILE_SIZE) + String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(MAX_FILE_SIZE)) ) } } @@ -102,14 +101,29 @@ fun CIFileView( } } + @Composable + fun progressIndicator() { + CircularProgressIndicator( + Modifier.size(32.dp), + color = if (isSystemInDarkTheme()) FileDark else FileLight, + strokeWidth = 4.dp + ) + } + @Composable fun fileIndicator() { Box( - Modifier.size(44.dp), + Modifier + .size(42.dp) + .clip(RoundedCornerShape(4.dp)) + .clickable(onClick = { fileAction() }), contentAlignment = Alignment.Center ) { if (file != null) { when (file.fileStatus) { + CIFileStatus.SndStored -> fileIcon() + CIFileStatus.SndTransfer -> progressIndicator() + CIFileStatus.SndComplete -> fileIcon(innerIcon = Icons.Filled.Check) CIFileStatus.SndCancelled -> fileIcon(innerIcon = Icons.Outlined.Close) CIFileStatus.RcvInvitation -> if (fileSizeValid()) @@ -117,14 +131,9 @@ fun CIFileView( else fileIcon(innerIcon = Icons.Outlined.PriorityHigh, color = WarningOrange) CIFileStatus.RcvAccepted -> fileIcon(innerIcon = Icons.Outlined.MoreHoriz) - CIFileStatus.RcvTransfer -> - CircularProgressIndicator( - Modifier.size(36.dp), - color = HighOrLowlight, - strokeWidth = 4.dp - ) + CIFileStatus.RcvTransfer -> progressIndicator() + CIFileStatus.RcvComplete -> fileIcon(innerIcon = Icons.Outlined.ArrowDownward) CIFileStatus.RcvCancelled -> fileIcon(innerIcon = Icons.Outlined.Close) - else -> fileIcon() } } else { fileIcon() @@ -132,30 +141,10 @@ fun CIFileView( } } - fun formatBytes(bytes: Long): String { - if (bytes == 0.toLong()) { - return "0 bytes" - } - val bytesDouble = bytes.toDouble() - val k = 1000.toDouble() - val units = arrayOf("bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") - val i = kotlin.math.floor(log2(bytesDouble) / log2(k)) - val size = bytesDouble / k.pow(i) - val unit = units[i.toInt()] - - return if (i <= 1) { - String.format("%.0f %s", size, unit) - } else { - String.format("%.2f %s", size, unit) - } - } - Row( - Modifier - .padding(top = 4.dp, bottom = 6.dp, start = 10.dp, end = 12.dp) - .clickable(onClick = { fileAction() }), + Modifier.padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 12.dp), verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(4.dp) + horizontalArrangement = Arrangement.spacedBy(2.dp) ) { fileIndicator() val metaReserve = if (edited) @@ -189,7 +178,7 @@ class ChatItemProvider: PreviewParameterProvider { meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.SndSent(), itemDeleted = false, itemEdited = true, editable = false), content = CIContent.SndMsgContent(msgContent = MsgContent.MCFile("")), quotedItem = null, - file = CIFile.getSample(fileStatus = CIFileStatus.SndStored) + file = CIFile.getSample(fileStatus = CIFileStatus.SndComplete) ) private val fileChatItemWtFile = ChatItem( chatDir = CIDirection.DirectRcv(), @@ -205,7 +194,7 @@ class ChatItemProvider: PreviewParameterProvider { ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvAccepted), ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvTransfer), ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvCancelled), - ChatItem.getFileMsgContentSample(fileSize = 2000000, fileStatus = CIFileStatus.RcvInvitation), + ChatItem.getFileMsgContentSample(fileSize = 1_000_000_000, fileStatus = CIFileStatus.RcvInvitation), ChatItem.getFileMsgContentSample(text = "Hello there", fileStatus = CIFileStatus.RcvInvitation), ChatItem.getFileMsgContentSample(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus = CIFileStatus.RcvInvitation), fileChatItemWtFile diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt index 8df4976e8b..918c429ea9 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Icon import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.outlined.MoreHoriz import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -34,6 +35,19 @@ fun CIImageView( contentAlignment = Alignment.Center ) { when (file.fileStatus) { + CIFileStatus.SndTransfer -> + CircularProgressIndicator( + Modifier.size(16.dp), + color = Color.White, + strokeWidth = 2.dp + ) + CIFileStatus.SndComplete -> + Icon( + Icons.Filled.Check, + stringResource(R.string.icon_descr_image_snd_complete), + Modifier.fillMaxSize(), + tint = Color.White + ) CIFileStatus.RcvAccepted -> Icon( Icons.Outlined.MoreHoriz, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index 5e87e6b2f7..3e484d74eb 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -1,10 +1,9 @@ package chat.simplex.app.views.chat.item import android.content.Context -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Edit @@ -12,6 +11,7 @@ import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext @@ -42,19 +42,18 @@ fun ChatItemView( val sent = cItem.chatDir.sent val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart val showMenu = remember { mutableStateOf(false) } - val saveFileLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument(), - onResult = { destination -> - saveFile(context, cItem.file, destination) - } - ) + val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file) Box( modifier = Modifier .padding(bottom = 4.dp) .fillMaxWidth(), contentAlignment = alignment, ) { - Column(Modifier.combinedClickable(onLongClick = { showMenu.value = true }, onClick = {})) { + Column( + Modifier + .clip(RoundedCornerShape(18.dp)) + .combinedClickable(onLongClick = { showMenu.value = true }, onClick = {}) + ) { if (cItem.isMsgContent) { if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) { EmojiItemView(cItem) @@ -71,7 +70,11 @@ fun ChatItemView( Modifier.width(220.dp) ) { ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = { - composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) + if (composeState.value.editing) { + composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem)) + } else { + composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) + } showMenu.value = false }) ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = { @@ -82,7 +85,7 @@ fun ChatItemView( copyText(cxt, cItem.content.text) showMenu.value = false }) - if (cItem.content.msgContent is MsgContent.MCImage) { + if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile) { val filePath = getStoredFilePath(context, cItem.file) if (filePath != null) { ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt index 4a184cc37d..19eabed563 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt @@ -1,12 +1,12 @@ package chat.simplex.app.views.chat.item -import CIFileView import CIImageView -import androidx.compose.foundation.Image -import androidx.compose.foundation.background +import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.InsertDriveFile import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -18,12 +18,10 @@ import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.* -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.* import chat.simplex.app.R import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.HighOrLowlight -import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.ChatItemLinkView import chat.simplex.app.views.helpers.base64ToBitmap import kotlinx.datetime.Clock @@ -43,6 +41,63 @@ fun FramedItemView( receiveFile: (Long) -> Unit ) { val sent = ci.chatDir.sent + + @Composable + fun ciQuotedMsgView(qi: CIQuote, paddingEnd: Dp = 0.dp) { + Box( + Modifier + .padding(vertical = 6.dp, horizontal = 12.dp) + .padding(end = paddingEnd), + contentAlignment = Alignment.TopStart + ) { + MarkdownText( + qi.text, sender = qi.sender(user), senderBold = true, maxLines = 3, + style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface) + ) + } + } + + @Composable + fun ciQuoteView(qi: CIQuote) { + Row( + Modifier + .background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight) + .fillMaxWidth() + ) { + when (qi.content) { + is MsgContent.MCImage -> { + ciQuotedMsgView(qi) + // TODO fix quotes containing long text and image / file + // ciQuotedMsgView(qi, paddingEnd = 60.dp) + Spacer(Modifier.weight(1f)) + val imageBitmap = base64ToBitmap(qi.content.image).asImageBitmap() + Image( + imageBitmap, + contentDescription = stringResource(R.string.image_descr), + contentScale = ContentScale.Crop, + modifier = Modifier + .size(60.dp) + .clipToBounds() + ) + } + is MsgContent.MCFile -> { + ciQuotedMsgView(qi) + // ciQuotedMsgView(qi, paddingEnd = 26.dp) + Spacer(Modifier.weight(1f)) + Icon( + Icons.Filled.InsertDriveFile, + stringResource(R.string.icon_descr_file), + Modifier + .padding(top = 6.dp, end = 4.dp) + .size(22.dp), + tint = if (isSystemInDarkTheme()) FileDark else FileLight + ) + } + else -> ciQuotedMsgView(qi, 22.dp) + } + } + } + Surface( shape = RoundedCornerShape(18.dp), color = if (sent) SentColorLight else ReceivedColorLight @@ -52,33 +107,7 @@ fun FramedItemView( Column(Modifier.width(IntrinsicSize.Max)) { val qi = ci.quotedItem if (qi != null) { - Row( - Modifier - .background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight) - .fillMaxWidth() - ) { - Box( - Modifier.padding(vertical = 6.dp, horizontal = 12.dp), - contentAlignment = Alignment.TopStart - ) { - MarkdownText( - qi.text, sender = qi.sender(user), senderBold = true, maxLines = 3, - style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface) - ) - } - Spacer(Modifier.weight(1f)) - if (qi.content is MsgContent.MCImage) { - val imageBitmap = base64ToBitmap(qi.content.image).asImageBitmap() - Image( - imageBitmap, - contentDescription = stringResource(R.string.image_descr), - contentScale = ContentScale.Crop, - modifier = Modifier - .size(60.dp) - .clipToBounds() - ) - } - } + ciQuoteView(qi) } if (ci.file == null && ci.formattedText == null && isShortEmoji(ci.content.text)) { Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { @@ -232,3 +261,59 @@ fun PreviewTextItemViewEmoji(@PreviewParameter(EditedProvider::class) edited: Bo ) } } + +@Preview +@Composable +fun PreviewQuoteWithTextAndImage(@PreviewParameter(EditedProvider::class) edited: Boolean) { + val ciQuote = CIQuote( + chatDir = CIDirection.DirectRcv(), itemId = 1, sentAt = Clock.System.now(), + content = MsgContent.MCImage( + text = "Hello there", + image = "data:image/jpg;base64,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" + ) + ) + val showMenu = remember { mutableStateOf(false) } + SimpleXTheme { + FramedItemView( + User.sampleData, + ChatItem.getSampleData( + 1, CIDirection.DirectSnd(), + Clock.System.now(), + "Hello there", + CIStatus.SndSent(), + quotedItem = ciQuote, + itemEdited = edited + ), + showMenu = showMenu, + receiveFile = {} + ) + } +} + +@Preview +@Composable +fun PreviewQuoteWithLongTextAndImage(@PreviewParameter(EditedProvider::class) edited: Boolean) { + val ciQuote = CIQuote( + chatDir = CIDirection.DirectRcv(), itemId = 1, sentAt = Clock.System.now(), + content = MsgContent.MCImage( + text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", + image = "data:image/jpg;base64,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" + ) + ) + val showMenu = remember { mutableStateOf(false) } + SimpleXTheme { + FramedItemView( + User.sampleData, + ChatItem.getSampleData( + 1, CIDirection.DirectSnd(), + Clock.System.now(), + "Hello there", + CIStatus.SndSent(), + quotedItem = ciQuote, + itemEdited = edited + ), + showMenu = showMenu, + receiveFile = {} + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt index 301a56aac3..095c3ef2e4 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt @@ -18,8 +18,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.CallSuper import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Collections -import androidx.compose.material.icons.outlined.PhotoCamera +import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged @@ -37,7 +36,6 @@ import kotlin.math.min import kotlin.math.sqrt // Inspired by https://github.com/MakeItEasyDev/Jetpack-Compose-Capture-Image-Or-Choose-from-Gallery - fun cropToSquare(image: Bitmap): Bitmap { var xOffset = 0 var yOffset = 0 @@ -50,7 +48,7 @@ fun cropToSquare(image: Bitmap): Bitmap { return Bitmap.createBitmap(image, xOffset, yOffset, side, side) } -fun resizeImageToStrSize(image: Bitmap, maxDataSize: Int): String { +fun resizeImageToStrSize(image: Bitmap, maxDataSize: Long): String { var img = image var str = compressImageStr(img) while (str.length > maxDataSize) { @@ -68,7 +66,7 @@ private fun compressImageStr(bitmap: Bitmap): String { return "data:image/jpg;base64," + Base64.encodeToString(compressImageData(bitmap).toByteArray(), Base64.NO_WRAP) } -fun resizeImageToDataSize(image: Bitmap, maxDataSize: Int): ByteArrayOutputStream { +fun resizeImageToDataSize(image: Bitmap, maxDataSize: Long): ByteArrayOutputStream { var img = image var stream = compressImageData(img) while (stream.size() > maxDataSize) { @@ -88,7 +86,7 @@ private fun compressImageData(bitmap: Bitmap): ByteArrayOutputStream { return stream } -fun base64ToBitmap(base64ImageString: String) : Bitmap { +fun base64ToBitmap(base64ImageString: String): Bitmap { val imageString = base64ImageString .removePrefix("data:image/png;base64,") .removePrefix("data:image/jpg;base64,") @@ -96,7 +94,7 @@ fun base64ToBitmap(base64ImageString: String) : Bitmap { return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size) } -class CustomTakePicturePreview : ActivityResultContract() { +class CustomTakePicturePreview: ActivityResultContract() { private var uri: Uri? = null private var tmpFile: File? = null lateinit var externalContext: Context @@ -122,15 +120,24 @@ class CustomTakePicturePreview : ActivityResultContract() { tmpFile?.delete() bitmap } else { - Log.e( TAG, "Getting image from camera cancelled or failed.") + Log.e(TAG, "Getting image from camera cancelled or failed.") tmpFile?.delete() null } } } - +//class GetGalleryContent: ActivityResultContracts.GetContent() { +// override fun createIntent(context: Context, input: String): Intent { +// return super.createIntent(context, input).apply { +// Log.e(TAG, "########################################################### in GetGalleryContent") +// uri = DocumentsContract.buildDocumentUriUsingTree(uri, DocumentsContract.getTreeDocumentId(uri)) +// putExtra(DocumentsContract.EXTRA_INITIAL_URI, Environment.DIRECTORY_PICTURES) +// } +// } +//} @Composable -fun rememberGalleryLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher = +fun rememberGetContentLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher = +// rememberLauncherForActivityResult(contract = GetGalleryContent(), cb) rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent(), cb) @Composable @@ -145,12 +152,13 @@ fun rememberPermissionLauncher(cb: (Boolean) -> Unit): ManagedActivityResultLaun fun GetImageBottomSheet( imageBitmap: MutableState, onImageChange: (Bitmap) -> Unit, + fileUri: MutableState? = null, + onFileChange: ((Uri) -> Unit)? = null, hideBottomSheet: () -> Unit ) { val context = LocalContext.current - val isCameraSelected = remember { mutableStateOf (false) } - - val galleryLauncher = rememberGalleryLauncher { uri: Uri? -> + val isCameraSelected = remember { mutableStateOf(false) } + val galleryLauncher = rememberGetContentLauncher { uri: Uri? -> if (uri != null) { val source = ImageDecoder.createSource(context.contentResolver, uri) val bitmap = ImageDecoder.decodeBitmap(source) @@ -158,21 +166,41 @@ fun GetImageBottomSheet( onImageChange(bitmap) } } - val cameraLauncher = rememberCameraLauncher { bitmap: Bitmap? -> if (bitmap != null) { imageBitmap.value = bitmap onImageChange(bitmap) } } - val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean -> if (isGranted) { if (isCameraSelected.value) cameraLauncher.launch(null) else galleryLauncher.launch("image/*") hideBottomSheet() } else { - Toast.makeText(context, generalGetString(R.string.toast_camera_permission_denied), Toast.LENGTH_SHORT).show() + Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show() + } + } + val filesLauncher = rememberGetContentLauncher { uri: Uri? -> + if (uri != null && fileUri != null && onFileChange != null) { + val fileSize = getFileSize(context, uri) + if (fileSize != null && fileSize <= MAX_FILE_SIZE) { + fileUri.value = uri + onFileChange(uri) + } else { + AlertManager.shared.showAlertMsg( + generalGetString(R.string.large_file), + String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE)) + ) + } + } + } + val filesPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean -> + if (isGranted) { + filesLauncher.launch("*/*") + hideBottomSheet() + } else { + Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show() } } @@ -214,6 +242,19 @@ fun GetImageBottomSheet( } } } + if (fileUri != null && onFileChange != null) { + ActionButton(null, stringResource(R.string.choose_file), icon = Icons.Outlined.InsertDriveFile) { + when (PackageManager.PERMISSION_GRANTED) { + ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) -> { + filesLauncher.launch("*/*") + hideBottomSheet() + } + else -> { + filesPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) + } + } + } + } } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt index ad68516caa..e05633a1ac 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt @@ -3,6 +3,10 @@ package chat.simplex.app.views.helpers import android.content.* import android.net.Uri import android.widget.Toast +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable import androidx.core.content.ContextCompat import chat.simplex.app.R import chat.simplex.app.model.CIFile @@ -24,24 +28,29 @@ fun copyText(cxt: Context, text: String) { clipboard?.setPrimaryClip(ClipData.newPlainText("text", text)) } -fun saveFile(cxt: Context, ciFile: CIFile?, destination: Uri?) { - if (destination != null) { - val filePath = getStoredFilePath(cxt, ciFile) - if (filePath != null) { - val contentResolver = cxt.contentResolver - val file = File(filePath) - try { - val outputStream = contentResolver.openOutputStream(destination) - if (outputStream != null) { - outputStream.write(file.readBytes()) - outputStream.close() - Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show() +@Composable +fun rememberSaveFileLauncher(cxt: Context, ciFile: CIFile?): ManagedActivityResultLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument(), + onResult = { destination -> + if (destination != null) { + val filePath = getStoredFilePath(cxt, ciFile) + if (filePath != null) { + val contentResolver = cxt.contentResolver + val file = File(filePath) + try { + val outputStream = contentResolver.openOutputStream(destination) + if (outputStream != null) { + outputStream.write(file.readBytes()) + outputStream.close() + Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show() + } + } catch (e: IOException) { + Toast.makeText(cxt, generalGetString(R.string.error_saving_file), Toast.LENGTH_SHORT).show() + } + } else { + Toast.makeText(cxt, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show() } - } catch (e: IOException) { - Toast.makeText(cxt, generalGetString(R.string.error_saving_file), Toast.LENGTH_SHORT).show() } - } else { - Toast.makeText(cxt, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show() } - } -} + ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt index b4970f12e1..17c307f7d2 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt @@ -4,9 +4,13 @@ import android.content.Context import android.content.res.Resources import android.graphics.* import android.graphics.Typeface +import android.net.Uri +import android.os.FileUtils +import android.provider.OpenableColumns import android.text.Spanned import android.text.SpannedString import android.text.style.* +import android.util.Log import android.view.ViewTreeObserver import androidx.annotation.StringRes import androidx.compose.runtime.* @@ -24,6 +28,9 @@ import chat.simplex.app.SimplexApp import chat.simplex.app.model.CIFile import kotlinx.coroutines.* import java.io.File +import java.io.FileOutputStream +import kotlin.math.log2 +import kotlin.math.pow fun withApi(action: suspend CoroutineScope.() -> Unit): Job = GlobalScope.launch { withContext(Dispatchers.Main, action) } @@ -202,21 +209,24 @@ private fun spannableStringToAnnotatedString( } // maximum image file size to be auto-accepted -const val MAX_IMAGE_SIZE = 236700 - -const val MAX_FILE_SIZE = 1893600 +const val MAX_IMAGE_SIZE: Long = 236700 +const val MAX_FILE_SIZE: Long = 8000000 fun getFilesDirectory(context: Context): String { return context.filesDir.toString() } fun getAppFilesDirectory(context: Context): String { - return getFilesDirectory(context) + "/app_files" + return "${getFilesDirectory(context)}/app_files" +} + +fun getAppFilePath(context: Context, fileName: String): String { + return "${getAppFilesDirectory(context)}/$fileName" } fun getStoredFilePath(context: Context, file: CIFile?): String? { return if (file?.filePath != null && file.stored) { - val filePath = getAppFilesDirectory(context) + "/" + file.filePath + val filePath = getAppFilePath(context, file.filePath) if (File(filePath).exists()) filePath else null } else { null @@ -241,3 +251,92 @@ fun getStoredImage(context: Context, file: CIFile?): Bitmap? { null } } + +fun getFileName(context: Context, uri: Uri): String? { + return context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + cursor.moveToFirst() + cursor.getString(nameIndex) + } +} + +fun getFileSize(context: Context, uri: Uri): Long? { + return context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + cursor.moveToFirst() + cursor.getLong(sizeIndex) + } +} + +fun saveImage(context: Context, image: Bitmap): String? { + return try { + val dataResized = resizeImageToDataSize(image, maxDataSize = MAX_IMAGE_SIZE) + val fileToSave = uniqueCombine(context, "image_${System.currentTimeMillis()}.jpg") + val file = File(getAppFilePath(context, fileToSave)) + val output = FileOutputStream(file) + dataResized.writeTo(output) + output.flush() + output.close() + fileToSave + } catch (e: Exception) { + Log.e(chat.simplex.app.TAG, "Util.kt saveImage error: ${e.message}") + null + } +} + +fun saveFileFromUri(context: Context, uri: Uri): String? { + return try { + val inputStream = context.contentResolver.openInputStream(uri) + val fileToSave = getFileName(context, uri) + if (inputStream != null && fileToSave != null) { + val destFileName = uniqueCombine(context, fileToSave) + val destFile = File(getAppFilePath(context, destFileName)) + FileUtils.copy(inputStream, FileOutputStream(destFile)) + destFileName + } else { + Log.e(chat.simplex.app.TAG, "Util.kt saveFileFromUri null inputStream") + null + } + } catch (e: Exception) { + Log.e(chat.simplex.app.TAG, "Util.kt saveFileFromUri error: ${e.message}") + null + } +} + +fun uniqueCombine(context: Context, fileName: String): String { + fun tryCombine(fileName: String, n: Int): String { + val name = File(fileName).nameWithoutExtension + val ext = File(fileName).extension + val suffix = if (n == 0) "" else "_$n" + val f = "$name$suffix.$ext" + return if (File(getAppFilePath(context, f)).exists()) tryCombine(fileName, n + 1) else f + } + return tryCombine(fileName, 0) +} + +fun formatBytes(bytes: Long): String { + if (bytes == 0.toLong()) { + return "0 bytes" + } + val bytesDouble = bytes.toDouble() + val k = 1000.toDouble() + val units = arrayOf("bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") + val i = kotlin.math.floor(log2(bytesDouble) / log2(k)) + val size = bytesDouble / k.pow(i) + val unit = units[i.toInt()] + + return if (i <= 1) { + String.format("%.0f %s", size, unit) + } else { + String.format("%.2f %s", size, unit) + } +} + +fun removeFile(context: Context, fileName: String): Boolean { + val file = File(getAppFilePath(context, fileName)) + val fileDeleted = file.delete() + if (!fileDeleted) { + Log.e(chat.simplex.app.TAG, "Util.kt removeFile error") + } + return fileDeleted +} diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index b601587f99..6cc2985897 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -87,20 +87,24 @@ Ваши чаты соединяется… - + Прикрепить - - - Изображение + Значок контекста Удалить превью изображения + Удалить превью файла + + + Изображение Ожидание изображения + Изображение отправлено Ожидание изображения Изображение будет получено, когда ваш контакт будет в сети, пожалуйста, подождите или проверьте позже! Файл Большой файл! - Ваш контакт отправил файл, размер которого превышает поддерживаемый в настоящее время максимальный размер (%1$s байта). + Ваш контакт отправил файл, размер которого превышает поддерживаемый в настоящее время максимальный размер (%1$s). + В настоящее время максимальный поддерживаемый размер файла составляет %1$s. Ожидание файла Файл будет получен, когда ваш контакт будет в сети, пожалуйста, подождите или проверьте позже! Файл сохранен @@ -136,9 +140,10 @@ (вставить ссылку из буфера обмена) - Разрешение не получено! + Разрешение не получено! Использовать камеру Открыть галерею + Выбрать файл Спасибо, что установили SimpleX Chat! diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index c2a15bee79..50d46ea542 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -87,20 +87,24 @@ Your chats connecting… - + Attach - - - Image + Context icon Cancel image preview + Cancel file preview + + + Image Waiting for image + Image sent Waiting for image Image will be received when your contact is online, please wait or check later! File Large file! - Your contact sent a file that is larger than currently supported maximum size (%1$s bytes). + Your contact sent a file that is larger than currently supported maximum size (%1$s). + Currently maximum supported file size is %1$s. Waiting for file File will be received when your contact is online, please wait or check later! File saved @@ -137,9 +141,10 @@ (paste link from clipboard) - Permission Denied! + Permission Denied! Use Camera From Gallery + Choose file Thank you for installing SimpleX Chat! diff --git a/apps/ios/Shared/FileUtils.swift b/apps/ios/Shared/FileUtils.swift index b3346293a8..8978ad7ccf 100644 --- a/apps/ios/Shared/FileUtils.swift +++ b/apps/ios/Shared/FileUtils.swift @@ -10,23 +10,27 @@ import Foundation import SwiftUI // maximum image file size to be auto-accepted -let maxImageSize = 236700 +let maxImageSize: Int64 = 236700 -let maxFileSize = 1893600 +let maxFileSize: Int64 = 8000000 func getDocumentsDirectory() -> URL { - return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! } func getAppFilesDirectory() -> URL { - return getDocumentsDirectory().appendingPathComponent("app_files", isDirectory: true) + getDocumentsDirectory().appendingPathComponent("app_files", isDirectory: true) +} + +func getAppFilePath(_ fileName: String) -> URL { + getAppFilesDirectory().appendingPathComponent(fileName) } func getStoredFilePath(_ file: CIFile?) -> String? { if let file = file, file.stored, let savedFile = file.filePath { - return getAppFilesDirectory().appendingPathComponent(savedFile).path + return getAppFilePath(savedFile).path } return nil } @@ -38,6 +42,76 @@ func getStoredImage(_ file: CIFile?) -> UIImage? { return nil } +func saveFileFromURL(_ url: URL) -> String? { + let savedFile: String? + if url.startAccessingSecurityScopedResource() { + do { + let fileData = try Data(contentsOf: url) + let fileName = uniqueCombine(url.lastPathComponent) + savedFile = saveFile(fileData, fileName) + } catch { + logger.error("FileUtils.saveFileFromURL error: \(error.localizedDescription)") + savedFile = nil + } + } else { + logger.error("FileUtils.saveFileFromURL startAccessingSecurityScopedResource returned false") + savedFile = nil + } + url.stopAccessingSecurityScopedResource() + return savedFile +} + +func saveImage(_ uiImage: UIImage) -> String? { + if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: maxImageSize) { + let millisecondsSince1970 = Int64((Date().timeIntervalSince1970 * 1000.0).rounded()) + let fileName = uniqueCombine("image_\(millisecondsSince1970).jpg") + return saveFile(imageDataResized, fileName) + } + return nil +} + +private func saveFile(_ data: Data, _ fileName: String) -> String? { + let filePath = getAppFilePath(fileName) + do { + try data.write(to: filePath) + return fileName + } catch { + logger.error("FileUtils.saveFile error: \(error.localizedDescription)") + return nil + } +} + +private func uniqueCombine(_ fileName: String) -> String { + func tryCombine(_ fileName: String, _ n: Int) -> String { + let name = fileName.deletingPathExtension + let ext = fileName.pathExtension + let suffix = (n == 0) ? "" : "_\(n)" + let f = "\(name)\(suffix).\(ext)" + return (FileManager.default.fileExists(atPath: getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f + } + return tryCombine(fileName, 0) +} + +private extension String { + var ns: NSString { + return self as NSString + } + var pathExtension: String { + return ns.pathExtension + } + var deletingPathExtension: String { + return ns.deletingPathExtension + } +} + +func removeFile(_ fileName: String) { + do { + try FileManager.default.removeItem(atPath: getAppFilePath(fileName).path) + } catch { + logger.error("FileUtils.removeFile error: \(error.localizedDescription)") + } +} + // image utils func dropImagePrefix(_ s: String) -> String { @@ -61,7 +135,7 @@ func cropToSquare(_ image: UIImage) -> UIImage { return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size)) } -func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int) -> Data? { +func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64) -> Data? { var img = image var data = img.jpegData(compressionQuality: 0.85) var dataSize = data?.count ?? 0 @@ -76,7 +150,7 @@ func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int) -> Data? { return data } -func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int) -> String? { +func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String? { var img = image var str = compressImageStr(img) var dataSize = str?.count ?? 0 diff --git a/apps/ios/Shared/Model/Shared/APITypes.swift b/apps/ios/Shared/Model/Shared/APITypes.swift index 2c019a834b..6edae1e60d 100644 --- a/apps/ios/Shared/Model/Shared/APITypes.swift +++ b/apps/ios/Shared/Model/Shared/APITypes.swift @@ -52,7 +52,7 @@ enum ChatCommand { case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100" case let .apiSendMessage(type, id, file, quotedItemId, mc): let msg = encodeJSON(ComposedMessage(filePath: file, quotedItemId: quotedItemId, msgContent: mc)) - return "/_send_v2 \(ref(type, id)) \(msg)" + return "/_send \(ref(type, id)) json \(msg)" case let .apiUpdateChatItem(type, id, itemId, mc): return "/_update item \(ref(type, id)) \(itemId) \(mc.cmdString)" case let .apiDeleteChatItem(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)" case let .apiRegisterToken(token): return "/_ntf register apns \(token)" diff --git a/apps/ios/Shared/Model/Shared/ChatTypes.swift b/apps/ios/Shared/Model/Shared/ChatTypes.swift index 6e34a62dd9..4005fdde4d 100644 --- a/apps/ios/Shared/Model/Shared/ChatTypes.swift +++ b/apps/ios/Shared/Model/Shared/ChatTypes.swift @@ -647,6 +647,8 @@ struct CIFile: Decodable { get { switch self.fileStatus { case .sndStored: return true + case .sndTransfer: return true + case .sndComplete: return true case .sndCancelled: return true case .rcvComplete: return true default: return false @@ -696,6 +698,13 @@ enum MsgContent { } } + func isFile() -> Bool { + switch self { + case .file: return true + default: return false + } + } + enum CodingKeys: String, CodingKey { case type case text diff --git a/apps/ios/Shared/Model/Shared/Notifications.swift b/apps/ios/Shared/Model/Shared/Notifications.swift index 5da0fe08ad..7e98333f78 100644 --- a/apps/ios/Shared/Model/Shared/Notifications.swift +++ b/apps/ios/Shared/Model/Shared/Notifications.swift @@ -65,17 +65,21 @@ func createNotification(categoryIdentifier: String, title: String, subtitle: Str } func hideSecrets(_ cItem: ChatItem) -> String { - if let md = cItem.formattedText { - var res = "" - for ft in md { - if case .secret = ft.format { - res = res + "..." - } else { - res = res + ft.text + if cItem.content.text != "" { + if let md = cItem.formattedText { + var res = "" + for ft in md { + if case .secret = ft.format { + res = res + "..." + } else { + res = res + ft.text + } } + return res + } else { + return cItem.content.text } - return res } else { - return cItem.content.text + return cItem.file?.fileName ?? "" } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index d51f666b67..c298e611aa 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -519,6 +519,17 @@ func processReceivedMsg(_ res: ChatResponse) { chatItemSimpleUpdate(aChatItem) case let .rcvFileComplete(aChatItem): chatItemSimpleUpdate(aChatItem) + case let .sndFileStart(aChatItem, _): + chatItemSimpleUpdate(aChatItem) + case let .sndFileComplete(aChatItem, _): + chatItemSimpleUpdate(aChatItem) + let cItem = aChatItem.chatItem + if aChatItem.chatInfo.chatType == .direct, + let mc = cItem.content.msgContent, + mc.isFile(), + let fileName = cItem.file?.filePath { + removeFile(fileName) + } default: logger.debug("unsupported event: \(res.responseType)") } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index ecca2a577d..80aa7dd830 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -57,6 +57,7 @@ struct FramedItemView: View { } case let .file(text): CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited) + .overlay(DetermineWidth()) if text != "" { ciMsgContentView (chatItem, showMember) } @@ -107,6 +108,16 @@ struct FramedItemView: View { .aspectRatio(contentMode: .fill) .frame(width: 68, height: 68) .clipped() + } else if case .file = qi.content { + ciQuotedMsgView(qi) + .padding(.trailing, 20).frame(minWidth: msgWidth, alignment: .leading) + Image(systemName: "doc.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 18, height: 18) + .foregroundColor(Color(uiColor: .tertiaryLabel)) + .padding(.top, 6) + .padding(.trailing, 4) } else { ciQuotedMsgView(qi) } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 7daa6d6c6f..372df7434d 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -116,7 +116,11 @@ struct ChatView: View { if ci.isMsgContent() { Button { withAnimation { - composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci)) + if composeState.editing() { + composeState = ComposeState(contextItem: .quotedItem(chatItem: ci)) + } else { + composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci)) + } } } label: { Label("Reply", systemImage: "arrowshape.turn.up.left") } Button { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 60fe848723..dcd57ce578 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -12,6 +12,7 @@ enum ComposePreview { case noPreview case linkPreview(linkPreview: LinkPreview) case imagePreview(imagePreview: String) + case filePreview(fileName: String) } enum ComposeContextItem { @@ -65,6 +66,8 @@ struct ComposeState { switch preview { case .imagePreview: return true + case .filePreview: + return true default: return !message.isEmpty } @@ -74,6 +77,8 @@ struct ComposeState { switch preview { case .imagePreview: return false + case .filePreview: + return false default: return true } @@ -92,10 +97,14 @@ struct ComposeState { func chatItemPreview(chatItem: ChatItem) -> ComposePreview { let chatItemPreview: ComposePreview switch chatItem.content.msgContent { + case .text: + chatItemPreview = .noPreview case let .link(_, preview: preview): chatItemPreview = .linkPreview(linkPreview: preview) case let .image(_, image: image): chatItemPreview = .imagePreview(imagePreview: image) + case .file: + chatItemPreview = .filePreview(fileName: chatItem.file?.fileName ?? "") default: chatItemPreview = .noPreview } @@ -116,12 +125,18 @@ struct ComposeView: View { @State private var showChooseSource = false @State private var showImagePicker = false @State private var imageSource: ImageSource = .imageLibrary - @State var chosenImage: UIImage? + @State var chosenImage: UIImage? = nil + + @State private var showFileImporter = false + @State var chosenFile: URL? = nil var body: some View { VStack(spacing: 0) { contextItemView() - previewView() + switch (composeState.editing(), composeState.preview) { + case (true, .filePreview): EmptyView() + default: previewView() + } HStack (alignment: .bottom) { Button { showChooseSource = true @@ -163,6 +178,9 @@ struct ComposeView: View { imageSource = .imageLibrary showImagePicker = true } + Button("Choose file") { + showFileImporter = true + } } .sheet(isPresented: $showImagePicker) { switch imageSource { @@ -182,6 +200,36 @@ struct ComposeView: View { composeState = composeState.copy(preview: .noPreview) } } + .fileImporter( + isPresented: $showFileImporter, + allowedContentTypes: [.data], + allowsMultipleSelection: false + ) { result in + if case .success = result { + do { + let fileURL: URL = try result.get().first! + var fileSize: Int? = nil + if fileURL.startAccessingSecurityScopedResource() { + let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey]) + fileSize = resourceValues.fileSize + } + fileURL.stopAccessingSecurityScopedResource() + if let fileSize = fileSize, + fileSize <= maxFileSize { + chosenFile = fileURL + composeState = composeState.copy(preview: .filePreview(fileName: fileURL.lastPathComponent)) + } else { + let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: maxFileSize) + AlertManager.shared.showAlertMsg( + title: "Large file!", + message: "Currently maximum supported file size is \(prettyMaxFileSize)." + ) + } + } catch { + logger.error("ComposeView fileImporter error \(error.localizedDescription)") + } + } + } } @ViewBuilder func previewView() -> some View { @@ -193,7 +241,18 @@ struct ComposeView: View { case let .imagePreview(imagePreview: img): ComposeImageView( image: img, - cancelImage: { composeState = composeState.copy(preview: .noPreview) }, + cancelImage: { + composeState = composeState.copy(preview: .noPreview) + chosenImage = nil + }, + cancelEnabled: !composeState.editing()) + case let .filePreview(fileName: fileName): + ComposeFileView( + fileName: fileName, + cancelFile: { + composeState = composeState.copy(preview: .noPreview) + chosenFile = nil + }, cancelEnabled: !composeState.editing()) } } @@ -203,9 +262,17 @@ struct ComposeView: View { case .noContextItem: EmptyView() case let .quotedItem(chatItem: quotedItem): - ContextItemView(contextItem: quotedItem, cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }) + ContextItemView( + contextItem: quotedItem, + contextIcon: "arrowshape.turn.up.left.circle", + cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) } + ) case let .editingItem(chatItem: editingItem): - ContextItemView(contextItem: editingItem, cancelContextItem: { composeState = ComposeState()}) + ContextItemView( + contextItem: editingItem, + contextIcon: "pencil.circle", + cancelContextItem: { clearState() } + ) } } @@ -241,6 +308,12 @@ struct ComposeView: View { mc = .image(text: composeState.message, image: image) file = savedFile } + case .filePreview: + if let fileURL = chosenFile, + let savedFile = saveFileFromURL(fileURL) { + mc = .file(composeState.message) + file = savedFile + } } var quotedItemId: Int64? = nil @@ -261,13 +334,24 @@ struct ComposeView: View { chatModel.addChatItem(chat.chatInfo, chatItem) } } - composeState = ComposeState() + clearState() } catch { + clearState() logger.error("ChatView.sendMessage error: \(error.localizedDescription)") } } } + private func clearState() { + composeState = ComposeState() + linkUrl = nil + prevLinkUrl = nil + pendingLinkUrl = nil + cancelledLinks = [] + chosenImage = nil + chosenFile = nil + } + private func updateMsgContent(_ msgContent: MsgContent) -> MsgContent { switch msgContent { case .text: @@ -283,22 +367,6 @@ struct ComposeView: View { } } - private func saveImage(_ uiImage: UIImage) -> String? { - if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: maxImageSize) { - let millisecondsSince1970 = Int64((Date().timeIntervalSince1970 * 1000.0).rounded()) - let fileToSave = "image_\(millisecondsSince1970).jpg" - let filePath = getAppFilesDirectory().appendingPathComponent(fileToSave) - do { - try imageDataResized.write(to: filePath) - return fileToSave - } catch { - logger.error("ChatView.saveImage error: \(error.localizedDescription)") - return nil - } - } - return nil - } - private func showLinkPreview(_ s: String) { prevLinkUrl = linkUrl linkUrl = parseMessage(s) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index f7ebb1e53f..0f22862d05 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -11,10 +11,16 @@ import SwiftUI struct ContextItemView: View { @Environment(\.colorScheme) var colorScheme let contextItem: ChatItem + let contextIcon: String let cancelContextItem: () -> Void var body: some View { HStack { + Image(systemName: contextIcon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 20, height: 20) + .foregroundColor(.secondary) contextText(contextItem).lineLimit(3) Spacer() Button { @@ -26,6 +32,7 @@ struct ContextItemView: View { } } .padding(12) + .frame(minHeight: 50) .frame(maxWidth: .infinity) .background(chatItemFrameColor(contextItem, colorScheme)) .padding(.top, 8) @@ -33,9 +40,9 @@ struct ContextItemView: View { func contextText(_ cxtItem: ChatItem) -> some View { if let s = cxtItem.memberDisplayName { - return (Text(s).fontWeight(.medium) + Text(": \(cxtItem.content.text)")) + return (Text(s).fontWeight(.medium) + Text(": \(cxtItem.text)")) } else { - return Text(cxtItem.content.text) + return Text(cxtItem.text) } } } @@ -43,6 +50,6 @@ struct ContextItemView: View { struct ContextItemView_Previews: PreviewProvider { static var previews: some View { let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello") - return ContextItemView(contextItem: contextItem, cancelContextItem: {}) + return ContextItemView(contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {}) } } diff --git a/apps/ios/Shared/Views/Helpers/CIFileView.swift b/apps/ios/Shared/Views/Helpers/CIFileView.swift index c5c864ada8..cebdfa64dd 100644 --- a/apps/ios/Shared/Views/Helpers/CIFileView.swift +++ b/apps/ios/Shared/Views/Helpers/CIFileView.swift @@ -23,12 +23,13 @@ struct CIFileView: View { .padding(.top, 5) .padding(.bottom, 3) if let file = file { + let prettyFileSize = ByteCountFormatter().string(fromByteCount: file.fileSize) VStack(alignment: .leading, spacing: 2) { Text(file.fileName) .lineLimit(1) .multilineTextAlignment(.leading) .foregroundColor(.primary) - Text(formatBytes(bytes: file.fileSize) + metaReserve) + Text(prettyFileSize + metaReserve) .font(.caption) .lineLimit(1) .multilineTextAlignment(.leading) @@ -64,9 +65,10 @@ struct CIFileView: View { await receiveFile(fileId: file.fileId) } } else { + let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: maxFileSize) AlertManager.shared.showAlertMsg( title: "Large file!", - message: "Your contact sent a file that is larger than currently supported maximum size (\(maxFileSize) bytes)." + message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))." ) } case .rcvAccepted: @@ -88,6 +90,9 @@ struct CIFileView: View { @ViewBuilder func fileIndicator() -> some View { if let file = file { switch file.fileStatus { + case .sndStored: fileIcon("doc.fill") + case .sndTransfer: ProgressView().frame(width: 30, height: 30) + case .sndComplete: fileIcon("doc.fill", innerIcon: "checkmark", innerIconSize: 10) case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) case .rcvInvitation: if fileSizeValid() { @@ -97,15 +102,15 @@ struct CIFileView: View { } case .rcvAccepted: fileIcon("doc.fill", innerIcon: "ellipsis", innerIconSize: 12) case .rcvTransfer: ProgressView().frame(width: 30, height: 30) + case .rcvComplete: fileIcon("arrow.down.doc.fill", innerIconSize: 10) case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) - default: fileIcon("doc.fill") } } else { fileIcon("doc.fill") } } - func fileIcon(_ icon: String, color: Color = .secondary, innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View { + func fileIcon(_ icon: String, color: Color = Color(uiColor: .tertiaryLabel), innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View { ZStack(alignment: .center) { Image(systemName: icon) .resizable() @@ -124,24 +129,6 @@ struct CIFileView: View { } } } - - func formatBytes(bytes: Int64) -> String { - if (bytes == 0) { return "0 bytes" } - - let bytesDouble = Double(bytes) - let k: Double = 1000 - let units = ["bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] - - let i = floor(log2(bytesDouble) / log2(k)) - let size = bytesDouble / pow(k, i) - let unit = units[Int(i)] - - if (i <= 1) { - return String(format: "%.0f \(unit)", size) - } else { - return String(format: "%.2f \(unit)", size) - } - } } struct CIFileView_Previews: PreviewProvider { @@ -151,7 +138,7 @@ struct CIFileView_Previews: PreviewProvider { meta: CIMeta.getSample(1, .now, "", .sndSent, false, true, false), content: .sndMsgContent(msgContent: .file("")), quotedItem: nil, - file: CIFile.getSample(fileStatus: .sndStored) + file: CIFile.getSample(fileStatus: .sndComplete) ) let fileChatItemWtFile = ChatItem( chatDir: .directRcv, @@ -167,7 +154,7 @@ struct CIFileView_Previews: PreviewProvider { ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted)) ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer)) ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled)) - ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileSize: 2000000, fileStatus: .rcvInvitation)) + ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation)) ChatItemView(chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation)) ChatItemView(chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation)) ChatItemView(chatItem: fileChatItemWtFile) diff --git a/apps/ios/Shared/Views/Helpers/CIImageView.swift b/apps/ios/Shared/Views/Helpers/CIImageView.swift index 61518f0438..ec15883cbf 100644 --- a/apps/ios/Shared/Views/Helpers/CIImageView.swift +++ b/apps/ios/Shared/Views/Helpers/CIImageView.swift @@ -62,24 +62,38 @@ struct CIImageView: View { .scaledToFit() .frame(maxWidth: w) loadingIndicator() - .padding(8) } } @ViewBuilder private func loadingIndicator() -> some View { if let file = file { switch file.fileStatus { + case .sndTransfer: + ProgressView() + .progressViewStyle(.circular) + .frame(width: 20, height: 20) + .tint(.white) + .padding(8) + case .sndComplete: + Image(systemName: "checkmark") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 10, height: 10) + .foregroundColor(.white) + .padding(13) case .rcvAccepted: Image(systemName: "ellipsis") .resizable() .aspectRatio(contentMode: .fit) - .frame(width: 20, height: 20) + .frame(width: 14, height: 14) .foregroundColor(.white) + .padding(11) case .rcvTransfer: ProgressView() .progressViewStyle(.circular) .frame(width: 20, height: 20) .tint(.white) + .padding(8) default: EmptyView() } } diff --git a/apps/ios/Shared/Views/Helpers/ComposeFileView.swift b/apps/ios/Shared/Views/Helpers/ComposeFileView.swift new file mode 100644 index 0000000000..bc6a96aa86 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/ComposeFileView.swift @@ -0,0 +1,40 @@ +// +// ComposeFileView.swift +// SimpleX (iOS) +// +// Created by JRoberts on 04.05.2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ComposeFileView: View { + @Environment(\.colorScheme) var colorScheme + let fileName: String + let cancelFile: (() -> Void) + let cancelEnabled: Bool + + var body: some View { + HStack(alignment: .center, spacing: 4) { + Image(systemName: "doc.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 30, height: 30) + .foregroundColor(Color(uiColor: .tertiaryLabel)) + .padding(.leading, 4) + Text(fileName) + Spacer() + if cancelEnabled { + Button { cancelFile() } label: { + Image(systemName: "multiply") + } + } + } + .padding(.vertical, 1) + .padding(.trailing, 12) + .frame(height: 50) + .background(colorScheme == .light ? sentColorLight : sentColorDark) + .frame(maxWidth: .infinity) + .padding(.top, 8) + } +} diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index db4127fcf2..04848db8f0 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -93,6 +93,7 @@ 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; }; 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; }; 640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; }; + 6454036F2822A9750090DDFF /* ComposeFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6454036E2822A9750090DDFF /* ComposeFileView.swift */; }; 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; @@ -214,6 +215,7 @@ 5CEACCE227DE9246000BD591 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = ""; }; 640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = ""; }; + 6454036E2822A9750090DDFF /* ComposeFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeFileView.swift; sourceTree = ""; }; 648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; @@ -350,6 +352,7 @@ 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */, 649BCDA12805D6EF00C3A862 /* CIImageView.swift */, 648010AA281ADD15009009B9 /* CIFileView.swift */, + 6454036E2822A9750090DDFF /* ComposeFileView.swift */, ); path = Helpers; sourceTree = ""; @@ -676,6 +679,7 @@ 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */, 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */, 5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */, + 6454036F2822A9750090DDFF /* ComposeFileView.swift in Sources */, 5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */, 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */, 5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */, From 29990765e722e15c3947e336dd6273635fe02352 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 7 May 2022 06:40:46 +0100 Subject: [PATCH 61/95] mobile: webrtc calls state machine (#606) * mobile: webrtc calls state machine * android: call api types * android: call api methods * ios: connect calls via chat UI (WIP) * ios: webrtc call connects via UI * core: update call duration/status when x.call.end is received * improve call UX/UI * audio calls * different overlay for audio calls * toggle video/audio in the call --- .../android/app/src/main/assets/www/call.html | 4 +- apps/android/app/src/main/assets/www/call.js | 73 +++--- .../android/app/src/main/assets/www/style.css | 1 + .../java/chat/simplex/app/model/SimpleXAPI.kt | 82 ++++++ .../chat/simplex/app/views/call/WebRTC.kt | 66 +++++ apps/ios/Shared/Model/ChatModel.swift | 5 + apps/ios/Shared/Model/NtfManager.swift | 51 +++- apps/ios/Shared/Model/Shared/APITypes.swift | 55 +++- apps/ios/Shared/Model/Shared/CallTypes.swift | 48 ++++ apps/ios/Shared/Model/Shared/ChatTypes.swift | 39 ++- .../Shared/Model/Shared/Notifications.swift | 11 + apps/ios/Shared/Model/SimpleXAPI.swift | 76 +++++- .../Shared/Views/Call/ActiveCallView.swift | 188 ++++++++++++++ apps/ios/Shared/Views/Call/CallView.swift | 183 -------------- apps/ios/Shared/Views/Call/WebRTC.swift | 239 +++++++++++++----- apps/ios/Shared/Views/Call/WebRTCView.swift | 169 +++++++++++++ apps/ios/Shared/Views/Chat/ChatItemView.swift | 2 + apps/ios/Shared/Views/Chat/ChatView.swift | 33 ++- .../Views/ChatList/ChatListNavLink.swift | 10 +- .../Shared/Views/ChatList/ChatListView.swift | 49 +++- .../Views/UserSettings/SettingsView.swift | 10 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 18 +- packages/simplex-chat-webrtc/src/call.html | 4 +- packages/simplex-chat-webrtc/src/call.ts | 99 +++++--- packages/simplex-chat-webrtc/src/style.css | 1 + src/Simplex/Chat.hs | 21 +- 26 files changed, 1175 insertions(+), 362 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt create mode 100644 apps/ios/Shared/Model/Shared/CallTypes.swift create mode 100644 apps/ios/Shared/Views/Call/ActiveCallView.swift delete mode 100644 apps/ios/Shared/Views/Call/CallView.swift create mode 100644 apps/ios/Shared/Views/Call/WebRTCView.swift diff --git a/apps/android/app/src/main/assets/www/call.html b/apps/android/app/src/main/assets/www/call.html index ebe63e1a3b..fd3019e8eb 100644 --- a/apps/android/app/src/main/assets/www/call.html +++ b/apps/android/app/src/main/assets/www/call.html @@ -4,8 +4,8 @@ - - + +
diff --git a/apps/android/app/src/main/assets/www/call.js b/apps/android/app/src/main/assets/www/call.js index 94ef42c09f..e74864b9dd 100644 --- a/apps/android/app/src/main/assets/www/call.js +++ b/apps/android/app/src/main/assets/www/call.js @@ -11,7 +11,7 @@ const keyAlgorithm = { length: 256, }; const keyUsages = ["encrypt", "decrypt"]; -let pc; +let activeCall; const IV_LENGTH = 12; const initialPlainTextRequired = { key: 10, @@ -86,7 +86,7 @@ async function initializeCall(config, mediaType, aesKey) { sendMessageToNative({ resp: { type: "ice", iceCandidates } }); } }); - return { connection: conn, iceCandidates }; + return { connection: conn, iceCandidates, localMedia: mediaType, localStream }; function connectionStateChange() { sendMessageToNative({ resp: { @@ -103,17 +103,15 @@ async function initializeCall(config, mediaType, aesKey) { conn.removeEventListener("connectionstatechange", connectionStateChange); sendMessageToNative({ resp: { type: "ended" } }); conn.close(); - pc = undefined; + activeCall = undefined; resetVideoElements(); } } } -function sendMessageToNative(msg) { - console.log(JSON.stringify(msg)); -} -// TODO remove WCallCommand from result type +var sendMessageToNative = (msg) => console.log(JSON.stringify(msg)); async function processCommand(body) { - const { command, corrId } = body; + const { corrId, command } = body; + const pc = activeCall === null || activeCall === void 0 ? void 0 : activeCall.connection; let resp; try { switch (command.type) { @@ -123,26 +121,31 @@ async function processCommand(body) { break; case "start": console.log("starting call"); - if (pc) { + if (activeCall) { resp = { type: "error", message: "start: call already started" }; } else if (!supportsInsertableStreams() && command.aesKey) { resp = { type: "error", message: "start: encryption is not supported" }; } else { + const encryption = supportsInsertableStreams(); const { media, aesKey } = command; - const call = await initializeCall(defaultCallConfig(!!aesKey), media, aesKey); - const { connection, iceCandidates } = call; - pc = connection; + activeCall = await initializeCall(defaultCallConfig(encryption && !!aesKey), media, encryption ? aesKey : undefined); + const pc = activeCall.connection; const offer = await pc.createOffer(); await pc.setLocalDescription(offer); // for debugging, returning the command for callee to use - resp = { type: "accept", offer: JSON.stringify(offer), iceCandidates: await iceCandidates, media, aesKey }; - // resp = {type: "offer", offer, iceCandidates: await iceCandidates} + // resp = {type: "accept", offer: JSON.stringify(offer), iceCandidates: await iceCandidates, media, aesKey} + resp = { + type: "offer", + offer: JSON.stringify(offer), + iceCandidates: await activeCall.iceCandidates, + capabilities: { encryption }, + }; } break; case "accept": - if (pc) { + if (activeCall) { resp = { type: "error", message: "accept: call already started" }; } else if (!supportsInsertableStreams() && command.aesKey) { @@ -151,15 +154,18 @@ async function processCommand(body) { else { const offer = JSON.parse(command.offer); const remoteIceCandidates = command.iceCandidates.map((c) => JSON.parse(c)); - const call = await initializeCall(defaultCallConfig(!!command.aesKey), command.media, command.aesKey); - const { connection, iceCandidates } = call; - pc = connection; + activeCall = await initializeCall(defaultCallConfig(!!command.aesKey), command.media, command.aesKey); + const pc = activeCall.connection; await pc.setRemoteDescription(new RTCSessionDescription(offer)); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); addIceCandidates(pc, remoteIceCandidates); // same as command for caller to use - resp = { type: "answer", answer: JSON.stringify(answer), iceCandidates: await iceCandidates }; + resp = { + type: "answer", + answer: JSON.stringify(answer), + iceCandidates: await activeCall.iceCandidates, + }; } break; case "answer": @@ -190,10 +196,22 @@ async function processCommand(body) { resp = { type: "error", message: "ice: call not started" }; } break; + case "media": + if (!activeCall) { + resp = { type: "error", message: "media: call not started" }; + } + else if (activeCall.localMedia == CallMediaType.Audio && command.media == CallMediaType.Video) { + resp = { type: "error", message: "media: no video" }; + } + else { + enableMedia(activeCall.localStream, command.media, command.enable); + resp = { type: "ok" }; + } + break; case "end": if (pc) { pc.close(); - pc = undefined; + activeCall = undefined; resetVideoElements(); resp = { type: "ok" }; } @@ -209,7 +227,7 @@ async function processCommand(body) { catch (e) { resp = { type: "error", message: e.message }; } - const apiResp = { resp, corrId }; + const apiResp = { corrId, resp, command }; sendMessageToNative(apiResp); return apiResp; } @@ -321,14 +339,11 @@ function getVideoElements() { // video.style.opacity = "1" // } // } -// what does it do? -// function toggleVideo(b) { -// if (b == "true") { -// localStream.getVideoTracks()[0].enabled = true -// } else { -// localStream.getVideoTracks()[0].enabled = false -// } -// } +function enableMedia(s, media, enable) { + const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks(); + for (const t of tracks) + t.enabled = enable; +} /* Stream Transforms */ function setupPeerTransform(peer, transform) { const streams = peer.createEncodedStreams(); diff --git a/apps/android/app/src/main/assets/www/style.css b/apps/android/app/src/main/assets/www/style.css index 624e96dfe7..7b18648d2d 100644 --- a/apps/android/app/src/main/assets/www/style.css +++ b/apps/android/app/src/main/assets/www/style.css @@ -5,6 +5,7 @@ html, body { padding: 0; margin: 0; + background-color: black; } #remote-video-stream { position: absolute; diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 2451a976fd..d82011b7ca 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import chat.simplex.app.* import chat.simplex.app.R +import chat.simplex.app.views.call.* import chat.simplex.app.views.helpers.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -320,6 +321,51 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt return false } + suspend fun apiSendCallInvitation(contact: Contact, callType: CallType): Boolean { + val r = sendCmd(CC.ApiSendCallInvitation(contact, callType)) + return r is CR.CmdOk + } + + suspend fun apiRejectCall(contact: Contact): Boolean { + val r = sendCmd(CC.ApiRejectCall(contact)) + return r is CR.CmdOk + } + + suspend fun apiSendCallOffer(contact: Contact, rtcSession: String, rtcIceCandidates: List, media: CallMediaType, capabilities: CallCapabilities): Boolean { + val webRtcSession = WebRTCSession(rtcSession, rtcIceCandidates) + val callOffer = WebRTCCallOffer(CallType(media, capabilities), webRtcSession) + val r = sendCmd(CC.ApiSendCallOffer(contact, callOffer)) + return r is CR.CmdOk + } + + suspend fun apiSendCallAnswer(contact: Contact, rtcSession: String, rtcIceCandidates: List): Boolean { + val answer = WebRTCSession(rtcSession, rtcIceCandidates) + val r = sendCmd(CC.ApiSendCallAnswer(contact, answer)) + return r is CR.CmdOk + } + + suspend fun apiSendCallExtraInfo(contact: Contact, rtcIceCandidates: List): Boolean { + val extraInfo = WebRTCExtraInfo(rtcIceCandidates) + val r = sendCmd(CC.ApiSendCallExtraInfo(contact, extraInfo)) + return r is CR.CmdOk + } + + suspend fun apiEndCall(contact: Contact): Boolean { + val r = sendCmd(CC.ApiEndCall(contact)) + return r is CR.CmdOk + } + + suspend fun apiCallStatus(contact: Contact, status: String): Boolean { + try { + val callStatus = WebRTCCallStatus.valueOf(status) + val r = sendCmd(CC.ApiCallStatus(contact, callStatus)) + return r is CR.CmdOk + } catch (e: Error) { + Log.d(TAG,"apiCallStatus: call status $status not used") + return false + } + } + suspend fun apiChatRead(type: ChatType, id: Long, range: CC.ItemRange): Boolean { val r = sendCmd(CC.ApiChatRead(type, id, range)) if (r is CR.CmdOk) return true @@ -562,6 +608,13 @@ sealed class CC { class CreateMyAddress: CC() class DeleteMyAddress: CC() class ShowMyAddress: CC() + class ApiSendCallInvitation(val contact: Contact, val callType: CallType): CC() + class ApiRejectCall(val contact: Contact): CC() + class ApiSendCallOffer(val contact: Contact, val callOffer: WebRTCCallOffer): CC() + class ApiSendCallAnswer(val contact: Contact, val answer: WebRTCSession): CC() + class ApiSendCallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CC() + class ApiEndCall(val contact: Contact): CC() + class ApiCallStatus(val contact: Contact, val callStatus: WebRTCCallStatus): CC() class ApiAcceptContact(val contactReqId: Long): CC() class ApiRejectContact(val contactReqId: Long): CC() class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC() @@ -590,6 +643,13 @@ sealed class CC { is ShowMyAddress -> "/show_address" is ApiAcceptContact -> "/_accept $contactReqId" is ApiRejectContact -> "/_reject $contactReqId" + is ApiSendCallInvitation -> "/_call invite @${contact.apiId} ${json.encodeToString(callType)}" + is ApiRejectCall -> "/_call reject @${contact.apiId}" + is ApiSendCallOffer -> "/_call offer @${contact.apiId} ${json.encodeToString(callOffer)}" + is ApiSendCallAnswer -> "/_call answer @${contact.apiId} ${json.encodeToString(answer)}" + is ApiSendCallExtraInfo -> "/_call extra @${contact.apiId} ${json.encodeToString(extraInfo)}" + is ApiEndCall -> "/_call end @${contact.apiId}" + is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus}" is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}" is ReceiveFile -> "/freceive $fileId" } @@ -617,6 +677,13 @@ sealed class CC { is ShowMyAddress -> "showMyAddress" is ApiAcceptContact -> "apiAcceptContact" is ApiRejectContact -> "apiRejectContact" + is ApiSendCallInvitation -> "apiSendCallInvitation" + is ApiRejectCall -> "apiRejectCall" + is ApiSendCallOffer -> "apiSendCallOffer" + is ApiSendCallAnswer -> "apiSendCallAnswer" + is ApiSendCallExtraInfo -> "apiSendCallExtraInfo" + is ApiEndCall -> "apiEndCall" + is ApiCallStatus -> "apiCallStatus" is ApiChatRead -> "apiChatRead" is ReceiveFile -> "receiveFile" } @@ -706,6 +773,11 @@ sealed class CR { @Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() @Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() @Serializable @SerialName("sndGroupFileCancelled") class SndGroupFileCancelled(val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List): CR() + @Serializable @SerialName("callInvitation") class CallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String?): CR() + @Serializable @SerialName("callOffer") class CallOffer(val contact: Contact, val callType: CallType, val offer: WebRTCSession, val sharedKey: String?, val askConfirmation: Boolean): CR() + @Serializable @SerialName("callAnswer") class CallAnswer(val contact: Contact, val answer: WebRTCSession): CR() + @Serializable @SerialName("callExtraInfo") class CallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CR() + @Serializable @SerialName("callEnded") class CallEnded(val contact: Contact): CR() @Serializable @SerialName("newContactConnection") class NewContactConnection(val connection: PendingContactConnection): CR() @Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val connection: PendingContactConnection): CR() @Serializable @SerialName("cmdOk") class CmdOk: CR() @@ -758,6 +830,11 @@ sealed class CR { is SndFileRcvCancelled -> "sndFileRcvCancelled" is SndFileStart -> "sndFileStart" is SndGroupFileCancelled -> "sndGroupFileCancelled" + is CallInvitation -> "callInvitation" + is CallOffer -> "callOffer" + is CallAnswer -> "callAnswer" + is CallExtraInfo -> "callExtraInfo" + is CallEnded -> "callEnded" is NewContactConnection -> "newContactConnection" is ContactConnectionDeleted -> "contactConnectionDeleted" is CmdOk -> "cmdOk" @@ -811,6 +888,11 @@ sealed class CR { is SndFileRcvCancelled -> json.encodeToString(chatItem) is SndFileStart -> json.encodeToString(chatItem) is SndGroupFileCancelled -> json.encodeToString(chatItem) + is CallInvitation -> "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}" + is CallOffer -> "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}\naskConfirmation: $askConfirmation\noffer: ${json.encodeToString(offer)}" + is CallAnswer -> "contact: ${contact.id}\nanswer: ${json.encodeToString(answer)}" + is CallExtraInfo -> "contact: ${contact.id}\nextraInfo: ${json.encodeToString(extraInfo)}" + is CallEnded -> "contact: ${contact.id}" is NewContactConnection -> json.encodeToString(connection) is ContactConnectionDeleted -> json.encodeToString(connection) is CmdOk -> noDetails() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt new file mode 100644 index 0000000000..31af628394 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt @@ -0,0 +1,66 @@ +package chat.simplex.app.views.call + +import chat.simplex.app.model.CR +import chat.simplex.app.model.User +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +sealed class WCallCommand { + @Serializable @SerialName("capabilities") class Capabilities(): WCallCommand() + @Serializable @SerialName("start") class Start(val media: CallMediaType, val aesKey: String? = null): WCallCommand() + @Serializable @SerialName("accept") class Accept(val offer: String, val iceCandidates: List, val media: CallMediaType, val aesKey: String? = null): WCallCommand() + @Serializable @SerialName("answer") class Answer (val answer: String, val iceCandidates: List): WCallCommand() + @Serializable @SerialName("ice") class Ice(val iceCandidates: List): WCallCommand() + @Serializable @SerialName("end") class End(): WCallCommand() +} + +@Serializable +sealed class WCallResponse { + @Serializable @SerialName("capabilities") class Capabilities(val capabilities: CallCapabilities): WCallResponse() + @Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: List): WCallResponse() + // TODO remove accept, it is needed for debugging + @Serializable @SerialName("accept") class Accept(val offer: String, val iceCandidates: List, val media: CallMediaType, val aesKey: String? = null): WCallResponse() + @Serializable @SerialName("answer") class Answer(val answer: String, val iceCandidates: List): WCallResponse() + @Serializable @SerialName("ice") class Ice(val iceCandidates: List): WCallResponse() + @Serializable @SerialName("connection") class Connection(val state: ConnectionState): WCallResponse() + @Serializable @SerialName("ended") class Ended(): WCallResponse() + @Serializable @SerialName("ok") class Ok(): WCallResponse() + @Serializable @SerialName("error") class Error(val message: String): WCallResponse() + @Serializable class Invalid(val str: String): WCallResponse() +} + +@Serializable +class WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession) + +@Serializable +class WebRTCSession(val rtcSession: String, val rtcIceCandidates: List) + +@Serializable +class WebRTCExtraInfo(val rtcIceCandidates: List) + +@Serializable +class CallType(val media: CallMediaType, val capabilities: CallCapabilities) + +enum class WebRTCCallStatus(val status: String) { + Connected("connected"), + Disconnected("disconnected"), + Failed("failed") +} + +@Serializable +enum class CallMediaType(val media: String) { + Video("video"), + Audio("audio") +} + +@Serializable +class CallCapabilities(val encryption: Boolean) + +@Serializable +class ConnectionState( + val connectionState: String, + val iceConnectionState: String, + val iceGatheringState: String, + val signalingState: String +) \ No newline at end of file diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 3e5fde0aa0..3fcc40b16a 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -25,6 +25,11 @@ final class ChatModel: ObservableObject { @Published var appOpenUrl: URL? @Published var deviceToken: String? @Published var tokenStatus = NtfTknStatus.new + // current WebRTC call + @Published var callInvitations: Dictionary = [:] + @Published var activeCallInvitation: ContactRef? + @Published var activeCall: Call? + @Published var callCommand: WCallCommand? var messageDelivery: Dictionary Void> = [:] diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index f6ba0fda00..a4c53c52ea 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -10,7 +10,9 @@ import Foundation import UserNotifications import UIKit -let ntfActionAccept = "NTF_ACT_ACCEPT" +let ntfActionAcceptContact = "NTF_ACT_ACCEPT_CONTACT" +let ntfActionAcceptCall = "NTF_ACT_ACCEPT_CALL" +let ntfActionRejectCall = "NTF_ACT_REJECT_CALL" private let ntfTimeInterval: TimeInterval = 1 @@ -28,10 +30,33 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { logger.debug("NtfManager.userNotificationCenter: didReceive") let content = response.notification.request.content let chatModel = ChatModel.shared - if content.categoryIdentifier == ntfCategoryContactRequest && response.actionIdentifier == ntfActionAccept, + let action = response.actionIdentifier + if content.categoryIdentifier == ntfCategoryContactRequest && action == ntfActionAcceptContact, let chatId = content.userInfo["chatId"] as? String, case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo { Task { await acceptContactRequest(contactRequest) } + } else if content.categoryIdentifier == ntfCategoryCallInvitation && (action == ntfActionAcceptCall || action == ntfActionRejectCall), + let chatId = content.userInfo["chatId"] as? String, + case let .direct(contact) = chatModel.getChat(chatId)?.chatInfo, + let invitation = chatModel.callInvitations[chatId] { + if action == ntfActionAcceptCall { + chatModel.activeCall = Call(contact: contact, callState: .invitationReceived, localMedia: invitation.peerMedia) + chatModel.chatId = nil + chatModel.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey) + } else { + Task { + do { + try await apiRejectCall(contact) + if chatModel.activeCall?.contact.id == chatId { + DispatchQueue.main.async { + chatModel.callCommand = .end + chatModel.activeCall = nil + } + } + } + } + } + chatModel.callInvitations.removeValue(forKey: chatId) } else { chatModel.chatId = content.targetContentIdentifier } @@ -88,7 +113,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { UNNotificationCategory( identifier: ntfCategoryContactRequest, actions: [UNNotificationAction( - identifier: ntfActionAccept, + identifier: ntfActionAcceptContact, title: NSLocalizedString("Accept", comment: "accept contact request via notification") )], intentIdentifiers: [], @@ -106,6 +131,21 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { intentIdentifiers: [], hiddenPreviewsBodyPlaceholder: NSLocalizedString("New message", comment: "notification") ), + UNNotificationCategory( + identifier: ntfCategoryCallInvitation, + actions: [ + UNNotificationAction( + identifier: ntfActionAcceptCall, + title: NSLocalizedString("Answer", comment: "accept incoming call via notification") + ), + UNNotificationAction( + identifier: ntfActionRejectCall, + title: NSLocalizedString("Ignore", comment: "ignore incoming call via notification") + ) + ], + intentIdentifiers: [], + hiddenPreviewsBodyPlaceholder: NSLocalizedString("Incoming call", comment: "notification") + ), // TODO remove UNNotificationCategory( identifier: ntfCategoryCheckingMessages, @@ -154,6 +194,11 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { addNotification(createMessageReceivedNtf(cInfo, cItem)) } + func notifyCallInvitation(_ contact: Contact, _ invitation: CallInvitation) { + logger.debug("NtfManager.notifyCallInvitation") + addNotification(createCallInvitationNtf(contact, invitation)) + } + // TODO remove func notifyCheckingMessages() { logger.debug("NtfManager.notifyCheckingMessages") diff --git a/apps/ios/Shared/Model/Shared/APITypes.swift b/apps/ios/Shared/Model/Shared/APITypes.swift index 6edae1e60d..6873c8bb42 100644 --- a/apps/ios/Shared/Model/Shared/APITypes.swift +++ b/apps/ios/Shared/Model/Shared/APITypes.swift @@ -37,6 +37,14 @@ enum ChatCommand { case showMyAddress case apiAcceptContact(contactReqId: Int64) case apiRejectContact(contactReqId: Int64) + // WebRTC calls + case apiSendCallInvitation(contact: Contact, callType: CallType) + case apiRejectCall(contact: Contact) + case apiSendCallOffer(contact: Contact, callOffer: WebRTCCallOffer) + case apiSendCallAnswer(contact: Contact, answer: WebRTCSession) + case apiSendCallExtraInfo(contact: Contact, extraInfo: WebRTCExtraInfo) + case apiEndCall(contact: Contact) + case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus) case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) case receiveFile(fileId: Int64) case string(String) @@ -71,6 +79,13 @@ enum ChatCommand { case .showMyAddress: return "/show_address" case let .apiAcceptContact(contactReqId): return "/_accept \(contactReqId)" case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)" + case let .apiSendCallInvitation(contact, callType): return "/_call invite @\(contact.apiId) \(encodeJSON(callType))" + case let .apiRejectCall(contact): return "/_call reject @\(contact.apiId)" + case let .apiSendCallOffer(contact, callOffer): return "/_call offer @\(contact.apiId) \(encodeJSON(callOffer))" + case let .apiSendCallAnswer(contact, answer): return "/_call answer @\(contact.apiId) \(encodeJSON(answer))" + case let .apiSendCallExtraInfo(contact, extraInfo): return "/_call extra @\(contact.apiId) \(encodeJSON(extraInfo))" + case let .apiEndCall(contact): return "/_call end @\(contact.apiId)" + case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)" case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)" case let .receiveFile(fileId): return "/freceive \(fileId)" case let .string(str): return str @@ -106,6 +121,13 @@ enum ChatCommand { case .showMyAddress: return "showMyAddress" case .apiAcceptContact: return "apiAcceptContact" case .apiRejectContact: return "apiRejectContact" + case .apiSendCallInvitation: return "apiSendCallInvitation" + case .apiRejectCall: return "apiRejectCall" + case .apiSendCallOffer: return "apiSendCallOffer" + case .apiSendCallAnswer: return "apiSendCallAnswer" + case .apiSendCallExtraInfo: return "apiSendCallExtraInfo" + case .apiEndCall: return "apiEndCall" + case .apiCallStatus: return "apiCallStatus" case .apiChatRead: return "apiChatRead" case .receiveFile: return "receiveFile" case .string: return "console command" @@ -173,6 +195,11 @@ enum ChatResponse: Decodable, Error { case sndFileCancelled(chatItem: AChatItem, sndFileTransfer: SndFileTransfer) case sndFileRcvCancelled(chatItem: AChatItem, sndFileTransfer: SndFileTransfer) case sndGroupFileCancelled(chatItem: AChatItem, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer]) + case callInvitation(contact: Contact, callType: CallType, sharedKey: String?) + case callOffer(contact: Contact, callType: CallType, offer: WebRTCSession, sharedKey: String?, askConfirmation: Bool) + case callAnswer(contact: Contact, answer: WebRTCSession) + case callExtraInfo(contact: Contact, extraInfo: WebRTCExtraInfo) + case callEnded(contact: Contact) case ntfTokenStatus(status: NtfTknStatus) case newContactConnection(connection: PendingContactConnection) case contactConnectionDeleted(connection: PendingContactConnection) @@ -227,6 +254,11 @@ enum ChatResponse: Decodable, Error { case .sndFileCancelled: return "sndFileCancelled" case .sndFileRcvCancelled: return "sndFileRcvCancelled" case .sndGroupFileCancelled: return "sndGroupFileCancelled" + case .callInvitation: return "callInvitation" + case .callOffer: return "callOffer" + case .callAnswer: return "callAnswer" + case .callExtraInfo: return "callExtraInfo" + case .callEnded: return "callEnded" case .ntfTokenStatus: return "ntfTokenStatus" case .newContactConnection: return "newContactConnection" case .contactConnectionDeleted: return "contactConnectionDeleted" @@ -284,6 +316,11 @@ enum ChatResponse: Decodable, Error { case let .sndFileCancelled(chatItem, _): return String(describing: chatItem) case let .sndFileRcvCancelled(chatItem, _): return String(describing: chatItem) case let .sndGroupFileCancelled(chatItem, _, _): return String(describing: chatItem) + case let .callInvitation(contact, callType, sharedKey): return "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")" + case let .callOffer(contact, callType, offer, sharedKey, askConfirmation): return "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")\naskConfirmation: \(askConfirmation)\noffer: \(String(describing: offer))" + case let .callAnswer(contact, answer): return "contact: \(contact.id)\nanswer: \(String(describing: answer))" + case let .callExtraInfo(contact, extraInfo): return "contact: \(contact.id)\nextraInfo: \(String(describing: extraInfo))" + case let .callEnded(contact): return "contact: \(contact.id)" case let .ntfTokenStatus(status): return String(describing: status) case let .newContactConnection(connection): return String(describing: connection) case let .contactConnectionDeleted(connection): return String(describing: connection) @@ -303,12 +340,18 @@ struct ComposedMessage: Encodable { var msgContent: MsgContent } -private func decodeCJSON(_ cjson: UnsafePointer) -> T? { - let s = String.init(cString: cjson) - let d = s.data(using: .utf8)! -// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) -// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) - return try? jsonDecoder.decode(T.self, from: d) +func decodeJSON(_ json: String) -> T? { + if let data = json.data(using: .utf8) { + return try? jsonDecoder.decode(T.self, from: data) + } + return nil +} + +func decodeCJSON(_ cjson: UnsafePointer) -> T? { + // TODO is there a way to do it without copying the data? e.g: + // let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) + // let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) + decodeJSON(String.init(cString: cjson)) } private func getJSONObject(_ cjson: UnsafePointer) -> NSDictionary? { diff --git a/apps/ios/Shared/Model/Shared/CallTypes.swift b/apps/ios/Shared/Model/Shared/CallTypes.swift new file mode 100644 index 0000000000..355cb15983 --- /dev/null +++ b/apps/ios/Shared/Model/Shared/CallTypes.swift @@ -0,0 +1,48 @@ +// +// CallTypes.swift +// SimpleX (iOS) +// +// Created by Evgeny on 05/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation + +struct WebRTCCallOffer: Encodable { + var callType: CallType + var rtcSession: WebRTCSession +} + +struct WebRTCSession: Codable { + var rtcSession: String + var rtcIceCandidates: [String] +} + +struct WebRTCExtraInfo: Codable { + var rtcIceCandidates: [String] +} + +struct CallInvitation { + var peerMedia: CallMediaType + var sharedKey: String? +} + +struct CallType: Codable { + var media: CallMediaType + var capabilities: CallCapabilities +} + +enum CallMediaType: String, Codable, Equatable { + case video = "video" + case audio = "audio" +} + +struct CallCapabilities: Codable, Equatable { + var encryption: Bool +} + +enum WebRTCCallStatus: String, Encodable { + case connected = "connected" + case disconnected = "disconnected" + case failed = "failed" +} diff --git a/apps/ios/Shared/Model/Shared/ChatTypes.swift b/apps/ios/Shared/Model/Shared/ChatTypes.swift index 4005fdde4d..9e3827ed85 100644 --- a/apps/ios/Shared/Model/Shared/ChatTypes.swift +++ b/apps/ios/Shared/Model/Shared/ChatTypes.swift @@ -218,7 +218,7 @@ struct Contact: Identifiable, Decodable, NamedChat { ) } -struct ContactRef: Decodable { +struct ContactRef: Decodable, Equatable { var contactId: Int64 var localDisplayName: ContactName @@ -460,6 +460,14 @@ struct ChatItem: Identifiable, Decodable { } } + func isCall() -> Bool { + switch content { + case .sndCall: return true + case .rcvCall: return true + default: return false + } + } + var memberDisplayName: String? { get { if case let .groupRcv(groupMember) = chatDir { @@ -578,6 +586,8 @@ enum CIContent: Decodable, ItemContent { case rcvMsgContent(msgContent: MsgContent) case sndDeleted(deleteMode: CIDeleteMode) case rcvDeleted(deleteMode: CIDeleteMode) + case sndCall(status: CICallStatus, duration: Int) + case rcvCall(status: CICallStatus, duration: Int) var text: String { get { @@ -586,6 +596,8 @@ enum CIContent: Decodable, ItemContent { case let .rcvMsgContent(mc): return mc.text case .sndDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") case .rcvDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") + case let .sndCall(status, duration): return status.text(duration) + case let .rcvCall(status, duration): return status.text(duration) } } } @@ -838,3 +850,28 @@ struct SndFileTransfer: Decodable { struct FileTransferMeta: Decodable { } + +enum CICallStatus: String, Decodable { + case pending + case missed + case rejected + case accepted + case negotiated + case progress + case ended + case error + + func text(_ sec: Int) -> String { + switch self { + case .pending: return "calling..." + case .negotiated: return "connecting..." + case .progress: return "in progress" + case .ended: return "ended \(duration(sec))" + default: return self.rawValue + } + } + + func duration(_ sec: Int) -> String { + String(format: "%02d:%02d", sec / 60, sec % 60) + } +} diff --git a/apps/ios/Shared/Model/Shared/Notifications.swift b/apps/ios/Shared/Model/Shared/Notifications.swift index 7e98333f78..725dbb8553 100644 --- a/apps/ios/Shared/Model/Shared/Notifications.swift +++ b/apps/ios/Shared/Model/Shared/Notifications.swift @@ -12,6 +12,7 @@ import UserNotifications let ntfCategoryContactRequest = "NTF_CAT_CONTACT_REQUEST" let ntfCategoryContactConnected = "NTF_CAT_CONTACT_CONNECTED" let ntfCategoryMessageReceived = "NTF_CAT_MESSAGE_RECEIVED" +let ntfCategoryCallInvitation = "NTF_CAT_CALL_INVITATION" let ntfCategoryCheckMessage = "NTF_CAT_CHECK_MESSAGE" // TODO remove let ntfCategoryCheckingMessages = "NTF_CAT_CHECKING_MESSAGES" @@ -48,6 +49,16 @@ func createMessageReceivedNtf(_ cInfo: ChatInfo, _ cItem: ChatItem) -> UNMutable ) } +func createCallInvitationNtf(_ contact: Contact, _ invitation: CallInvitation) -> UNMutableNotificationContent { + createNotification( + categoryIdentifier: ntfCategoryCallInvitation, + title: "\(contact.chatViewName):", + body: String.localizedStringWithFormat(NSLocalizedString("Incoming %@ call", comment: "notification body"), invitation.peerMedia.rawValue), + targetContentIdentifier: nil, + userInfo: ["chatId": contact.id] + ) +} + func createNotification(categoryIdentifier: String, title: String, subtitle: String? = nil, body: String? = nil, targetContentIdentifier: String? = nil, userInfo: [AnyHashable : Any] = [:]) -> UNMutableNotificationContent { let content = UNMutableNotificationContent() diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index c298e611aa..394fb6c266 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -360,6 +360,42 @@ func rejectContactRequest(_ contactRequest: UserContactRequest) async { } } +func apiSendCallInvitation(_ contact: Contact, _ callType: CallType) async throws { + try await sendCommandOkResp(.apiSendCallInvitation(contact: contact, callType: callType)) +} + +func apiRejectCall(_ contact: Contact) async throws { + try await sendCommandOkResp(.apiRejectCall(contact: contact)) +} + +func apiSendCallOffer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandidates: [String], media: CallMediaType, capabilities: CallCapabilities) async throws { + let webRtcSession = WebRTCSession(rtcSession: rtcSession, rtcIceCandidates: rtcIceCandidates) + let callOffer = WebRTCCallOffer(callType: CallType(media: media, capabilities: capabilities), rtcSession: webRtcSession) + try await sendCommandOkResp(.apiSendCallOffer(contact: contact, callOffer: callOffer)) +} + +func apiSendCallAnswer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandidates: [String]) async throws { + let answer = WebRTCSession(rtcSession: rtcSession, rtcIceCandidates: rtcIceCandidates) + try await sendCommandOkResp(.apiSendCallAnswer(contact: contact, answer: answer)) +} + +func apiSendCallExtraInfo(_ contact: Contact, _ rtcIceCandidates: [String]) async throws { + let extraInfo = WebRTCExtraInfo(rtcIceCandidates: rtcIceCandidates) + try await sendCommandOkResp(.apiSendCallExtraInfo(contact: contact, extraInfo: extraInfo)) +} + +func apiEndCall(_ contact: Contact) async throws { + try await sendCommandOkResp(.apiEndCall(contact: contact)) +} + +func apiCallStatus(_ contact: Contact, _ status: String) async throws { + if let callStatus = WebRTCCallStatus.init(rawValue: status) { + try await sendCommandOkResp(.apiCallStatus(contact: contact, callStatus: callStatus)) + } else { + logger.debug("apiCallStatus: call status \(status) not used") + } +} + func markChatRead(_ chat: Chat) async { do { let minItemId = chat.chatStats.minUnreadItemId @@ -486,7 +522,9 @@ func processReceivedMsg(_ res: ChatResponse) { await receiveFile(fileId: file.fileId) } } - NtfManager.shared.notifyMessageReceived(cInfo, cItem) + if !cItem.isCall() { + NtfManager.shared.notifyMessageReceived(cInfo, cItem) + } case let .chatItemStatusUpdated(aChatItem): let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem @@ -530,9 +568,45 @@ func processReceivedMsg(_ res: ChatResponse) { let fileName = cItem.file?.filePath { removeFile(fileName) } + case let .callInvitation(contact, callType, sharedKey): + let invitation = CallInvitation(peerMedia: callType.media, sharedKey: sharedKey) + m.callInvitations[contact.id] = invitation + if (m.activeCallInvitation == nil) { + m.activeCallInvitation = ContactRef(contactId: contact.apiId, localDisplayName: contact.localDisplayName) + } + NtfManager.shared.notifyCallInvitation(contact, invitation) + case let .callOffer(contact, callType, offer, sharedKey, _): + // TODO askConfirmation? + // TODO check encryption is compatible + withCall(contact) { call in + m.activeCall = call.copy(callState: .offerReceived, peerMedia: callType.media, sharedKey: sharedKey) + m.callCommand = .accept(offer: offer.rtcSession, iceCandidates: offer.rtcIceCandidates, media: callType.media, aesKey: sharedKey) + } + case let .callAnswer(contact, answer): + withCall(contact) { call in + m.activeCall = call.copy(callState: .negotiated) + m.callCommand = .answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates) + } + case let .callExtraInfo(contact, extraInfo): + withCall(contact) { _ in + m.callCommand = .ice(iceCandidates: extraInfo.rtcIceCandidates) + } + case let .callEnded(contact): + m.activeCallInvitation = nil + withCall(contact) { _ in + m.callCommand = .end + } default: logger.debug("unsupported event: \(res.responseType)") } + + func withCall(_ contact: Contact, _ perform: (Call) -> Void) { + if let call = m.activeCall, call.contact.apiId == contact.apiId { + perform(call) + } else { + logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)") + } + } } } diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift new file mode 100644 index 0000000000..33628e685f --- /dev/null +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -0,0 +1,188 @@ +// +// ActiveCallView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 05/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ActiveCallView: View { + @EnvironmentObject var chatModel: ChatModel + @Environment(\.dismiss) private var dismiss + @Binding var showCallView: Bool + @State private var coordinator: WebRTCCoordinator? = nil + @State private var webViewReady: Bool = false + @State private var webViewMsg: WVAPIMessage? = nil + + var body: some View { + ZStack(alignment: .bottom) { + WebRTCView(coordinator: $coordinator, webViewReady: $webViewReady, webViewMsg: $webViewMsg) + .onAppear() { sendCommandToWebView() } + .onChange(of: chatModel.callCommand) { _ in sendCommandToWebView() } + .onChange(of: webViewReady) { _ in sendCommandToWebView() } + .onChange(of: webViewMsg) { _ in processWebViewMessage() } + .background(.black) + ActiveCallOverlay(call: chatModel.activeCall, dismiss: { dismiss() }) + } + } + + private func sendCommandToWebView() { + if chatModel.activeCall != nil && webViewReady, + let cmd = chatModel.callCommand, + let c = coordinator { + chatModel.callCommand = nil + logger.debug("ActiveCallView: command \(cmd.cmdType)") + c.sendCommand(command: cmd) + } + } + + private func processWebViewMessage() { + let m = chatModel + if let msg = webViewMsg, + let call = chatModel.activeCall { + logger.debug("ActiveCallView: response \(msg.resp.respType)") + Task { + switch msg.resp { + case let .capabilities(capabilities): + let callType = CallType(media: call.localMedia, capabilities: capabilities) + try await apiSendCallInvitation(call.contact, callType) + m.activeCall = call.copy(callState: .invitationSent, localCapabilities: capabilities) + case let .offer(offer, iceCandidates, capabilities): + try await apiSendCallOffer(call.contact, offer, iceCandidates, + media: call.localMedia, capabilities: capabilities) + m.activeCall = call.copy(callState: .offerSent, localCapabilities: capabilities) + case let .answer(answer, iceCandidates): + try await apiSendCallAnswer(call.contact, answer, iceCandidates) + m.activeCall = call.copy(callState: .negotiated) + case let .ice(iceCandidates): + try await apiSendCallExtraInfo(call.contact, iceCandidates) + case let .connection(state): + if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState), + case .connected = callStatus { + m.activeCall = call.copy(callState: .connected) + } + try await apiCallStatus(call.contact, state.connectionState) + case .ended: + m.activeCall = nil + m.activeCallInvitation = nil + m.callCommand = nil + showCallView = false + case .ok: + switch msg.command { + case let .media(media, enable): + switch media { + case .video: m.activeCall = call.copy(videoEnabled: enable) + case .audio: m.activeCall = call.copy(audioEnabled: enable) + } + case .end: + m.activeCall = nil + m.activeCallInvitation = nil + m.callCommand = nil + showCallView = false + default: () + } + case let .error(message): + logger.debug("ActiveCallView: command error: \(message)") + case let .invalid(type): + logger.debug("ActiveCallView: invalid response: \(type)") + } + } + } + } +} + +struct ActiveCallOverlay: View { + @EnvironmentObject var chatModel: ChatModel + var call: Call? + var dismiss: () -> Void + + var body: some View { + VStack { + if let call = call { + switch call.localMedia { + case .video: + callInfoView(call, .leading) + .foregroundColor(.white) + .opacity(0.8) + .padding() + case .audio: + VStack { + ProfileImage(imageStr: call.contact.profile.image) + .scaledToFit() + .frame(width: 192, height: 192) + callInfoView(call, .center) + } + .foregroundColor(.white) + .opacity(0.8) + .padding() + .frame(maxHeight: .infinity) + } + Spacer() + ZStack(alignment: .bottom) { + VStack(alignment: .leading) { + if call.localMedia == .video { + callButton(call.videoEnabled ? "video.fill" : "video.slash", size: 48) { + chatModel.callCommand = .media(media: .video, enable: !call.videoEnabled) + } + .foregroundColor(.white) + .opacity(0.85) + } + callButton(call.audioEnabled ? "mic.fill" : "mic.slash", size: 48) { + chatModel.callCommand = .media(media: .audio, enable: !call.audioEnabled) + } + .foregroundColor(.white) + .opacity(0.85) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top) + } + callButton("phone.down.fill", size: 60) { dismiss() } + .foregroundColor(.red) + } + .padding(.bottom, 60) + .padding(.horizontal, 48) + } + } + .frame(maxWidth: .infinity) + } + + private func callInfoView(_ call: Call, _ alignment: Alignment) -> some View { + VStack { + Text(call.contact.chatViewName) + .lineLimit(1) + .font(.title) + .frame(maxWidth: .infinity, alignment: alignment) + let status = call.callState == .connected + ? call.encrypted + ? "end-to-end encrypted" + : "no end-to-end encryption" + : call.callState.text + Text(status) + .font(.subheadline) + .frame(maxWidth: .infinity, alignment: alignment) + } + } + + private func callButton(_ imageName: String, size: CGFloat, perform: @escaping () -> Void) -> some View { + Button { + perform() + } label: { + Image(systemName: imageName) + .resizable() + .scaledToFit() + .frame(maxWidth: size, maxHeight: size) + } + } +} + +struct ActiveCallOverlay_Previews: PreviewProvider { + static var previews: some View { + Group{ + ActiveCallOverlay(call: Call(contact: Contact.sampleData, callState: .offerSent, localMedia: .video), dismiss: {}) + .background(.black) + ActiveCallOverlay(call: Call(contact: Contact.sampleData, callState: .offerSent, localMedia: .audio), dismiss: {}) + .background(.black) + } + } +} diff --git a/apps/ios/Shared/Views/Call/CallView.swift b/apps/ios/Shared/Views/Call/CallView.swift deleted file mode 100644 index ff67b1e165..0000000000 --- a/apps/ios/Shared/Views/Call/CallView.swift +++ /dev/null @@ -1,183 +0,0 @@ -// -// CallView.swift -// SimpleX (iOS) -// -// Created by Ian Davies on 29/04/2022. -// Copyright © 2022 SimpleX Chat. All rights reserved. -// - -import SwiftUI -import WebKit - -class WebRTCCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { - var webView: WKWebView! - - var corrId = 0 - var pendingCommands: Dictionary> = [:] - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - webView.allowsBackForwardNavigationGestures = false - self.webView = webView - } - - // receive message from WKWebView - func userContentController( - _ userContentController: WKUserContentController, - didReceive message: WKScriptMessage - ) { - logger.debug("WebRTCCoordinator.userContentController") - if let data = (message.body as? String)?.data(using: .utf8), - let msg = try? jsonDecoder.decode(WVAPIMessage.self, from: data) { - if let corrId = msg.corrId, let cont = pendingCommands.removeValue(forKey: corrId) { - cont.resume(returning: msg.resp) - } else { - // TODO pass messages to call view via binding - // print(msg.resp) - } - } else { - logger.error("WebRTCCoordinator.userContentController: invalid message \(String(describing: message.body))") - } - } - - func messageToWebview(msg: String) { - logger.debug("WebRTCCoordinator.messageToWebview") - self.webView.evaluateJavaScript("webkit.messageHandlers.logHandler.postMessage('\(msg)')") - } - - func processCommand(command: WCallCommand) async -> WCallResponse { - await withCheckedContinuation { cont in - logger.debug("WebRTCCoordinator.processCommand") - let corrId_ = corrId - corrId = corrId + 1 - pendingCommands[corrId_] = cont - let apiCmd = encodeJSON(WVAPICall(corrId: corrId_, command: command)) - DispatchQueue.main.async { - logger.debug("WebRTCCoordinator.processCommand DispatchQueue.main.async") - let js = "processCommand(\(apiCmd))" - self.webView.evaluateJavaScript(js) - } - } - } -} - -struct WebRTCView: UIViewRepresentable { - @Binding var coordinator: WebRTCCoordinator? - - func makeCoordinator() -> WebRTCCoordinator { - WebRTCCoordinator() - } - - func makeUIView(context: Context) -> WKWebView { - let _coordinator = makeCoordinator() - DispatchQueue.main.async { - coordinator = _coordinator - } - - let userContentController = WKUserContentController() - - let cfg = WKWebViewConfiguration() - cfg.userContentController = userContentController - cfg.mediaTypesRequiringUserActionForPlayback = [] - cfg.allowsInlineMediaPlayback = true - - // Enable us to capture calls to console.log in the xcode logs - let source = "console.log = (msg) => webkit.messageHandlers.logHandler.postMessage(msg)" - let script = WKUserScript(source: source, injectionTime: .atDocumentStart, forMainFrameOnly: false) - cfg.userContentController.addUserScript(script) - cfg.userContentController.add(_coordinator, name: "logHandler") - - let _wkwebview = WKWebView(frame: .zero, configuration: cfg) - _wkwebview.navigationDelegate = _coordinator - guard let path: String = Bundle.main.path(forResource: "call", ofType: "html", inDirectory: "www") else { - logger.error("WebRTCView.makeUIView call.html not found") - return _wkwebview - } - let localHTMLUrl = URL(fileURLWithPath: path, isDirectory: false) - _wkwebview.loadFileURL(localHTMLUrl, allowingReadAccessTo: localHTMLUrl) - return _wkwebview - } - - func updateUIView(_ webView: WKWebView, context: Context) { - logger.debug("WebRTCView.updateUIView") - } -} - - - -struct CallView: View { - @State var coordinator: WebRTCCoordinator? = nil - @State var commandStr = "" - @FocusState private var keyboardVisible: Bool - - var body: some View { - VStack(spacing: 30) { - WebRTCView(coordinator: $coordinator).frame(maxHeight: 260) - TextEditor(text: $commandStr) - .focused($keyboardVisible) - .disableAutocorrection(true) - .textInputAutocapitalization(.never) - .padding(.horizontal, 5) - .padding(.top, 2) - .frame(height: 112) - .overlay( - RoundedRectangle(cornerRadius: 10) - .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true) - ) - HStack(spacing: 20) { - Button("Copy") { - UIPasteboard.general.string = commandStr - } - Button("Paste") { - commandStr = UIPasteboard.general.string ?? "" - } - Button("Clear") { - commandStr = "" - } - Button("Send") { - do { - let command = try jsonDecoder.decode(WCallCommand.self, from: commandStr.data(using: .utf8)!) - if let c = coordinator { - Task { - let resp = await c.processCommand(command: command) - print(encodeJSON(resp)) - } - } - } catch { - print(error) - } - } - } - HStack(spacing: 20) { - Button("Capabilities") { - - } - Button("Start") { - if let c = coordinator { - Task { - let resp = await c.processCommand(command: .start(media: .video)) - print(encodeJSON(resp)) - } - } - } - Button("Accept") { - - } - Button("Answer") { - - } - Button("ICE") { - - } - Button("End") { - - } - } - } - } -} - -struct CallView_Previews: PreviewProvider { - static var previews: some View { - CallView() - } -} diff --git a/apps/ios/Shared/Views/Call/WebRTC.swift b/apps/ios/Shared/Views/Call/WebRTC.swift index 7af5b90cca..d05833c859 100644 --- a/apps/ios/Shared/Views/Call/WebRTC.swift +++ b/apps/ios/Shared/Views/Call/WebRTC.swift @@ -7,23 +7,109 @@ // import Foundation +import SwiftUI + +class Call: Equatable { + static func == (lhs: Call, rhs: Call) -> Bool { + lhs.contact.apiId == rhs.contact.apiId + } + + var contact: Contact + var callState: CallState + var localMedia: CallMediaType + var localCapabilities: CallCapabilities? + var peerMedia: CallMediaType? + var sharedKey: String? + var audioEnabled: Bool + var videoEnabled: Bool + + init( + contact: Contact, + callState: CallState, + localMedia: CallMediaType, + localCapabilities: CallCapabilities? = nil, + peerMedia: CallMediaType? = nil, + sharedKey: String? = nil, + audioEnabled: Bool? = nil, + videoEnabled: Bool? = nil + ) { + self.contact = contact + self.callState = callState + self.localMedia = localMedia + self.localCapabilities = localCapabilities + self.peerMedia = peerMedia + self.sharedKey = sharedKey + self.audioEnabled = audioEnabled ?? true + self.videoEnabled = videoEnabled ?? (localMedia == .video) + } + + func copy( + contact: Contact? = nil, + callState: CallState? = nil, + localMedia: CallMediaType? = nil, + localCapabilities: CallCapabilities? = nil, + peerMedia: CallMediaType? = nil, + sharedKey: String? = nil, + audioEnabled: Bool? = nil, + videoEnabled: Bool? = nil + ) -> Call { + Call ( + contact: contact ?? self.contact, + callState: callState ?? self.callState, + localMedia: localMedia ?? self.localMedia, + localCapabilities: localCapabilities ?? self.localCapabilities, + peerMedia: peerMedia ?? self.peerMedia, + sharedKey: sharedKey ?? self.sharedKey, + audioEnabled: audioEnabled ?? self.audioEnabled, + videoEnabled: videoEnabled ?? self.videoEnabled + ) + } + + var encrypted: Bool { + (localCapabilities?.encryption ?? false) && sharedKey != nil + } +} + +enum CallState { + case waitCapabilities + case invitationSent + case invitationReceived + case offerSent + case offerReceived + case negotiated + case connected + + var text: LocalizedStringKey { + switch self { + case .waitCapabilities: return "starting..." + case .invitationSent: return "waiting for answer..." + case .invitationReceived: return "starting..." + case .offerSent: return "waiting for confirmation..." + case .offerReceived: return "received answer..." + case .negotiated: return "connecting..." + case .connected: return "connected" + } + } +} struct WVAPICall: Encodable { - var corrId: Int + var corrId: Int? = nil var command: WCallCommand } -struct WVAPIMessage: Decodable { +struct WVAPIMessage: Equatable, Decodable { var corrId: Int? var resp: WCallResponse + var command: WCallCommand? } -enum WCallCommand { +enum WCallCommand: Equatable, Encodable, Decodable { case capabilities case start(media: CallMediaType, aesKey: String? = nil) case accept(offer: String, iceCandidates: [String], media: CallMediaType, aesKey: String? = nil) case answer(answer: String, iceCandidates: [String]) case ice(iceCandidates: [String]) + case media(media: CallMediaType, enable: Bool) case end enum CodingKeys: String, CodingKey { @@ -33,10 +119,23 @@ enum WCallCommand { case offer case answer case iceCandidates + case enable + } + + var cmdType: String { + get { + switch self { + case .capabilities: return "capabilities" + case .start: return "start" + case .accept: return "accept" + case .answer: return "answer" + case .ice: return "ice" + case .media: return "media" + case .end: return "end" + } + } } -} -extension WCallCommand: Encodable { func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { @@ -59,14 +158,15 @@ extension WCallCommand: Encodable { case let .ice(iceCandidates): try container.encode("ice", forKey: .type) try container.encode(iceCandidates, forKey: .iceCandidates) + case let .media(media, enable): + try container.encode("media", forKey: .type) + try container.encode(media, forKey: .media) + try container.encode(enable, forKey: .enable) case .end: try container.encode("end", forKey: .type) } } -} -// This protocol is only needed for debugging -extension WCallCommand: Decodable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(String.self, forKey: CodingKeys.type) @@ -90,6 +190,10 @@ extension WCallCommand: Decodable { case "ice": let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates) self = .ice(iceCandidates: iceCandidates) + case "media": + let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media) + let enable = try container.decode(Bool.self, forKey: CodingKeys.enable) + self = .media(media: media, enable: enable) case "end": self = .end default: @@ -99,11 +203,11 @@ extension WCallCommand: Decodable { } -enum WCallResponse { +enum WCallResponse: Equatable, Decodable { case capabilities(capabilities: CallCapabilities) - case offer(offer: String, iceCandidates: [String]) + case offer(offer: String, iceCandidates: [String], capabilities: CallCapabilities) // TODO remove accept, it is needed for debugging - case accept(offer: String, iceCandidates: [String], media: CallMediaType, aesKey: String? = nil) +// case accept(offer: String, iceCandidates: [String], media: CallMediaType, aesKey: String? = nil) case answer(answer: String, iceCandidates: [String]) case ice(iceCandidates: [String]) case connection(state: ConnectionState) @@ -124,9 +228,24 @@ enum WCallResponse { case media case aesKey } -} -extension WCallResponse: Decodable { + var respType: String { + get { + switch self { + case .capabilities: return("capabilities") + case .offer: return("offer") +// case .accept: return("accept") + case .answer: return("answer (TODO remove)") + case .ice: return("ice") + case .connection: return("connection") + case .ended: return("ended") + case .ok: return("ok") + case .error: return("error") + case .invalid: return("invalid") + } + } + } + init(from decoder: Decoder) throws { do { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -138,14 +257,8 @@ extension WCallResponse: Decodable { case "offer": let offer = try container.decode(String.self, forKey: CodingKeys.offer) let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates) - self = .offer(offer: offer, iceCandidates: iceCandidates) - // TODO remove accept - case "accept": - let offer = try container.decode(String.self, forKey: CodingKeys.offer) - let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates) - let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media) - let aesKey = try? container.decode(String.self, forKey: CodingKeys.aesKey) - self = .accept(offer: offer, iceCandidates: iceCandidates, media: media, aesKey: aesKey) + let capabilities = try container.decode(CallCapabilities.self, forKey: CodingKeys.capabilities) + self = .offer(offer: offer, iceCandidates: iceCandidates, capabilities: capabilities) case "answer": let answer = try container.decode(String.self, forKey: CodingKeys.answer) let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates) @@ -172,56 +285,42 @@ extension WCallResponse: Decodable { } } -// This protocol is only needed for debugging -extension WCallResponse: Encodable { - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - switch self { - case .capabilities: - try container.encode("capabilities", forKey: .type) - case let .offer(offer, iceCandidates): - try container.encode("offer", forKey: .type) - try container.encode(offer, forKey: .offer) - try container.encode(iceCandidates, forKey: .iceCandidates) - case let .accept(offer, iceCandidates, media, aesKey): - try container.encode("accept", forKey: .type) - try container.encode(offer, forKey: .offer) - try container.encode(iceCandidates, forKey: .iceCandidates) - try container.encode(media, forKey: .media) - try container.encode(aesKey, forKey: .aesKey) - case let .answer(answer, iceCandidates): - try container.encode("answer", forKey: .type) - try container.encode(answer, forKey: .answer) - try container.encode(iceCandidates, forKey: .iceCandidates) - case let .ice(iceCandidates): - try container.encode("ice", forKey: .type) - try container.encode(iceCandidates, forKey: .iceCandidates) - case let .connection(state): - try container.encode("connection", forKey: .type) - try container.encode(state, forKey: .state) - case .ended: - try container.encode("ended", forKey: .type) - case .ok: - try container.encode("ok", forKey: .type) - case let .error(message): - try container.encode("error", forKey: .type) - try container.encode(message, forKey: .message) - case let .invalid(type): - try container.encode(type, forKey: .type) - } - } -} +// This protocol is for debugging +//extension WCallResponse: Encodable { +// func encode(to encoder: Encoder) throws { +// var container = encoder.container(keyedBy: CodingKeys.self) +// switch self { +// case .capabilities: +// try container.encode("capabilities", forKey: .type) +// case let .offer(offer, iceCandidates, capabilities): +// try container.encode("offer", forKey: .type) +// try container.encode(offer, forKey: .offer) +// try container.encode(iceCandidates, forKey: .iceCandidates) +// try container.encode(capabilities, forKey: .capabilities) +// case let .answer(answer, iceCandidates): +// try container.encode("answer", forKey: .type) +// try container.encode(answer, forKey: .answer) +// try container.encode(iceCandidates, forKey: .iceCandidates) +// case let .ice(iceCandidates): +// try container.encode("ice", forKey: .type) +// try container.encode(iceCandidates, forKey: .iceCandidates) +// case let .connection(state): +// try container.encode("connection", forKey: .type) +// try container.encode(state, forKey: .state) +// case .ended: +// try container.encode("ended", forKey: .type) +// case .ok: +// try container.encode("ok", forKey: .type) +// case let .error(message): +// try container.encode("error", forKey: .type) +// try container.encode(message, forKey: .message) +// case let .invalid(type): +// try container.encode(type, forKey: .type) +// } +// } +//} -enum CallMediaType: String, Codable { - case video = "video" - case audio = "audio" -} - -struct CallCapabilities: Codable { - var encryption: Bool -} - -struct ConnectionState: Codable { +struct ConnectionState: Codable, Equatable { var connectionState: String var iceConnectionState: String var iceGatheringState: String diff --git a/apps/ios/Shared/Views/Call/WebRTCView.swift b/apps/ios/Shared/Views/Call/WebRTCView.swift new file mode 100644 index 0000000000..b97d0f2807 --- /dev/null +++ b/apps/ios/Shared/Views/Call/WebRTCView.swift @@ -0,0 +1,169 @@ +// +// WebRTCView.swift +// SimpleX (iOS) +// +// Created by Ian Davies on 29/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import WebKit + +class WebRTCCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { + var webViewReady: Binding + var webViewMsg: Binding + private var webView: WKWebView? + + internal init(webViewReady: Binding, webViewMsg: Binding) { + self.webViewReady = webViewReady + self.webViewMsg = webViewMsg + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + webView.allowsBackForwardNavigationGestures = false + self.webView = webView + webViewReady.wrappedValue = true + } + + // receive message from WKWebView + func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + logger.debug("WebRTCCoordinator.userContentController") + if let msgStr = message.body as? String, + let msg: WVAPIMessage = decodeJSON(msgStr) { + webViewMsg.wrappedValue = msg + } else { + logger.error("WebRTCCoordinator.userContentController: invalid message \(String(describing: message.body))") + } + } + + func sendCommand(command: WCallCommand) { + if let webView = webView { + logger.debug("WebRTCCoordinator.sendCommand") + let apiCmd = encodeJSON(WVAPICall(command: command)) + let js = "processCommand(\(apiCmd))" + webView.evaluateJavaScript(js) + } + } +} + +struct WebRTCView: UIViewRepresentable { + @Binding var coordinator: WebRTCCoordinator? + @Binding var webViewReady: Bool + @Binding var webViewMsg: WVAPIMessage? + + func makeCoordinator() -> WebRTCCoordinator { + WebRTCCoordinator(webViewReady: $webViewReady, webViewMsg: $webViewMsg) + } + + func makeUIView(context: Context) -> WKWebView { + let wkCoordinator = makeCoordinator() + DispatchQueue.main.async { coordinator = wkCoordinator } + + let wkController = WKUserContentController() + + let cfg = WKWebViewConfiguration() + cfg.userContentController = wkController + cfg.mediaTypesRequiringUserActionForPlayback = [] + cfg.allowsInlineMediaPlayback = true + + let source = "sendMessageToNative = (msg) => webkit.messageHandlers.webrtc.postMessage(JSON.stringify(msg))" + let script = WKUserScript(source: source, injectionTime: .atDocumentEnd, forMainFrameOnly: false) + wkController.addUserScript(script) + wkController.add(wkCoordinator, name: "webrtc") + + let wkWebView = WKWebView(frame: .zero, configuration: cfg) + wkWebView.navigationDelegate = wkCoordinator + guard let path: String = Bundle.main.path(forResource: "call", ofType: "html", inDirectory: "www") else { + logger.error("WebRTCView.makeUIView call.html not found") + return wkWebView + } + let localHTMLUrl = URL(fileURLWithPath: path, isDirectory: false) + wkWebView.loadFileURL(localHTMLUrl, allowingReadAccessTo: localHTMLUrl) + return wkWebView + } + + func updateUIView(_ webView: WKWebView, context: Context) { + logger.debug("WebRTCView.updateUIView") + } +} + +//struct CallViewDebug: View { +// @State var coordinator: WebRTCCoordinator? = nil +// @State var commandStr = "" +// @State private var webViewMsg: WCallResponse? = nil +// @FocusState private var keyboardVisible: Bool +// +// var body: some View { +// VStack(spacing: 30) { +// WebRTCView(coordinator: $coordinator, webViewMsg: $webViewMsg).frame(maxHeight: 260) +// .onChange(of: webViewMsg) { _ in +// if let resp = webViewMsg { +// commandStr = encodeJSON(resp) +// } +// } +// TextEditor(text: $commandStr) +// .focused($keyboardVisible) +// .disableAutocorrection(true) +// .textInputAutocapitalization(.never) +// .padding(.horizontal, 5) +// .padding(.top, 2) +// .frame(height: 112) +// .overlay( +// RoundedRectangle(cornerRadius: 10) +// .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true) +// ) +// HStack(spacing: 20) { +// Button("Copy") { +// UIPasteboard.general.string = commandStr +// } +// Button("Paste") { +// commandStr = UIPasteboard.general.string ?? "" +// } +// Button("Clear") { +// commandStr = "" +// } +// Button("Send") { +// do { +// if let c = coordinator, +// let command: WCallCommand = decodeJSON(commandStr) { +// c.sendCommand(command: command) +// } +// } catch { +// print(error) +// } +// } +// } +// HStack(spacing: 20) { +// Button("Capabilities") { +// +// } +// Button("Start") { +// if let c = coordinator { +// c.sendCommand(command: .start(media: .video)) +// } +// } +// Button("Accept") { +// +// } +// Button("Answer") { +// +// } +// Button("ICE") { +// +// } +// Button("End") { +// +// } +// } +// } +// } +//} +// +//struct CallViewDebug_Previews: PreviewProvider { +// static var previews: some View { +// CallViewDebug() +// } +//} diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 5cb4aad03e..6d549a92b8 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -22,6 +22,8 @@ struct ChatItemView: View { } } else if chatItem.isDeletedContent() { DeletedItemView(chatItem: chatItem, showMember: showMember) + } else if chatItem.isCall() { + FramedItemView(chatItem: chatItem, showMember: showMember, maxWidth: maxWidth) } } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 372df7434d..ec691f0cfd 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -14,8 +14,9 @@ struct ChatView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.colorScheme) var colorScheme @ObservedObject var chat: Chat - @State var composeState = ComposeState() - @State var deletingItem: ChatItem? = nil + @Binding var showCallView: Bool + @State private var composeState = ComposeState() + @State private var deletingItem: ChatItem? = nil @FocusState private var keyboardVisible: Bool @State private var showChatInfo = false @State private var showDeleteMessage = false @@ -105,10 +106,32 @@ struct ChatView: View { ChatInfoView(chat: chat, showChatInfo: $showChatInfo) } } + ToolbarItem(placement: .navigationBarTrailing) { + if case let .direct(contact) = cInfo { + HStack { + callButton(contact, .audio, imageName: "phone") + callButton(contact, .video, imageName: "video") + } + } + } } .navigationBarBackButtonHidden(true) } + private func callButton(_ contact: Contact, _ media: CallMediaType, imageName: String) -> some View { + Button { + chatModel.activeCall = Call( + contact: contact, + callState: .waitCapabilities, + localMedia: media + ) + showCallView = true + chatModel.callCommand = .capabilities + } label: { + Image(systemName: imageName) + } + } + private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat, showMember: Bool = false) -> some View { let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading return ChatItemView(chatItem: ci, showMember: showMember, maxWidth: maxWidth) @@ -166,7 +189,8 @@ struct ChatView: View { } if let di = deletingItem { if di.meta.editable { - Button("Delete for everyone",role: .destructive) { deleteMessage(.cidmBroadcast) + Button("Delete for everyone",role: .destructive) { + deleteMessage(.cidmBroadcast) } } } @@ -236,6 +260,7 @@ struct ChatView: View { struct ChatView_Previews: PreviewProvider { static var previews: some View { + @State var showCallView = false let chatModel = ChatModel() chatModel.chatId = "@1" chatModel.chatItems = [ @@ -249,7 +274,7 @@ struct ChatView_Previews: PreviewProvider { ChatItem.getSample(8, .directSnd, .now, "👍👍👍👍"), ChatItem.getSample(9, .directSnd, .now, "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.") ] - return ChatView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])) + return ChatView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []), showCallView: $showCallView) .environmentObject(chatModel) } } diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 8d96361479..dd2a16cf91 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -11,6 +11,7 @@ import SwiftUI struct ChatListNavLink: View { @EnvironmentObject var chatModel: ChatModel @State var chat: Chat + @Binding var showCallView: Bool @State private var showContactRequestDialog = false var body: some View { @@ -27,7 +28,7 @@ struct ChatListNavLink: View { } private func chatView() -> some View { - ChatView(chat: chat) + ChatView(chat: chat, showCallView: $showCallView) .onAppear { do { let cInfo = chat.chatInfo @@ -252,19 +253,20 @@ struct ChatListNavLink: View { struct ChatListNavLink_Previews: PreviewProvider { static var previews: some View { @State var chatId: String? = "@1" + @State var showCallView = false return Group { ChatListNavLink(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")] - )) + ), showCallView: $showCallView) ChatListNavLink(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")] - )) + ), showCallView: $showCallView) ChatListNavLink(chat: Chat( chatInfo: ChatInfo.sampleData.contactRequest, chatItems: [] - )) + ), showCallView: $showCallView) } .previewLayout(.fixed(width: 360, height: 80)) } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 094f916bda..ff9ba5bee5 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -13,6 +13,7 @@ struct ChatListView: View { // not really used in this view @State private var showSettings = false @State private var searchText = "" + @State private var showCallView = false @AppStorage("pendingConnections") private var pendingConnections = true var user: User @@ -24,7 +25,7 @@ struct ChatListView: View { ChatHelp(showSettings: $showSettings) } else { ForEach(filteredChats()) { chat in - ChatListNavLink(chat: chat) + ChatListNavLink(chat: chat, showCallView: $showCallView) .padding(.trailing, -16) } } @@ -53,6 +54,29 @@ struct ChatListView: View { NewChatButton() } } + .fullScreenCover(isPresented: $showCallView) { + ActiveCallView(showCallView: $showCallView) + } + .onChange(of: showCallView) { _ in + if (showCallView) { return } + if let call = chatModel.activeCall { + Task { + do { + try await apiEndCall(call.contact) + } catch { + logger.error("ChatListView apiEndCall error: \(error.localizedDescription)") + } + } + } + chatModel.callCommand = .end + } + .onChange(of: chatModel.activeCallInvitation) { _ in + if let contactRef = chatModel.activeCallInvitation, + case let .direct(contact) = chatModel.getChat(contactRef.id)?.chatInfo, + let invitation = chatModel.callInvitations.removeValue(forKey: contactRef.id) { + answerCallAlert(contact, invitation) + } + } } .navigationViewStyle(.stack) @@ -99,6 +123,29 @@ struct ChatListView: View { return Alert(title: Text("Error: URL is invalid")) } } + + private func answerCallAlert(_ contact: Contact, _ invitation: CallInvitation) { + AlertManager.shared.showAlert(Alert( + title: Text("Incoming call"), + primaryButton: .default(Text("Answer")) { + if chatModel.activeCallInvitation == nil { + DispatchQueue.main.async { + AlertManager.shared.showAlertMsg(title: "Call already ended!") + } + } else { + chatModel.activeCallInvitation = nil + chatModel.activeCall = Call( + contact: contact, + callState: .invitationReceived, + localMedia: invitation.peerMedia + ) + showCallView = true + chatModel.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey) + } + }, + secondaryButton: .cancel() + )) + } } struct ChatListView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 3e5bdf0d5b..996560adb4 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -143,12 +143,12 @@ struct SettingsView: View { notificationsToggle(token) } } - NavigationLink { - CallView() - .frame(maxHeight: .infinity, alignment: .top) - } label: { +// NavigationLink { +// CallViewDebug() +// .frame(maxHeight: .infinity, alignment: .top) +// } label: { Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))") - } +// } } } .navigationTitle("Your settings") diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 04848db8f0..2b4dea2111 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* Begin PBXBuildFile section */ - 3C714777281C081000CB4D4B /* CallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C714776281C081000CB4D4B /* CallView.swift */; }; + 3C714777281C081000CB4D4B /* WebRTCView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C714776281C081000CB4D4B /* WebRTCView.swift */; }; 3C71477A281C0F6800CB4D4B /* www in Resources */ = {isa = PBXBuildFile; fileRef = 3C714779281C0F6800CB4D4B /* www */; }; 3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */; }; 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; }; @@ -32,6 +32,9 @@ 5C545C8F2812A834007A6B96 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C8A2812A833007A6B96 /* libffi.a */; }; 5C545C902812A834007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C8B2812A833007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK-ghc8.10.7.a */; }; 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; }; + 5C5E5D3B2824468B00B0488A /* ActiveCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5E5D3A2824468B00B0488A /* ActiveCallView.swift */; }; + 5C5E5D3D282447AB00B0488A /* CallTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5E5D3C282447AB00B0488A /* CallTypes.swift */; }; + 5C5E5D3E282447BF00B0488A /* CallTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5E5D3C282447AB00B0488A /* CallTypes.swift */; }; 5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */; }; 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */; }; 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; }; @@ -134,7 +137,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 3C714776281C081000CB4D4B /* CallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallView.swift; sourceTree = ""; }; + 3C714776281C081000CB4D4B /* WebRTCView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTCView.swift; sourceTree = ""; }; 3C714779281C0F6800CB4D4B /* www */ = {isa = PBXFileReference; lastKnownFileType = folder; name = www; path = ../android/app/src/main/assets/www; sourceTree = ""; }; 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteToConnectView.swift; sourceTree = ""; }; 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = ""; }; @@ -161,6 +164,8 @@ 5C545C8A2812A833007A6B96 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; 5C545C8B2812A833007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK-ghc8.10.7.a"; sourceTree = ""; }; 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = ""; }; + 5C5E5D3A2824468B00B0488A /* ActiveCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveCallView.swift; sourceTree = ""; }; + 5C5E5D3C282447AB00B0488A /* CallTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallTypes.swift; sourceTree = ""; }; 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = ""; }; 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileImage.swift; sourceTree = ""; }; 5C6AD81227A834E300348BD7 /* NewChatButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatButton.swift; sourceTree = ""; }; @@ -268,8 +273,9 @@ 3C714775281C080100CB4D4B /* Call */ = { isa = PBXGroup; children = ( - 3C714776281C081000CB4D4B /* CallView.swift */, + 3C714776281C081000CB4D4B /* WebRTCView.swift */, 5C9D13A2282187BB00AB8B43 /* WebRTC.swift */, + 5C5E5D3A2824468B00B0488A /* ActiveCallView.swift */, ); path = Call; sourceTree = ""; @@ -466,6 +472,7 @@ 5CDCAD5228186F9500503DA2 /* GroupDefaults.swift */, 5CDCAD7228188CFF00503DA2 /* ChatTypes.swift */, 5CDCAD7428188D2900503DA2 /* APITypes.swift */, + 5C5E5D3C282447AB00B0488A /* CallTypes.swift */, 5C9FD96A27A56D4D0075386C /* JSON.swift */, 5CDCAD7D2818941F00503DA2 /* API.swift */, 5CDCAD80281A7E2700503DA2 /* Notifications.swift */, @@ -691,10 +698,12 @@ 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */, 5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */, 5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */, + 5C5E5D3B2824468B00B0488A /* ActiveCallView.swift in Sources */, 5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */, 5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */, - 3C714777281C081000CB4D4B /* CallView.swift in Sources */, + 3C714777281C081000CB4D4B /* WebRTCView.swift in Sources */, 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */, + 5C5E5D3D282447AB00B0488A /* CallTypes.swift in Sources */, 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */, 5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */, 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */, @@ -716,6 +725,7 @@ buildActionMask = 2147483647; files = ( 5CDCAD7928188FD600503DA2 /* APITypes.swift in Sources */, + 5C5E5D3E282447BF00B0488A /* CallTypes.swift in Sources */, 5CDCAD5828187C7500503DA2 /* dummy.m in Sources */, 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */, 5CDCAD82281A7E2700503DA2 /* Notifications.swift in Sources */, diff --git a/packages/simplex-chat-webrtc/src/call.html b/packages/simplex-chat-webrtc/src/call.html index ebe63e1a3b..fd3019e8eb 100644 --- a/packages/simplex-chat-webrtc/src/call.html +++ b/packages/simplex-chat-webrtc/src/call.html @@ -4,8 +4,8 @@ - - + +
diff --git a/packages/simplex-chat-webrtc/src/call.ts b/packages/simplex-chat-webrtc/src/call.ts index 9dee8a612b..5c0678b6e2 100644 --- a/packages/simplex-chat-webrtc/src/call.ts +++ b/packages/simplex-chat-webrtc/src/call.ts @@ -1,24 +1,22 @@ // Inspired by // https://github.com/webrtc/samples/blob/gh-pages/src/content/insertable-streams/endtoend-encryption -// type WCallMessage = WCallCommand | WCallResponse - -interface WebViewAPICall { - corrId: number +interface WVAPICall { + corrId?: number command: WCallCommand } -// TODO remove WCallCommand from resp type -interface WebViewMessage { +interface WVApiMessage { corrId?: number - resp: WCallResponse | WCallCommand + resp: WCallResponse + command?: WCallCommand } -type WCallCommand = WCCapabilities | WCStartCall | WCAcceptOffer | WCallAnswer | WCallIceCandidates | WCEndCall +type WCallCommand = WCCapabilities | WCStartCall | WCAcceptOffer | WCallAnswer | WCallIceCandidates | WCEnableMedia | WCEndCall type WCallResponse = WRCapabilities | WCallOffer | WCallAnswer | WCallIceCandidates | WRConnection | WRCallEnded | WROk | WRError -type WCallCommandTag = "capabilities" | "start" | "accept" | "answer" | "ice" | "end" +type WCallCommandTag = "capabilities" | "start" | "accept" | "answer" | "ice" | "media" | "end" type WCallResponseTag = "capabilities" | "offer" | "answer" | "ice" | "connection" | "ended" | "ok" | "error" @@ -61,6 +59,7 @@ interface WCallOffer extends IWCallResponse { type: "offer" offer: string // JSON string for RTCSessionDescriptionInit iceCandidates: string[] // JSON strings for RTCIceCandidateInit + capabilities: CallCapabilities } interface WCallAnswer extends IWCallCommand, IWCallResponse { @@ -74,6 +73,12 @@ interface WCallIceCandidates extends IWCallCommand, IWCallResponse { iceCandidates: string[] // JSON strings for RTCIceCandidateInit } +interface WCEnableMedia extends IWCallCommand { + type: "media" + media: CallMediaType + enable: boolean +} + interface WRCapabilities extends IWCallResponse { type: "capabilities" capabilities: CallCapabilities @@ -125,7 +130,7 @@ const keyAlgorithm: AesKeyAlgorithm = { const keyUsages: KeyUsage[] = ["encrypt", "decrypt"] -let pc: RTCPeerConnection | undefined +let activeCall: Call | undefined const IV_LENGTH = 12 @@ -138,6 +143,8 @@ const initialPlainTextRequired = { interface Call { connection: RTCPeerConnection iceCandidates: Promise // JSON strings for RTCIceCandidate + localMedia: CallMediaType + localStream: MediaStream } interface CallConfig { @@ -217,7 +224,7 @@ async function initializeCall(config: CallConfig, mediaType: CallMediaType, aesK } }) - return {connection: conn, iceCandidates} + return {connection: conn, iceCandidates, localMedia: mediaType, localStream} function connectionStateChange() { sendMessageToNative({ @@ -235,20 +242,18 @@ async function initializeCall(config: CallConfig, mediaType: CallMediaType, aesK conn.removeEventListener("connectionstatechange", connectionStateChange) sendMessageToNative({resp: {type: "ended"}}) conn.close() - pc = undefined + activeCall = undefined resetVideoElements() } } } -function sendMessageToNative(msg: WebViewMessage) { - console.log(JSON.stringify(msg)) -} +var sendMessageToNative = (msg: WVApiMessage) => console.log(JSON.stringify(msg)) -// TODO remove WCallCommand from result type -async function processCommand(body: WebViewAPICall): Promise { - const {command, corrId} = body - let resp: WCallResponse | WCallCommand +async function processCommand(body: WVAPICall): Promise { + const {corrId, command} = body + const pc = activeCall?.connection + let resp: WCallResponse try { switch (command.type) { case "capabilities": @@ -257,39 +262,47 @@ async function processCommand(body: WebViewAPICall): Promise { break case "start": console.log("starting call") - if (pc) { + if (activeCall) { resp = {type: "error", message: "start: call already started"} } else if (!supportsInsertableStreams() && command.aesKey) { resp = {type: "error", message: "start: encryption is not supported"} } else { + const encryption = supportsInsertableStreams() const {media, aesKey} = command - const call = await initializeCall(defaultCallConfig(!!aesKey), media, aesKey) - const {connection, iceCandidates} = call - pc = connection + activeCall = await initializeCall(defaultCallConfig(encryption && !!aesKey), media, encryption ? aesKey : undefined) + const pc = activeCall.connection const offer = await pc.createOffer() await pc.setLocalDescription(offer) // for debugging, returning the command for callee to use - resp = {type: "accept", offer: JSON.stringify(offer), iceCandidates: await iceCandidates, media, aesKey} - // resp = {type: "offer", offer, iceCandidates: await iceCandidates} + // resp = {type: "accept", offer: JSON.stringify(offer), iceCandidates: await iceCandidates, media, aesKey} + resp = { + type: "offer", + offer: JSON.stringify(offer), + iceCandidates: await activeCall.iceCandidates, + capabilities: {encryption}, + } } break case "accept": - if (pc) { + if (activeCall) { resp = {type: "error", message: "accept: call already started"} } else if (!supportsInsertableStreams() && command.aesKey) { resp = {type: "error", message: "accept: encryption is not supported"} } else { const offer = JSON.parse(command.offer) const remoteIceCandidates = command.iceCandidates.map((c) => JSON.parse(c)) - const call = await initializeCall(defaultCallConfig(!!command.aesKey), command.media, command.aesKey) - const {connection, iceCandidates} = call - pc = connection + activeCall = await initializeCall(defaultCallConfig(!!command.aesKey), command.media, command.aesKey) + const pc = activeCall.connection await pc.setRemoteDescription(new RTCSessionDescription(offer)) const answer = await pc.createAnswer() await pc.setLocalDescription(answer) addIceCandidates(pc, remoteIceCandidates) // same as command for caller to use - resp = {type: "answer", answer: JSON.stringify(answer), iceCandidates: await iceCandidates} + resp = { + type: "answer", + answer: JSON.stringify(answer), + iceCandidates: await activeCall.iceCandidates, + } } break case "answer": @@ -316,10 +329,20 @@ async function processCommand(body: WebViewAPICall): Promise { resp = {type: "error", message: "ice: call not started"} } break + case "media": + if (!activeCall) { + resp = {type: "error", message: "media: call not started"} + } else if (activeCall.localMedia == CallMediaType.Audio && command.media == CallMediaType.Video) { + resp = {type: "error", message: "media: no video"} + } else { + enableMedia(activeCall.localStream, command.media, command.enable) + resp = {type: "ok"} + } + break case "end": if (pc) { pc.close() - pc = undefined + activeCall = undefined resetVideoElements() resp = {type: "ok"} } else { @@ -333,7 +356,7 @@ async function processCommand(body: WebViewAPICall): Promise { } catch (e) { resp = {type: "error", message: (e as Error).message} } - const apiResp = {resp, corrId} + const apiResp = {corrId, resp, command} sendMessageToNative(apiResp) return apiResp } @@ -460,14 +483,10 @@ function getVideoElements(): VideoElements | undefined { // } // } -// what does it do? -// function toggleVideo(b) { -// if (b == "true") { -// localStream.getVideoTracks()[0].enabled = true -// } else { -// localStream.getVideoTracks()[0].enabled = false -// } -// } +function enableMedia(s: MediaStream, media: CallMediaType, enable: boolean) { + const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks() + for (const t of tracks) t.enabled = enable +} /* Stream Transforms */ function setupPeerTransform( diff --git a/packages/simplex-chat-webrtc/src/style.css b/packages/simplex-chat-webrtc/src/style.css index 624e96dfe7..7b18648d2d 100644 --- a/packages/simplex-chat-webrtc/src/style.css +++ b/packages/simplex-chat-webrtc/src/style.css @@ -5,6 +5,7 @@ html, body { padding: 0; margin: 0; + background-color: black; } #remote-video-stream { position: absolute; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 9b2912657e..f17adb82f8 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -453,8 +453,13 @@ processChatCommand = \case APISendCallExtraInfo contactId rtcExtraInfo -> -- any call party withCurrentCall contactId $ \_ ct call@Call {callId, callState} -> case callState of + CallOfferSent {localCallType, peerCallType, localCallSession, sharedKey} -> do + -- TODO update the list of ice servers in localCallSession + _ <- sendDirectContactMessage ct (XCallExtra callId CallExtraInfo {rtcExtraInfo}) + let callState' = CallOfferSent {localCallType, peerCallType, localCallSession, sharedKey} + pure $ Just call {callState = callState'} CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession, sharedKey} -> do - -- TODO update the list of ice servers + -- TODO update the list of ice servers in localCallSession _ <- sendDirectContactMessage ct (XCallExtra callId CallExtraInfo {rtcExtraInfo}) let callState' = CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession, sharedKey} pure $ Just call {callState = callState'} @@ -1630,8 +1635,13 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage xCallExtra ct callId CallExtraInfo {rtcExtraInfo} msg msgMeta = do msgCurrentCall ct callId "x.call.extra" msg msgMeta $ \call -> case callState call of + CallOfferReceived {localCallType, peerCallType, peerCallSession, sharedKey} -> do + -- TODO update the list of ice servers in peerCallSession + let callState' = CallOfferReceived {localCallType, peerCallType, peerCallSession, sharedKey} + toView $ CRCallExtraInfo ct rtcExtraInfo + pure (Just call {callState = callState'}, Nothing) CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession, sharedKey} -> do - -- TODO update the list of ice servers + -- TODO update the list of ice servers in peerCallSession let callState' = CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession, sharedKey} toView $ CRCallExtraInfo ct rtcExtraInfo pure (Just call {callState = callState'}, Nothing) @@ -1641,13 +1651,10 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage -- to any call party xCallEnd :: Contact -> CallId -> RcvMessage -> MsgMeta -> m () - xCallEnd ct@Contact {contactId} callId msg msgMeta = + xCallEnd ct callId msg msgMeta = msgCurrentCall ct callId "x.call.end" msg msgMeta $ \Call {chatItemId} -> do toView $ CRCallEnded ct - CChatItem msgDir _ <- withStore $ \st -> getDirectChatItem st userId contactId chatItemId - pure $ case msgDir of - SMDSnd -> (Nothing, Just . ACIContent SMDSnd $ CISndCall CISCallEnded 0) - SMDRcv -> (Nothing, Just . ACIContent SMDRcv $ CIRcvCall CISCallEnded 0) + (Nothing,) <$> callStatusItemContent userId ct chatItemId WCSDisconnected msgCurrentCall :: Contact -> CallId -> Text -> RcvMessage -> MsgMeta -> (Call -> m (Maybe Call, Maybe ACIContent)) -> m () msgCurrentCall ct@Contact {contactId = ctId'} callId' eventName RcvMessage {msgId} msgMeta action = do From fcb5c69281f61bb5a70ce1f48b1cf93fa6eb0cdd Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 7 May 2022 13:23:20 +0100 Subject: [PATCH 62/95] android: types and messages for webrtc calls (#609) * android: webrtc calls * string localizations, more types --- .../java/chat/simplex/app/MainActivity.kt | 4 +- .../java/chat/simplex/app/model/ChatModel.kt | 164 +++++++++--------- .../java/chat/simplex/app/model/SimpleXAPI.kt | 4 +- .../chat/simplex/app/views/call/CallView.kt | 15 +- .../chat/simplex/app/views/call/WebRTC.kt | 62 +++++-- .../app/views/chat/item/ChatItemView.kt | 2 + .../app/views/chatlist/ChatListNavLinkView.kt | 3 +- .../app/src/main/res/values-ru/strings.xml | 17 ++ .../app/src/main/res/values/strings.xml | 17 ++ apps/ios/Shared/Model/Shared/ChatTypes.swift | 18 +- apps/ios/Shared/Views/Call/WebRTC.swift | 12 +- apps/ios/Shared/Views/Call/WebRTCView.swift | 19 +- 12 files changed, 195 insertions(+), 142 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt index 09160bd550..d1b106b29f 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt @@ -17,8 +17,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.lifecycle.AndroidViewModel import androidx.work.* -import chat.simplex.app.model.ChatModel -import chat.simplex.app.model.NtfManager +import chat.simplex.app.model.* import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.SplashView import chat.simplex.app.views.WelcomeView @@ -29,7 +28,6 @@ import chat.simplex.app.views.helpers.* import chat.simplex.app.views.newchat.connectViaUri import chat.simplex.app.views.newchat.withUriAction import java.util.concurrent.TimeUnit - //import kotlinx.serialization.decodeFromString class MainActivity: ComponentActivity() { diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index a3fd1ffc45..56e69929b6 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.text.style.TextDecoration import chat.simplex.app.R import chat.simplex.app.ui.theme.SecretColor import chat.simplex.app.ui.theme.SimplexBlue +import chat.simplex.app.views.call.* import chat.simplex.app.views.helpers.generalGetString import kotlinx.datetime.* import kotlinx.serialization.* @@ -19,23 +20,29 @@ import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.* class ChatModel(val controller: ChatController) { - var currentUser = mutableStateOf(null) - var userCreated = mutableStateOf(null) - var chats = mutableStateListOf() - var chatId = mutableStateOf(null) - var chatItems = mutableStateListOf() + val currentUser = mutableStateOf(null) + val userCreated = mutableStateOf(null) + val chats = mutableStateListOf() + val chatId = mutableStateOf(null) + val chatItems = mutableStateListOf() var connReqInvitation: String? = null - var terminalItems = mutableStateListOf() - var userAddress = mutableStateOf(null) - var userSMPServers = mutableStateOf<(List)?>(null) + val terminalItems = mutableStateListOf() + val userAddress = mutableStateOf(null) + val userSMPServers = mutableStateOf<(List)?>(null) // set when app opened from external intent - var clearOverlays = mutableStateOf(false) + val clearOverlays = mutableStateOf(false) // set when app is opened via contact or invitation URI - var appOpenUrl = mutableStateOf(null) - var runServiceInBackground = mutableStateOf(true) + val appOpenUrl = mutableStateOf(null) + val runServiceInBackground = mutableStateOf(true) + + // current WebRTC call + val callInvitations = mutableStateMapOf() + val activeCallInvitation = mutableStateOf(null) + val activeCall = mutableStateOf(null) + val callCommand = mutableStateOf(null) fun updateUserProfile(profile: Profile) { val user = currentUser.value @@ -654,6 +661,13 @@ data class ChatItem ( else -> false } + val isCall: Boolean get() = + when (content) { + is CIContent.SndCall -> true + is CIContent.RcvCall -> true + else -> false + } + companion object { fun getSampleData( id: Long = 1, @@ -709,26 +723,16 @@ data class ChatItem ( @Serializable sealed class CIDirection { - abstract val sent: Boolean + @Serializable @SerialName("directSnd") class DirectSnd: CIDirection() + @Serializable @SerialName("directRcv") class DirectRcv: CIDirection() + @Serializable @SerialName("groupSnd") class GroupSnd: CIDirection() + @Serializable @SerialName("groupRcv") class GroupRcv(val groupMember: GroupMember): CIDirection() - @Serializable @SerialName("directSnd") - class DirectSnd: CIDirection() { - override val sent get() = true - } - - @Serializable @SerialName("directRcv") - class DirectRcv: CIDirection() { - override val sent get() = false - } - - @Serializable @SerialName("groupSnd") - class GroupSnd: CIDirection() { - override val sent get() = true - } - - @Serializable @SerialName("groupRcv") - class GroupRcv(val groupMember: GroupMember): CIDirection() { - override val sent get() = false + val sent: Boolean get() = when(this) { + is DirectSnd -> true + is DirectRcv -> false + is GroupSnd -> true + is GroupRcv -> false } } @@ -775,23 +779,12 @@ fun getTimestampText(t: Instant): String { @Serializable sealed class CIStatus { - @Serializable @SerialName("sndNew") - class SndNew: CIStatus() - - @Serializable @SerialName("sndSent") - class SndSent: CIStatus() - - @Serializable @SerialName("sndErrorAuth") - class SndErrorAuth: CIStatus() - - @Serializable @SerialName("sndError") - class SndError(val agentError: AgentErrorType): CIStatus() - - @Serializable @SerialName("rcvNew") - class RcvNew: CIStatus() - - @Serializable @SerialName("rcvRead") - class RcvRead: CIStatus() + @Serializable @SerialName("sndNew") class SndNew: CIStatus() + @Serializable @SerialName("sndSent") class SndSent: CIStatus() + @Serializable @SerialName("sndErrorAuth") class SndErrorAuth: CIStatus() + @Serializable @SerialName("sndError") class SndError(val agentError: AgentErrorType): CIStatus() + @Serializable @SerialName("rcvNew") class RcvNew: CIStatus() + @Serializable @SerialName("rcvRead") class RcvRead: CIStatus() } @Serializable @@ -806,29 +799,22 @@ interface ItemContent { @Serializable sealed class CIContent: ItemContent { - abstract override val text: String abstract val msgContent: MsgContent? - @Serializable @SerialName("sndMsgContent") - class SndMsgContent(override val msgContent: MsgContent): CIContent() { - override val text get() = msgContent.text - } + @Serializable @SerialName("sndMsgContent") class SndMsgContent(override val msgContent: MsgContent): CIContent() + @Serializable @SerialName("rcvMsgContent") class RcvMsgContent(override val msgContent: MsgContent): CIContent() + @Serializable @SerialName("sndDeleted") class SndDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent get() = null } + @Serializable @SerialName("rcvDeleted") class RcvDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent get() = null } + @Serializable @SerialName("sndCall") class SndCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent get() = null } + @Serializable @SerialName("rcvCall") class RcvCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent get() = null } - @Serializable @SerialName("rcvMsgContent") - class RcvMsgContent(override val msgContent: MsgContent): CIContent() { - override val text get() = msgContent.text - } - - @Serializable @SerialName("sndDeleted") - class SndDeleted(val deleteMode: CIDeleteMode): CIContent() { - override val text get() = generalGetString(R.string.deleted_description) - override val msgContent get() = null - } - - @Serializable @SerialName("rcvDeleted") - class RcvDeleted(val deleteMode: CIDeleteMode): CIContent() { - override val text get() = generalGetString(R.string.deleted_description) - override val msgContent get() = null + override val text: String get() = when(this) { + is SndMsgContent -> msgContent.text + is RcvMsgContent -> msgContent.text + is SndDeleted -> generalGetString(R.string.deleted_description) + is RcvDeleted -> generalGetString(R.string.deleted_description) + is SndCall -> status.text(duration) + is RcvCall -> status.text(duration) } } @@ -904,20 +890,11 @@ enum class CIFileStatus { sealed class MsgContent { abstract val text: String - @Serializable(with = MsgContentSerializer::class) - class MCText(override val text: String): MsgContent() - - @Serializable(with = MsgContentSerializer::class) - class MCLink(override val text: String, val preview: LinkPreview): MsgContent() - - @Serializable(with = MsgContentSerializer::class) - class MCImage(override val text: String, val image: String): MsgContent() - - @Serializable(with = MsgContentSerializer::class) - class MCFile(override val text: String): MsgContent() - - @Serializable(with = MsgContentSerializer::class) - class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent() + @Serializable(with = MsgContentSerializer::class) class MCText(override val text: String): MsgContent() + @Serializable(with = MsgContentSerializer::class) class MCLink(override val text: String, val preview: LinkPreview): MsgContent() + @Serializable(with = MsgContentSerializer::class) class MCImage(override val text: String, val image: String): MsgContent() + @Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent() + @Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent() val cmdString: String get() = when (this) { is MCText -> "text $text" @@ -1073,3 +1050,28 @@ class SndFileTransfer() {} @Serializable class FileTransferMeta() {} + +@Serializable +enum class CICallStatus { + @SerialName("pending") Pending, + @SerialName("missed") Missed, + @SerialName("rejected") Rejected, + @SerialName("accepted") Accepted, + @SerialName("negotiated") Negotiated, + @SerialName("progress") Progress, + @SerialName("ended") Ended, + @SerialName("error") Error; + + fun text(sec: Int): String = when (this) { + Pending -> generalGetString(R.string.callstatus_calling) + Missed -> generalGetString(R.string.callstatus_missed) + Rejected -> generalGetString(R.string.callstatus_rejected) + Accepted -> generalGetString(R.string.callstatus_accepted) + Negotiated -> generalGetString(R.string.callstatus_connecting) + Progress -> generalGetString(R.string.callstatus_in_progress) + Ended -> String.format(generalGetString(R.string.callstatus_ended), duration(sec)) + Error -> generalGetString(R.string.callstatus_error) + } + + fun duration(sec: Int): String = "%02d:%02d".format(sec / 60, sec % 60) +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index d82011b7ca..c34d68da19 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -47,7 +47,7 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt val chats = apiGetChats() chatModel.chats.clear() chatModel.chats.addAll(chats) - chatModel.currentUser = mutableStateOf(user) + chatModel.currentUser.value = user chatModel.userCreated.value = true Log.d(TAG, "started chat") } catch(e: Error) { @@ -713,7 +713,7 @@ class APIResponse(val resp: CR, val corr: String? = null) { json.decodeFromString(str) } catch(e: Exception) { try { - Log.d(TAG, e.localizedMessage) + Log.d(TAG, e.localizedMessage ?: "") val data = json.parseToJsonElement(str).jsonObject APIResponse( resp = CR.Response(data["resp"]!!.jsonObject["type"]?.toString() ?: "invalid", json.encodeToString(data)), diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt index 8895c44e40..b85c6b59b7 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt @@ -27,7 +27,7 @@ import chat.simplex.app.TAG import chat.simplex.app.views.helpers.TextEditor import com.google.accompanist.permissions.rememberMultiplePermissionsState -//@SuppressLint("JavascriptInterface") +@SuppressLint("SetJavaScriptEnabled") @Composable fun VideoCallView(close: () -> Unit) { BackHandler(onBack = close) @@ -57,8 +57,7 @@ fun VideoCallView(close: () -> Unit) { } } val localContext = LocalContext.current - val iceCandidateCommand = remember { mutableStateOf("") } - val commandToShow = remember { mutableStateOf("processCommand({type: 'start', media: 'video', aesKey: 'FwW+t6UbnwHoapYOfN4mUBUuqR7UtvYWxW16iBqM29U='})") } + val commandToShow = remember { mutableStateOf("processCommand({command: {type: 'start', media: 'video'}})") } //, aesKey: 'FwW+t6UbnwHoapYOfN4mUBUuqR7UtvYWxW16iBqM29U='})") } val assetLoader = WebViewAssetLoader.Builder() .addPathHandler("/assets/www/", WebViewAssetLoader.AssetsPathHandler(localContext)) .build() @@ -96,9 +95,7 @@ fun VideoCallView(close: () -> Unit) { override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { val rtnValue = super.onConsoleMessage(consoleMessage) val msg = consoleMessage?.message() as String - if (msg.startsWith("{\"action\":\"processIceCandidates\"")) { - iceCandidateCommand.value = "processCommand($msg)" - } else if (msg.startsWith("{")) { + if (msg.startsWith("{")) { commandToShow.value = "processCommand($msg)" } return rtnValue @@ -142,15 +139,9 @@ fun VideoCallView(close: () -> Unit) { wv.evaluateJavascript(commandToShow.value, null) commandToShow.value = "" }) {Text("Send")} - Button( onClick = { - commandToShow.value = iceCandidateCommand.value - }) {Text("ICE")} Button( onClick = { commandToShow.value = "" }) {Text("Clear")} - Button( onClick = { - wv.evaluateJavascript("endCall()", null) - }) {Text("End Call")} } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt index 31af628394..cdf9ebf45c 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt @@ -1,10 +1,47 @@ package chat.simplex.app.views.call -import chat.simplex.app.model.CR -import chat.simplex.app.model.User +import chat.simplex.app.R +import chat.simplex.app.model.* +import chat.simplex.app.views.helpers.generalGetString import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +class Call( + val contact: Contact, + val callState: CallState, + val localMedia: CallMediaType, + val localCapabilities: CallCapabilities? = null, + val peerMedia: CallMediaType? = null, + val sharedKey: String? = null, + val audioEnabled: Boolean = true, + val videoEnabled: Boolean = localMedia == CallMediaType.Video +) { + val encrypted: Boolean get() = (localCapabilities?.encryption ?: false) && sharedKey != null +} + +enum class CallState { + WaitCapabilities, + InvitationSent, + InvitationReceived, + OfferSent, + OfferReceived, + Negotiated, + Connected; + + val text: String get() = when(this) { + WaitCapabilities -> generalGetString(R.string.callstate_starting) + InvitationSent -> generalGetString(R.string.callstate_waiting_for_answer) + InvitationReceived -> generalGetString(R.string.callstate_starting) + OfferSent -> generalGetString(R.string.callstate_waiting_for_confirmation) + OfferReceived -> generalGetString(R.string.callstate_received_answer) + Negotiated -> generalGetString(R.string.callstate_connecting) + Connected -> generalGetString(R.string.callstate_connected) + } +} + +@Serializable class WVAPICall(val corrId: Int? = null, val command: WCallCommand) +@Serializable class WVAPIMessage(val corrId: Int? = null, val resp: WCallResponse, val command: WCallCommand?) + @Serializable sealed class WCallCommand { @Serializable @SerialName("capabilities") class Capabilities(): WCallCommand() @@ -12,6 +49,7 @@ sealed class WCallCommand { @Serializable @SerialName("accept") class Accept(val offer: String, val iceCandidates: List, val media: CallMediaType, val aesKey: String? = null): WCallCommand() @Serializable @SerialName("answer") class Answer (val answer: String, val iceCandidates: List): WCallCommand() @Serializable @SerialName("ice") class Ice(val iceCandidates: List): WCallCommand() + @Serializable @SerialName("media") class Media(val media: CallMediaType, val enable: Boolean): WCallCommand() @Serializable @SerialName("end") class End(): WCallCommand() } @@ -30,17 +68,12 @@ sealed class WCallResponse { @Serializable class Invalid(val str: String): WCallResponse() } -@Serializable -class WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession) - -@Serializable -class WebRTCSession(val rtcSession: String, val rtcIceCandidates: List) - -@Serializable -class WebRTCExtraInfo(val rtcIceCandidates: List) - -@Serializable -class CallType(val media: CallMediaType, val capabilities: CallCapabilities) +@Serializable class WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession) +@Serializable class WebRTCSession(val rtcSession: String, val rtcIceCandidates: List) +@Serializable class WebRTCExtraInfo(val rtcIceCandidates: List) +@Serializable class CallType(val media: CallMediaType, val capabilities: CallCapabilities) +@Serializable class CallInvitation(val peerMedia: CallMediaType?, val sharedKey: String?) +@Serializable class CallCapabilities(val encryption: Boolean) enum class WebRTCCallStatus(val status: String) { Connected("connected"), @@ -54,9 +87,6 @@ enum class CallMediaType(val media: String) { Audio("audio") } -@Serializable -class CallCapabilities(val encryption: Boolean) - @Serializable class ConnectionState( val connectionState: String, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index 3e484d74eb..1d43b0afd7 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -62,6 +62,8 @@ fun ChatItemView( } } else if (cItem.isDeletedContent) { DeletedItemView(cItem, showMember = showMember) + } else if (cItem.isCall) { + FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu, receiveFile) } if (cItem.isMsgContent) { DropdownMenu( diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt index 02bc964b69..4857025afc 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt @@ -39,7 +39,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { suspend fun openChat(chatModel: ChatModel, cInfo: ChatInfo) { val chat = chatModel.controller.apiGetChat(cInfo.chatType, cInfo.apiId) if (chat != null) { - chatModel.chatItems = chat.chatItems.toMutableStateList() + chatModel.chatItems.clear() + chatModel.chatItems.addAll(chat.chatItems) chatModel.chatId.value = cInfo.id } } diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index 6cc2985897..ac1acc9801 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -275,4 +275,21 @@ Вы также можете соединиться, открыв ссылку там, где вы её получили. Если ссылка откроется в браузере, нажмите кнопку Open in mobile app. Добавьте контакт, чтобы начать разговор: + + входящий звонок… + пропущенный звонок + отклоненный звонок + принятый звонок + соединяется… + активный звонок + звонок завершён %1$s! + ошибка соединения + + + инициализация… + ожидается ответ… + ожидается подтверждение… + получен ответ… + соединяется… + соединено diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 50d46ea542..774d6e8c2f 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -276,4 +276,21 @@ colored secret + + calling… + missed + rejected + accepted + connecting… + in progress + ended %1$s! + error + + + starting… + waiting for answer… + waiting for confirmation… + received answer… + connecting… + connected diff --git a/apps/ios/Shared/Model/Shared/ChatTypes.swift b/apps/ios/Shared/Model/Shared/ChatTypes.swift index 9e3827ed85..3ab6dc2c89 100644 --- a/apps/ios/Shared/Model/Shared/ChatTypes.swift +++ b/apps/ios/Shared/Model/Shared/ChatTypes.swift @@ -725,7 +725,6 @@ enum MsgContent { } } -// TODO define Encodable extension MsgContent: Decodable { init(from decoder: Decoder) throws { do { @@ -863,15 +862,14 @@ enum CICallStatus: String, Decodable { func text(_ sec: Int) -> String { switch self { - case .pending: return "calling..." - case .negotiated: return "connecting..." - case .progress: return "in progress" - case .ended: return "ended \(duration(sec))" - default: return self.rawValue + case .pending: return NSLocalizedString("calling…", comment: "call status") + case .missed: return NSLocalizedString("missed…", comment: "call status") + case .rejected: return NSLocalizedString("rejected", comment: "call status") + case .accepted: return NSLocalizedString("accepted", comment: "call status") + case .negotiated: return NSLocalizedString("connecting…", comment: "call status") + case .progress: return NSLocalizedString("in progress", comment: "call status") + case .ended: return String.localizedStringWithFormat(NSLocalizedString("ended %02d:%02d", comment: "call status"), sec / 60, sec % 60) + case .error: return NSLocalizedString("error", comment: "call status") } } - - func duration(_ sec: Int) -> String { - String(format: "%02d:%02d", sec / 60, sec % 60) - } } diff --git a/apps/ios/Shared/Views/Call/WebRTC.swift b/apps/ios/Shared/Views/Call/WebRTC.swift index d05833c859..ec3842bb07 100644 --- a/apps/ios/Shared/Views/Call/WebRTC.swift +++ b/apps/ios/Shared/Views/Call/WebRTC.swift @@ -81,12 +81,12 @@ enum CallState { var text: LocalizedStringKey { switch self { - case .waitCapabilities: return "starting..." - case .invitationSent: return "waiting for answer..." - case .invitationReceived: return "starting..." - case .offerSent: return "waiting for confirmation..." - case .offerReceived: return "received answer..." - case .negotiated: return "connecting..." + case .waitCapabilities: return "starting…" + case .invitationSent: return "waiting for answer…" + case .invitationReceived: return "starting…" + case .offerSent: return "waiting for confirmation…" + case .offerReceived: return "received answer…" + case .negotiated: return "connecting…" case .connected: return "connected" } } diff --git a/apps/ios/Shared/Views/Call/WebRTCView.swift b/apps/ios/Shared/Views/Call/WebRTCView.swift index b97d0f2807..1dad35e67c 100644 --- a/apps/ios/Shared/Views/Call/WebRTCView.swift +++ b/apps/ios/Shared/Views/Call/WebRTCView.swift @@ -91,14 +91,15 @@ struct WebRTCView: UIViewRepresentable { } //struct CallViewDebug: View { -// @State var coordinator: WebRTCCoordinator? = nil -// @State var commandStr = "" -// @State private var webViewMsg: WCallResponse? = nil +// @State private var coordinator: WebRTCCoordinator? = nil +// @State private var commandStr = "" +// @State private var webViewReady: Bool = false +// @State private var webViewMsg: WVAPIMessage? = nil // @FocusState private var keyboardVisible: Bool // // var body: some View { // VStack(spacing: 30) { -// WebRTCView(coordinator: $coordinator, webViewMsg: $webViewMsg).frame(maxHeight: 260) +// WebRTCView(coordinator: $coordinator, webViewReady: $webViewReady, webViewMsg: $webViewMsg).frame(maxHeight: 260) // .onChange(of: webViewMsg) { _ in // if let resp = webViewMsg { // commandStr = encodeJSON(resp) @@ -126,13 +127,9 @@ struct WebRTCView: UIViewRepresentable { // commandStr = "" // } // Button("Send") { -// do { -// if let c = coordinator, -// let command: WCallCommand = decodeJSON(commandStr) { -// c.sendCommand(command: command) -// } -// } catch { -// print(error) +// if let c = coordinator, +// let command: WCallCommand = decodeJSON(commandStr) { +// c.sendCommand(command: command) // } // } // } From 235bce8e2a4d50e86ca81ba0b4fd3bdb2ebdda31 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Sat, 7 May 2022 16:25:04 +0400 Subject: [PATCH 63/95] android: remove unnecessary READ_EXTERNAL_STORAGE permission request, open image picker in Gallery; IMG timestamp format (#610) --- apps/android/app/src/main/AndroidManifest.xml | 1 - .../java/chat/simplex/app/model/ChatModel.kt | 7 +- .../simplex/app/views/chat/SendMsgView.kt | 4 +- .../simplex/app/views/chat/item/CIFileView.kt | 2 +- .../app/views/chat/item/CIImageView.kt | 4 +- .../app/views/chat/item/ChatItemView.kt | 2 +- .../simplex/app/views/helpers/GetImageView.kt | 67 ++++++------------- .../chat/simplex/app/views/helpers/Share.kt | 2 +- .../chat/simplex/app/views/helpers/Util.kt | 13 ++-- apps/ios/Shared/FileUtils.swift | 21 ++++-- apps/ios/Shared/Model/Shared/ChatTypes.swift | 7 +- apps/ios/Shared/Views/Chat/ChatView.swift | 6 +- .../ios/Shared/Views/Helpers/CIFileView.swift | 2 +- .../Shared/Views/Helpers/CIImageView.swift | 2 +- 14 files changed, 67 insertions(+), 73 deletions(-) diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index 3c2b3d9451..113ffa93e2 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -8,7 +8,6 @@ - diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 56e69929b6..acf8469c83 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -851,13 +851,16 @@ class CIFile( val filePath: String? = null, val fileStatus: CIFileStatus ) { - val stored: Boolean = when (fileStatus) { + val loaded: Boolean = when (fileStatus) { CIFileStatus.SndStored -> true CIFileStatus.SndTransfer -> true CIFileStatus.SndComplete -> true CIFileStatus.SndCancelled -> true + CIFileStatus.RcvInvitation -> false + CIFileStatus.RcvAccepted -> false + CIFileStatus.RcvTransfer -> false + CIFileStatus.RcvCancelled -> false CIFileStatus.RcvComplete -> true - else -> false } companion object { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index 74fee47400..08f2672a15 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -73,7 +73,7 @@ fun SendMsgView( .size(36.dp) .padding(4.dp), color = HighOrLowlight, - strokeWidth = 4.dp + strokeWidth = 3.dp ) } else { Icon( @@ -149,7 +149,7 @@ fun PreviewSendMsgViewEditing() { fun PreviewSendMsgViewInProgress() { val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) val textStyle = remember { mutableStateOf(smallFont) } - val composeStateInProgress = ComposeState(inProgress = true) + val composeStateInProgress = ComposeState(preview = ComposePreview.FilePreview("test.txt"), inProgress = true) SimpleXTheme { SendMsgView( composeState = remember { mutableStateOf(composeStateInProgress) }, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt index 35a0c22a75..6553a2d7ff 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt @@ -89,7 +89,7 @@ fun CIFileView( String.format(generalGetString(R.string.file_will_be_received_when_contact_is_online), MAX_FILE_SIZE) ) CIFileStatus.RcvComplete -> { - val filePath = getStoredFilePath(context, file) + val filePath = getLoadedFilePath(context, file) if (filePath != null) { saveFileLauncher.launch(file.fileName) } else { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt index 918c429ea9..e3f342deac 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt @@ -86,10 +86,10 @@ fun CIImageView( Box(contentAlignment = Alignment.TopEnd) { val context = LocalContext.current - val imageBitmap: Bitmap? = getStoredImage(context, file) + val imageBitmap: Bitmap? = getLoadedImage(context, file) if (imageBitmap != null) { imageView(imageBitmap, onClick = { - if (getStoredFilePath(context, file) != null) { + if (getLoadedFilePath(context, file) != null) { ModalManager.shared.showCustomModal { close -> ImageFullScreenView(imageBitmap, close) } } }) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index 1d43b0afd7..8aed21baf4 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -88,7 +88,7 @@ fun ChatItemView( showMenu.value = false }) if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile) { - val filePath = getStoredFilePath(context, cItem.file) + val filePath = getLoadedFilePath(context, cItem.file) if (filePath != null) { ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = { saveFileLauncher.launch(cItem.file?.fileName) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt index 095c3ef2e4..14b7adbb80 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt @@ -19,7 +19,8 @@ import androidx.annotation.CallSuper import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.* -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext @@ -126,19 +127,17 @@ class CustomTakePicturePreview: ActivityResultContract() { } } } -//class GetGalleryContent: ActivityResultContracts.GetContent() { -// override fun createIntent(context: Context, input: String): Intent { -// return super.createIntent(context, input).apply { -// Log.e(TAG, "########################################################### in GetGalleryContent") -// uri = DocumentsContract.buildDocumentUriUsingTree(uri, DocumentsContract.getTreeDocumentId(uri)) -// putExtra(DocumentsContract.EXTRA_INITIAL_URI, Environment.DIRECTORY_PICTURES) -// } -// } -//} + +class GetGalleryContent: ActivityResultContracts.GetContent() { + override fun createIntent(context: Context, input: String): Intent { + super.createIntent(context, input) + return Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI) + } +} + @Composable -fun rememberGetContentLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher = -// rememberLauncherForActivityResult(contract = GetGalleryContent(), cb) - rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent(), cb) +fun rememberGalleryLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher = + rememberLauncherForActivityResult(contract = GetGalleryContent(), cb) @Composable fun rememberCameraLauncher(cb: (Bitmap?) -> Unit): ManagedActivityResultLauncher = @@ -148,6 +147,10 @@ fun rememberCameraLauncher(cb: (Bitmap?) -> Unit): ManagedActivityResultLauncher fun rememberPermissionLauncher(cb: (Boolean) -> Unit): ManagedActivityResultLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission(), cb) +@Composable +fun rememberGetContentLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent(), cb) + @Composable fun GetImageBottomSheet( imageBitmap: MutableState, @@ -157,8 +160,7 @@ fun GetImageBottomSheet( hideBottomSheet: () -> Unit ) { val context = LocalContext.current - val isCameraSelected = remember { mutableStateOf(false) } - val galleryLauncher = rememberGetContentLauncher { uri: Uri? -> + val galleryLauncher = rememberGalleryLauncher { uri: Uri? -> if (uri != null) { val source = ImageDecoder.createSource(context.contentResolver, uri) val bitmap = ImageDecoder.decodeBitmap(source) @@ -174,8 +176,7 @@ fun GetImageBottomSheet( } val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean -> if (isGranted) { - if (isCameraSelected.value) cameraLauncher.launch(null) - else galleryLauncher.launch("image/*") + cameraLauncher.launch(null) hideBottomSheet() } else { Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show() @@ -195,14 +196,6 @@ fun GetImageBottomSheet( } } } - val filesPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean -> - if (isGranted) { - filesLauncher.launch("*/*") - hideBottomSheet() - } else { - Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show() - } - } Box( modifier = Modifier @@ -225,34 +218,18 @@ fun GetImageBottomSheet( hideBottomSheet() } else -> { - isCameraSelected.value = true permissionLauncher.launch(Manifest.permission.CAMERA) } } } ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Collections) { - when (PackageManager.PERMISSION_GRANTED) { - ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) -> { - galleryLauncher.launch("image/*") - hideBottomSheet() - } - else -> { - isCameraSelected.value = false - permissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) - } - } + galleryLauncher.launch("image/*") + hideBottomSheet() } if (fileUri != null && onFileChange != null) { ActionButton(null, stringResource(R.string.choose_file), icon = Icons.Outlined.InsertDriveFile) { - when (PackageManager.PERMISSION_GRANTED) { - ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) -> { - filesLauncher.launch("*/*") - hideBottomSheet() - } - else -> { - filesPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) - } - } + filesLauncher.launch("*/*") + hideBottomSheet() } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt index e05633a1ac..6c66b2bbb8 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt @@ -34,7 +34,7 @@ fun rememberSaveFileLauncher(cxt: Context, ciFile: CIFile?): ManagedActivityResu contract = ActivityResultContracts.CreateDocument(), onResult = { destination -> if (destination != null) { - val filePath = getStoredFilePath(cxt, ciFile) + val filePath = getLoadedFilePath(cxt, ciFile) if (filePath != null) { val contentResolver = cxt.contentResolver val file = File(filePath) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt index 17c307f7d2..478bc7b5fb 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt @@ -29,6 +29,8 @@ import chat.simplex.app.model.CIFile import kotlinx.coroutines.* import java.io.File import java.io.FileOutputStream +import java.text.SimpleDateFormat +import java.util.* import kotlin.math.log2 import kotlin.math.pow @@ -224,8 +226,8 @@ fun getAppFilePath(context: Context, fileName: String): String { return "${getAppFilesDirectory(context)}/$fileName" } -fun getStoredFilePath(context: Context, file: CIFile?): String? { - return if (file?.filePath != null && file.stored) { +fun getLoadedFilePath(context: Context, file: CIFile?): String? { + return if (file?.filePath != null && file.loaded) { val filePath = getAppFilePath(context, file.filePath) if (File(filePath).exists()) filePath else null } else { @@ -234,8 +236,8 @@ fun getStoredFilePath(context: Context, file: CIFile?): String? { } // https://developer.android.com/training/data-storage/shared/documents-files#bitmap -fun getStoredImage(context: Context, file: CIFile?): Bitmap? { - val filePath = getStoredFilePath(context, file) +fun getLoadedImage(context: Context, file: CIFile?): Bitmap? { + val filePath = getLoadedFilePath(context, file) return if (filePath != null) { try { val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) @@ -271,7 +273,8 @@ fun getFileSize(context: Context, uri: Uri): Long? { fun saveImage(context: Context, image: Bitmap): String? { return try { val dataResized = resizeImageToDataSize(image, maxDataSize = MAX_IMAGE_SIZE) - val fileToSave = uniqueCombine(context, "image_${System.currentTimeMillis()}.jpg") + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val fileToSave = uniqueCombine(context, "IMG_${timestamp}.jpg") val file = File(getAppFilePath(context, fileToSave)) val output = FileOutputStream(file) dataResized.writeTo(output) diff --git a/apps/ios/Shared/FileUtils.swift b/apps/ios/Shared/FileUtils.swift index 8978ad7ccf..abb1509653 100644 --- a/apps/ios/Shared/FileUtils.swift +++ b/apps/ios/Shared/FileUtils.swift @@ -26,17 +26,17 @@ func getAppFilePath(_ fileName: String) -> URL { getAppFilesDirectory().appendingPathComponent(fileName) } -func getStoredFilePath(_ file: CIFile?) -> String? { +func getLoadedFilePath(_ file: CIFile?) -> String? { if let file = file, - file.stored, + file.loaded, let savedFile = file.filePath { return getAppFilePath(savedFile).path } return nil } -func getStoredImage(_ file: CIFile?) -> UIImage? { - if let filePath = getStoredFilePath(file) { +func getLoadedImage(_ file: CIFile?) -> UIImage? { + if let filePath = getLoadedFilePath(file) { return UIImage(contentsOfFile: filePath) } return nil @@ -63,13 +63,22 @@ func saveFileFromURL(_ url: URL) -> String? { func saveImage(_ uiImage: UIImage) -> String? { if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: maxImageSize) { - let millisecondsSince1970 = Int64((Date().timeIntervalSince1970 * 1000.0).rounded()) - let fileName = uniqueCombine("image_\(millisecondsSince1970).jpg") + let timestamp = Date().getFormattedDate("yyyyMMdd_HHmmss") + let fileName = uniqueCombine("IMG_\(timestamp).jpg") return saveFile(imageDataResized, fileName) } return nil } +extension Date { + func getFormattedDate(_ format: String) -> String { + let df = DateFormatter() + df.dateFormat = format + df.locale = Locale(identifier: "US") + return df.string(from: self) + } +} + private func saveFile(_ data: Data, _ fileName: String) -> String? { let filePath = getAppFilePath(fileName) do { diff --git a/apps/ios/Shared/Model/Shared/ChatTypes.swift b/apps/ios/Shared/Model/Shared/ChatTypes.swift index 3ab6dc2c89..6f3981b69d 100644 --- a/apps/ios/Shared/Model/Shared/ChatTypes.swift +++ b/apps/ios/Shared/Model/Shared/ChatTypes.swift @@ -655,15 +655,18 @@ struct CIFile: Decodable { CIFile(fileId: fileId, fileName: fileName, fileSize: fileSize, filePath: filePath, fileStatus: fileStatus) } - var stored: Bool { + var loaded: Bool { get { switch self.fileStatus { case .sndStored: return true case .sndTransfer: return true case .sndComplete: return true case .sndCancelled: return true + case .rcvInvitation: return false + case .rcvAccepted: return false + case .rcvTransfer: return false + case .rcvCancelled: return false case .rcvComplete: return true - default: return false } } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index ec691f0cfd..21fc16c5b8 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -148,7 +148,7 @@ struct ChatView: View { } label: { Label("Reply", systemImage: "arrowshape.turn.up.left") } Button { var shareItems: [Any] = [ci.content.text] - if case .image = ci.content.msgContent, let image = getStoredImage(ci.file) { + if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) { shareItems.append(image) } showShareSheet(items: shareItems) @@ -156,14 +156,14 @@ struct ChatView: View { Button { if case let .image(text, _) = ci.content.msgContent, text == "", - let image = getStoredImage(ci.file) { + let image = getLoadedImage(ci.file) { UIPasteboard.general.image = image } else { UIPasteboard.general.string = ci.content.text } } label: { Label("Copy", systemImage: "doc.on.doc") } if case .image = ci.content.msgContent, - let image = getStoredImage(ci.file) { + let image = getLoadedImage(ci.file) { Button { UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) } label: { Label("Save", systemImage: "square.and.arrow.down") } diff --git a/apps/ios/Shared/Views/Helpers/CIFileView.swift b/apps/ios/Shared/Views/Helpers/CIFileView.swift index cebdfa64dd..f6ced00a1d 100644 --- a/apps/ios/Shared/Views/Helpers/CIFileView.swift +++ b/apps/ios/Shared/Views/Helpers/CIFileView.swift @@ -78,7 +78,7 @@ struct CIFileView: View { ) case .rcvComplete: logger.debug("CIFileView processFile - in .rcvComplete") - if let filePath = getStoredFilePath(file){ + if let filePath = getLoadedFilePath(file){ let url = URL(fileURLWithPath: filePath) showShareSheet(items: [url]) } diff --git a/apps/ios/Shared/Views/Helpers/CIImageView.swift b/apps/ios/Shared/Views/Helpers/CIImageView.swift index ec15883cbf..c4af213e6e 100644 --- a/apps/ios/Shared/Views/Helpers/CIImageView.swift +++ b/apps/ios/Shared/Views/Helpers/CIImageView.swift @@ -18,7 +18,7 @@ struct CIImageView: View { var body: some View { VStack(alignment: .center, spacing: 6) { - if let uiImage = getStoredImage(file) { + if let uiImage = getLoadedImage(file) { imageView(uiImage) .fullScreenCover(isPresented: $showFullScreenImage) { ZStack { From c4f4cd85c4d0c60b6a1b9946b46ce0bc389cf273 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 7 May 2022 14:00:17 +0100 Subject: [PATCH 64/95] android: fix quotes with images and files (#611) --- .../app/views/chat/item/FramedItemView.kt | 52 +++++++++++++------ 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt index 19eabed563..e14e031538 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt @@ -43,11 +43,9 @@ fun FramedItemView( val sent = ci.chatDir.sent @Composable - fun ciQuotedMsgView(qi: CIQuote, paddingEnd: Dp = 0.dp) { + fun ciQuotedMsgView(qi: CIQuote) { Box( - Modifier - .padding(vertical = 6.dp, horizontal = 12.dp) - .padding(end = paddingEnd), + Modifier.padding(vertical = 6.dp, horizontal = 12.dp), contentAlignment = Alignment.TopStart ) { MarkdownText( @@ -66,24 +64,21 @@ fun FramedItemView( ) { when (qi.content) { is MsgContent.MCImage -> { - ciQuotedMsgView(qi) - // TODO fix quotes containing long text and image / file - // ciQuotedMsgView(qi, paddingEnd = 60.dp) - Spacer(Modifier.weight(1f)) + Box(Modifier.fillMaxWidth().weight(1f)) { + ciQuotedMsgView(qi) + } val imageBitmap = base64ToBitmap(qi.content.image).asImageBitmap() Image( imageBitmap, contentDescription = stringResource(R.string.image_descr), contentScale = ContentScale.Crop, - modifier = Modifier - .size(60.dp) - .clipToBounds() + modifier = Modifier.size(68.dp).clipToBounds() ) } is MsgContent.MCFile -> { - ciQuotedMsgView(qi) - // ciQuotedMsgView(qi, paddingEnd = 26.dp) - Spacer(Modifier.weight(1f)) + Box(Modifier.fillMaxWidth().weight(1f)) { + ciQuotedMsgView(qi) + } Icon( Icons.Filled.InsertDriveFile, stringResource(R.string.icon_descr_file), @@ -93,7 +88,7 @@ fun FramedItemView( tint = if (isSystemInDarkTheme()) FileDark else FileLight ) } - else -> ciQuotedMsgView(qi, 22.dp) + else -> ciQuotedMsgView(qi) } } } @@ -317,3 +312,30 @@ fun PreviewQuoteWithLongTextAndImage(@PreviewParameter(EditedProvider::class) ed ) } } + +@Preview +@Composable +fun PreviewQuoteWithLongTextAndFile(@PreviewParameter(EditedProvider::class) edited: Boolean) { + val ciQuote = CIQuote( + chatDir = CIDirection.DirectRcv(), itemId = 1, sentAt = Clock.System.now(), + content = MsgContent.MCFile( + text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." + ) + ) + val showMenu = remember { mutableStateOf(false) } + SimpleXTheme { + FramedItemView( + User.sampleData, + ChatItem.getSampleData( + 1, CIDirection.DirectSnd(), + Clock.System.now(), + "Hello there", + CIStatus.SndSent(), + quotedItem = ciQuote, + itemEdited = edited + ), + showMenu = showMenu, + receiveFile = {} + ) + } +} From b3641bdf83e3ece18e7dac1f9c89b99f15d3e865 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Sat, 7 May 2022 18:24:38 +0400 Subject: [PATCH 65/95] fix deletion of contact with files wip (#614) --- .../app/views/chatlist/ChatListNavLinkView.kt | 41 +++++++++++--- src/Simplex/Chat.hs | 53 ++++++++++++++++--- 2 files changed, 81 insertions(+), 13 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt index 4857025afc..95bd343b1b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt @@ -2,6 +2,7 @@ package chat.simplex.app.views.chatlist import android.content.res.Configuration import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.Composable @@ -32,7 +33,29 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { pendingContactAlertDialog(chat.chatInfo, chatModel) } } - } + }, + longClick = { + when (chat.chatInfo) { + is ChatInfo.Direct -> { + AlertManager.shared.showAlertMsg( + title = generalGetString(R.string.delete_contact__question), + text = generalGetString(R.string.delete_contact_all_messages_deleted_cannot_undo_warning), + confirmText = generalGetString(R.string.delete_verb), + onConfirm = { + val cInfo = chat.chatInfo + withApi { + val r = chatModel.controller.apiDeleteChat(cInfo.chatType, cInfo.apiId) + if (r) { + chatModel.removeChat(cInfo.id) + chatModel.chatId.value = null + } + } + } + ) + } + else -> {} + } + }, ) } @@ -139,11 +162,14 @@ fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) { } @Composable -fun ChatListNavLinkLayout(chat: Chat, click: () -> Unit) { +fun ChatListNavLinkLayout(chat: Chat, click: () -> Unit, longClick: () -> Unit) { Surface( modifier = Modifier .fillMaxWidth() - .clickable(onClick = click) + .combinedClickable( + onClick = click, + onLongClick = longClick + ) .height(88.dp) ) { Row( @@ -187,7 +213,8 @@ fun PreviewChatListNavLinkDirect() { ), chatStats = Chat.ChatStats() ), - click = {} + click = {}, + longClick = {} ) } } @@ -214,7 +241,8 @@ fun PreviewChatListNavLinkGroup() { ), chatStats = Chat.ChatStats() ), - click = {} + click = {}, + longClick = {} ) } } @@ -234,7 +262,8 @@ fun PreviewChatListNavLinkContactRequest() { chatItems = listOf(), chatStats = Chat.ChatStats() ), - click = {} + click = {}, + longClick = {} ) } } diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index f17adb82f8..a55603e437 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -376,15 +376,25 @@ processChatCommand = \case ct@Contact {localDisplayName} <- withStore $ \st -> getContact st userId chatId withStore (\st -> getContactGroupNames st userId ct) >>= \case [] -> do + liftIO $ putStrLn "### files <- withStore $ \\st -> getContactFiles st userId ct" files <- withStore $ \st -> getContactFiles st userId ct + liftIO $ putStrLn "### conns <- withStore $ \\st -> getContactConnections st userId ct" conns <- withStore $ \st -> getContactConnections st userId ct + liftIO $ putStrLn "### withChatLock . procCmd $ do" withChatLock . procCmd $ do + liftIO $ putStrLn "### cancelFiles user (map (\\(fId, fStatus, _) -> (fId, fStatus)) files)" cancelFiles user (map (\(fId, fStatus, _) -> (fId, fStatus)) files) + liftIO $ putStrLn "### withFilesFolder $ \\filesFolder -> do" withFilesFolder $ \filesFolder -> do + liftIO $ putStrLn "### deleteFiles filesFolder (map (\\(_, _, fPath) -> fPath) files)" deleteFiles filesFolder (map (\(_, _, fPath) -> fPath) files) - withAgent $ \a -> forM_ conns $ \conn -> + liftIO $ putStrLn "### withAgent $ \\a -> forM_ conns $ \\conn ->" + withAgent $ \a -> forM_ conns $ \conn -> do + liftIO $ putStrLn "### deleteConnection a (aConnId conn) `catchError` \\(_ :: AgentErrorType) -> pure ()" deleteConnection a (aConnId conn) `catchError` \(_ :: AgentErrorType) -> pure () + liftIO $ putStrLn "### withStore $ \\st -> deleteContact st userId ct" withStore $ \st -> deleteContact st userId ct + liftIO $ putStrLn "### unsetActive $ ActiveC localDisplayName" unsetActive $ ActiveC localDisplayName pure $ CRContactDeleted ct gs -> throwChatError $ CEContactGroups ct gs @@ -734,11 +744,14 @@ processChatCommand = \case withFilesFolder :: (FilePath -> m ()) -> m () withFilesFolder action = asks filesFolder >>= readTVarIO >>= mapM_ action deleteFiles :: FilePath -> [Maybe FilePath] -> m () - deleteFiles filesFolder filePaths = + deleteFiles filesFolder filePaths = do forM_ filePaths $ \filePath_ -> forM_ filePath_ $ \filePath -> do let fsFilePath = filesFolder <> "/" <> filePath - removeFile fsFilePath `E.catch` \(_ :: E.SomeException) -> + liftIO $ putStrLn ("in deleteFiles - fsFilePath " <> fsFilePath) + liftIO $ putStrLn "removeFile fsFilePath `E.catch` \\(_ :: E.SomeException) ->" + removeFile fsFilePath `E.catch` \(_ :: E.SomeException) -> do + liftIO $ putStrLn "removePathForcibly fsFilePath `E.catch` \\(_ :: E.SomeException) -> pure ()" removePathForcibly fsFilePath `E.catch` \(_ :: E.SomeException) -> pure () cancelFiles :: User -> [(Int64, ACIFileStatus)] -> m () cancelFiles user@User {userId} files = mapM_ maybeCancelFile files @@ -756,26 +769,39 @@ processChatCommand = \case AFS _ CIFSRcvComplete -> pure () cancelById :: Int64 -> m () cancelById fileId = do + liftIO $ putStrLn ("cancelById - fileId " <> show fileId) + liftIO $ putStrLn "ft <- withStore (\\st -> getFileTransfer st userId fileId)" ft <- withStore (\st -> getFileTransfer st userId fileId) + liftIO $ putStrLn "void $ cancelFile user fileId ft" void $ cancelFile user fileId ft cancelFile :: User -> Int64 -> FileTransfer -> m ChatResponse cancelFile user@User {userId} fileId ft = case ft of FTSnd ftm fts -> do + liftIO $ putStrLn "cancelFileTransfer CIFSSndCancelled" cancelFileTransfer CIFSSndCancelled + liftIO $ putStrLn "forM_ fts $ \\ft' -> cancelSndFileTransfer ft'" forM_ fts $ \ft' -> cancelSndFileTransfer ft' + liftIO $ putStrLn "ci <- withStore $ \\st -> getChatItemByFileId st user fileId" ci <- withStore $ \st -> getChatItemByFileId st user fileId + liftIO $ putStrLn "pure $ CRSndGroupFileCancelled ci ftm fts" pure $ CRSndGroupFileCancelled ci ftm fts FTRcv ftr -> do + liftIO $ putStrLn "cancelFileTransfer CIFSRcvCancelled" cancelFileTransfer CIFSRcvCancelled + liftIO $ putStrLn "cancelRcvFileTransfer ftr" cancelRcvFileTransfer ftr + liftIO $ putStrLn "pure $ CRRcvFileCancelled ftr" pure $ CRRcvFileCancelled ftr where cancelFileTransfer :: MsgDirectionI d => CIFileStatus d -> m () - cancelFileTransfer ciFileStatus = + cancelFileTransfer ciFileStatus = do + liftIO $ putStrLn "unless (fileTransferCancelled ft) $" unless (fileTransferCancelled ft) $ withStore $ \st -> do + liftIO $ putStrLn "updateFileCancelled st userId fileId" updateFileCancelled st userId fileId + liftIO $ putStrLn "updateCIFileStatus st userId fileId ciFileStatus" updateCIFileStatus st userId fileId ciFileStatus withCurrentCall :: ContactId -> (UserId -> Contact -> Call -> m (Maybe Call)) -> m ChatResponse withCurrentCall ctId action = withUser $ \User {userId} -> do @@ -1871,23 +1897,36 @@ isFileActive fileId files = do cancelRcvFileTransfer :: ChatMonad m => RcvFileTransfer -> m () cancelRcvFileTransfer ft@RcvFileTransfer {fileId, fileStatus} = do + liftIO $ putStrLn "closeFileHandle fileId rcvFiles" closeFileHandle fileId rcvFiles + liftIO $ putStrLn "withStore $ \\st -> do" withStore $ \st -> do + liftIO $ putStrLn "updateRcvFileStatus st ft FSCancelled" updateRcvFileStatus st ft FSCancelled + liftIO $ putStrLn "deleteRcvFileChunks st ft" deleteRcvFileChunks st ft case fileStatus of - RFSAccepted RcvFileInfo {agentConnId = AgentConnId acId} -> withAgent (`deleteConnection` acId) - RFSConnected RcvFileInfo {agentConnId = AgentConnId acId} -> withAgent (`deleteConnection` acId) + RFSAccepted RcvFileInfo {agentConnId = AgentConnId acId} -> do + liftIO $ putStrLn "withAgent (`deleteConnection` acId)" + withAgent (`deleteConnection` acId) + RFSConnected RcvFileInfo {agentConnId = AgentConnId acId} -> do + liftIO $ putStrLn "withAgent (`deleteConnection` acId)" + withAgent (`deleteConnection` acId) _ -> pure () cancelSndFileTransfer :: ChatMonad m => SndFileTransfer -> m () -cancelSndFileTransfer ft@SndFileTransfer {agentConnId = AgentConnId acId, fileStatus} = +cancelSndFileTransfer ft@SndFileTransfer {agentConnId = AgentConnId acId, fileStatus} = do + liftIO $ putStrLn "unless (fileStatus == FSCancelled || fileStatus == FSComplete) $ do" unless (fileStatus == FSCancelled || fileStatus == FSComplete) $ do withStore $ \st -> do + liftIO $ putStrLn "updateSndFileStatus st ft FSCancelled" updateSndFileStatus st ft FSCancelled + liftIO $ putStrLn "deleteSndFileChunks st ft" deleteSndFileChunks st ft withAgent $ \a -> do + liftIO $ putStrLn "void (sendMessage a acId $ smpEncode FileChunkCancel) `catchError` \\_ -> pure ()" void (sendMessage a acId $ smpEncode FileChunkCancel) `catchError` \_ -> pure () + liftIO $ putStrLn "deleteConnection a acId" deleteConnection a acId closeFileHandle :: ChatMonad m => Int64 -> (ChatController -> TVar (Map Int64 Handle)) -> m () From b1024be74d1417798b14afd277fde71b3fe19bc1 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 7 May 2022 16:10:57 +0100 Subject: [PATCH 66/95] mobile: hide notifications and webrtc calls (#612) * mobile: hide notifications and webrtc calls * android: remove webrtc link --- .../app/views/usersettings/SettingsView.kt | 3 ++- apps/ios/Shared/AppDelegate.swift | 21 ++++++++++--------- apps/ios/Shared/Views/Chat/ChatView.swift | 16 +++++++------- .../Shared/Views/ChatList/ChatListView.swift | 2 +- .../Views/UserSettings/SettingsView.swift | 19 ++++++++++------- 5 files changed, 33 insertions(+), 28 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt index 5eb906cea8..1c4aac68f6 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt @@ -194,7 +194,8 @@ fun SettingsLayout( Text(annotatedStringResource(R.string.install_simplex_chat_for_terminal)) } Divider(Modifier.padding(horizontal = 8.dp)) - SettingsSectionView(showVideoChatPrototype) { +// SettingsSectionView(showVideoChatPrototype) { + SettingsSectionView() { Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") } } diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index 2d427d20a7..415f6348f0 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -21,16 +21,17 @@ class AppDelegate: NSObject, UIApplicationDelegate { logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)") let m = ChatModel.shared m.deviceToken = token - let useNotifications = UserDefaults.standard.bool(forKey: "useNotifications") - if useNotifications { - Task { - do { - m.tokenStatus = try await apiRegisterToken(token: token) - } catch { - logger.error("apiRegisterToken error: \(responseError(error))") - } - } - } + UserDefaults.standard.set(false, forKey: DEFAULT_USE_NOTIFICATIONS) +// let useNotifications = UserDefaults.standard.bool(forKey: "useNotifications") +// if useNotifications { +// Task { +// do { +// m.tokenStatus = try await apiRegisterToken(token: token) +// } catch { +// logger.error("apiRegisterToken error: \(responseError(error))") +// } +// } +// } } func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 21fc16c5b8..f9c313863a 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -106,14 +106,14 @@ struct ChatView: View { ChatInfoView(chat: chat, showChatInfo: $showChatInfo) } } - ToolbarItem(placement: .navigationBarTrailing) { - if case let .direct(contact) = cInfo { - HStack { - callButton(contact, .audio, imageName: "phone") - callButton(contact, .video, imageName: "video") - } - } - } +// ToolbarItem(placement: .navigationBarTrailing) { +// if case let .direct(contact) = cInfo { +// HStack { +// callButton(contact, .audio, imageName: "phone") +// callButton(contact, .video, imageName: "video") +// } +// } +// } } .navigationBarBackButtonHidden(true) } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index ff9ba5bee5..0761350b5b 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -14,7 +14,7 @@ struct ChatListView: View { @State private var showSettings = false @State private var searchText = "" @State private var showCallView = false - @AppStorage("pendingConnections") private var pendingConnections = true + @AppStorage(DEFAULT_PENDING_CONNECTIONS) private var pendingConnections = true var user: User diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 996560adb4..5e716b76bb 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -14,12 +14,15 @@ let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionS let appBuild = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String +let DEFAULT_USE_NOTIFICATIONS = "useNotifications" +let DEFAULT_PENDING_CONNECTIONS = "pendingConnections" + struct SettingsView: View { @Environment(\.colorScheme) var colorScheme @EnvironmentObject var chatModel: ChatModel @Binding var showSettings: Bool - @AppStorage("useNotifications") private var useNotifications = false - @AppStorage("pendingConnections") private var pendingConnections = true + @AppStorage(DEFAULT_USE_NOTIFICATIONS) private var useNotifications = false + @AppStorage(DEFAULT_PENDING_CONNECTIONS) private var pendingConnections = true @State var showNotificationsAlert: Bool = false @State var whichNotificationsAlert = NotificationAlert.enable @@ -137,12 +140,12 @@ struct SettingsView: View { .padding(.trailing, 8) Text("Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)") } - if let token = chatModel.deviceToken { - HStack { - notificationsIcon() - notificationsToggle(token) - } - } +// if let token = chatModel.deviceToken { +// HStack { +// notificationsIcon() +// notificationsToggle(token) +// } +// } // NavigationLink { // CallViewDebug() // .frame(maxHeight: .infinity, alignment: .top) From 2c61358cb9c6484203ee3a4e118bd24da8875c2c Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Sat, 7 May 2022 19:33:47 +0400 Subject: [PATCH 67/95] add logs to deleteContact --- src/Simplex/Chat.hs | 4 ++-- src/Simplex/Chat/Store.hs | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index a55603e437..eb69eae86c 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -2137,9 +2137,9 @@ withStore :: m a withStore action = asks chatStore - >>= runExceptT . action + -- >>= runExceptT . action -- use this line instead of above to log query errors - -- >>= (\st -> runExceptT $ action st `E.catch` \(e :: E.SomeException) -> liftIO (print e) >> E.throwIO e) + >>= (\st -> runExceptT $ action st `E.catch` \(e :: E.SomeException) -> liftIO (print e) >> E.throwIO e) >>= liftEither . first ChatErrorStore chatCommandP :: Parser ChatCommand diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 39795d5b6d..3da0994e01 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -428,8 +428,10 @@ getContactGroupNames st userId Contact {contactId} = (userId, contactId) deleteContact :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> m () -deleteContact st userId Contact {contactId, localDisplayName} = +deleteContact st userId Contact {contactId, localDisplayName} = do + liftIO $ putStrLn "in deleteContact, liftIO . withTransaction st $ \\db -> do" liftIO . withTransaction st $ \db -> do + liftIO $ putStrLn "DB.execute DELETE FROM connections WHERE connection_id IN ..." DB.execute db [sql| @@ -441,7 +443,9 @@ deleteContact st userId Contact {contactId, localDisplayName} = ) |] (userId, contactId) + liftIO $ putStrLn "DB.execute db \"DELETE FROM contacts WHERE user_id = ? AND contact_id = ?\" (userId, contactId)" DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId) + liftIO $ putStrLn "DB.execute db \"DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?\" (userId, localDisplayName)" DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) updateUserProfile :: StoreMonad m => SQLiteStore -> User -> Profile -> m () From 1530d1e12b4bb9f05385d615d794de98432db23f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 7 May 2022 17:29:58 +0100 Subject: [PATCH 68/95] mobile: translations (#615) * mobile: translations * change some android translations * corrections Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> * correction Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> * correction * picture Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> * import updated Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> --- .../app/src/main/res/values-ru/strings.xml | 10 +- apps/ios/Shared/Model/Shared/ChatTypes.swift | 2 +- .../en.xcloc/Localized Contents/en.xliff | 282 +++++++++++++++++- .../SimpleX NSE/en.lproj/InfoPlist.strings | 6 + .../SimpleX NSE/en.lproj/Localizable.strings | Bin 0 -> 2690 bytes .../en.lproj/SimpleX--iOS--InfoPlist.strings | 6 +- .../ru.xcloc/Localized Contents/ru.xliff | 281 ++++++++++++++++- .../SimpleX NSE/en.lproj/InfoPlist.strings | 6 + .../SimpleX NSE/en.lproj/Localizable.strings | Bin 0 -> 2690 bytes .../en.lproj/SimpleX--iOS--InfoPlist.strings | 6 +- .../SimpleX NSE/ru.lproj/InfoPlist.strings | 9 + .../SimpleX NSE/ru.lproj/Localizable.strings | 61 ++++ apps/ios/SimpleX.xcodeproj/project.pbxproj | 24 ++ apps/ios/ru.lproj/Localizable.strings | 84 +++++- .../ru.lproj/SimpleX--iOS--InfoPlist.strings | 8 +- 15 files changed, 769 insertions(+), 16 deletions(-) create mode 100644 apps/ios/SimpleX Localizations/en.xcloc/Source Contents/SimpleX NSE/en.lproj/InfoPlist.strings create mode 100644 apps/ios/SimpleX Localizations/en.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings create mode 100644 apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/SimpleX NSE/en.lproj/InfoPlist.strings create mode 100644 apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings create mode 100644 apps/ios/SimpleX NSE/ru.lproj/InfoPlist.strings create mode 100644 apps/ios/SimpleX NSE/ru.lproj/Localizable.strings diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index ac1acc9801..0531c2b4b6 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -95,18 +95,18 @@ Изображение - Ожидание изображения + Ожидается прием изображения Изображение отправлено - Ожидание изображения - Изображение будет получено, когда ваш контакт будет в сети, пожалуйста, подождите или проверьте позже! + Ожидается прием изображения + Изображение будет принято, когда ваш контакт будет в сети, подождите или проверьте позже! Файл Большой файл! Ваш контакт отправил файл, размер которого превышает поддерживаемый в настоящее время максимальный размер (%1$s). В настоящее время максимальный поддерживаемый размер файла составляет %1$s. - Ожидание файла - Файл будет получен, когда ваш контакт будет в сети, пожалуйста, подождите или проверьте позже! + Ожидается прием файла + Файл будет принят, когда ваш контакт будет в сети, подождите или проверьте позже! Файл сохранен Файл не найден Ошибка сохранения файла diff --git a/apps/ios/Shared/Model/Shared/ChatTypes.swift b/apps/ios/Shared/Model/Shared/ChatTypes.swift index 6f3981b69d..9a3b36d05b 100644 --- a/apps/ios/Shared/Model/Shared/ChatTypes.swift +++ b/apps/ios/Shared/Model/Shared/ChatTypes.swift @@ -866,7 +866,7 @@ enum CICallStatus: String, Decodable { func text(_ sec: Int) -> String { switch self { case .pending: return NSLocalizedString("calling…", comment: "call status") - case .missed: return NSLocalizedString("missed…", comment: "call status") + case .missed: return NSLocalizedString("missed", comment: "call status") case .rejected: return NSLocalizedString("rejected", comment: "call status") case .accepted: return NSLocalizedString("accepted", comment: "call status") case .negotiated: return NSLocalizedString("connecting…", comment: "call status") diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 57ca5f9cbc..4e728c6920 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -125,11 +125,21 @@ All your contacts will remain connected No comment provided by engineer. + + Answer + Answer + accept incoming call via notification + Attach Attach No comment provided by engineer. + + Call already ended! + Call already ended! + No comment provided by engineer. + Cancel Cancel @@ -160,6 +170,11 @@ Checking new messages... notification + + Choose file + Choose file + No comment provided by engineer. + Choose from library Choose from library @@ -285,6 +300,11 @@ Create profile No comment provided by engineer. + + Currently maximum supported file size is %@. + Currently maximum supported file size is %@. + No comment provided by engineer. + Delete Delete @@ -395,6 +415,11 @@ Error: URL is invalid No comment provided by engineer. + + File will be received when your contact is online, please wait or check later! + File will be received when your contact is online, please wait or check later! + No comment provided by engineer. + Full name (optional) Full name (optional) @@ -440,6 +465,26 @@ If you received SimpleX Chat invitation link you can open it in your browser: No comment provided by engineer. + + Ignore + Ignore + ignore incoming call via notification + + + Image will be received when your contact is online, please wait or check later! + Image will be received when your contact is online, please wait or check later! + No comment provided by engineer. + + + Incoming %@ call + Incoming %@ call + notification body + + + Incoming call + Incoming call + notification + Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) @@ -450,6 +495,11 @@ Invalid connection link No comment provided by engineer. + + Large file! + Large file! + No comment provided by engineer. + Make sure SMP server addresses are in correct format, line separated and are not duplicated. Make sure SMP server addresses are in correct format, line separated and are not duplicated. @@ -701,6 +751,16 @@ To connect, please ask your contact to create another connection link and check Using SimpleX Chat servers. No comment provided by engineer. + + Waiting for file + Waiting for file + No comment provided by engineer. + + + Waiting for image + Waiting for image + No comment provided by engineer. + Welcome %@! Welcome %@! @@ -803,6 +863,11 @@ You can cancel this connection and remove the contact (and try later with a new You can cancel this connection and remove the contact (and try later with a new link). No comment provided by engineer. + + Your contact sent a file that is larger than currently supported maximum size (%@). + Your contact sent a file that is larger than currently supported maximum size (%@). + No comment provided by engineer. + Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. @@ -845,11 +910,21 @@ SimpleX servers cannot see your profile. above, then: No comment provided by engineer. + + accepted + accepted + call status + bold bold No comment provided by engineer. + + calling… + calling… + call status + colored colored @@ -860,10 +935,16 @@ SimpleX servers cannot see your profile. connect to SimpleX Chat developers. No comment provided by engineer. + + connected + connected + No comment provided by engineer. + connecting… connecting… - chat list item title + call status +chat list item title connection established @@ -880,6 +961,26 @@ SimpleX servers cannot see your profile. deleted deleted chat item + + end-to-end encrypted + end-to-end encrypted + No comment provided by engineer. + + + ended %1$d:%2$d + ended %1$d:%2$d + call status + + + error + error + call status + + + in progress + in progress + call status + invited to connect invited to connect @@ -890,11 +991,36 @@ SimpleX servers cannot see your profile. italic No comment provided by engineer. + + missed + missed + call status + + + no end-to-end encryption + no end-to-end encryption + No comment provided by engineer. + + + received answer… + received answer… + No comment provided by engineer. + + + rejected + rejected + call status + secret secret No comment provided by engineer. + + starting… + starting… + No comment provided by engineer. + strike strike @@ -915,6 +1041,16 @@ SimpleX servers cannot see your profile. via one-time link chat list item description + + waiting for answer… + waiting for answer… + No comment provided by engineer. + + + waiting for confirmation… + waiting for confirmation… + No comment provided by engineer. + wants to connect to you! wants to connect to you! @@ -953,10 +1089,150 @@ SimpleX servers cannot see your profile. Bundle name - SimpleX needs camera access to scan QR codes to connect to other app users - SimpleX needs camera access to scan QR codes to connect to other app users + SimpleX needs camera access to scan QR codes to connect to other users and for video calls. + SimpleX needs camera access to scan QR codes to connect to other users and for video calls. Privacy - Camera Usage Description + + SimpleX needs microphone access for audio and video calls. + SimpleX needs microphone access for audio and video calls. + Privacy - Microphone Usage Description + + + SimpleX needs access to Photo Library for saving captured and received media + SimpleX needs access to Photo Library for saving captured and received media + Privacy - Photo Library Additions Usage Description + + + + +
+ +
+ + + SimpleX NSE + SimpleX NSE + Bundle display name + + + SimpleX NSE + SimpleX NSE + Bundle name + + + Copyright © 2022 SimpleX Chat. All rights reserved. + Copyright © 2022 SimpleX Chat. All rights reserved. + Copyright (human-readable) + + +
+ +
+ +
+ + + %@ is connected! + %@ is connected! + notification title + + + %@ wants to connect! + %@ wants to connect! + notification title + + + Accept contact request from %@? + Accept contact request from %@? + notification body + + + Incoming %@ call + Incoming %@ call + notification body + + + You can now send messages to %@ + You can now send messages to %@ + notification body + + + accepted + accepted + call status + + + calling… + calling… + call status + + + connecting… + connecting… + call status + chat list item title + + + connection established + connection established + chat list item title (it should not be shown + + + connection:%@ + connection:%@ + connection information + + + deleted + deleted + deleted chat item + + + ended %1$d:%2$d + ended %1$d:%2$d + call status + + + error + error + call status + + + in progress + in progress + call status + + + invited to connect + invited to connect + chat list item title + + + missed + missed + call status + + + rejected + rejected + call status + + + via contact address link + via contact address link + chat list item description + + + via one-time link + via one-time link + chat list item description + + + you shared one-time link + you shared one-time link + chat list item description +
diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/SimpleX NSE/en.lproj/InfoPlist.strings b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/SimpleX NSE/en.lproj/InfoPlist.strings new file mode 100644 index 0000000000..124ddbcc33 --- /dev/null +++ b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/SimpleX NSE/en.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ +/* Bundle display name */ +"CFBundleDisplayName" = "SimpleX NSE"; +/* Bundle name */ +"CFBundleName" = "SimpleX NSE"; +/* Copyright (human-readable) */ +"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved."; diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings new file mode 100644 index 0000000000000000000000000000000000000000..e0e63e6f6ff0c7c44c65a72418931493b4148ff5 GIT binary patch literal 2690 zcmb_e+fKtU4E6KAA|OBr2@QAzA>ffu;IUgfR$v`;9TPtfoRciXZMUugR5kN*v5)Oz zr(d5#IT0g;%%zfCCV0n^Sb2_FW2H)7B!he+Lm9}v9Lg>J<+8vy#>oOt2C2fB${uzY zxyIXP=M^*#p!c^ZRzO?8GO<*^(nQoo)8|A#R3q3+DbDK*#ht2qRQKB1qxTcwE)h-L1w2wOni+Lt(+rLFl=Rau4^_5set=whx6It|Z0VkB1I=6sA!=I;rwzeNN7 z_DWrES7#!>7tmP(G4G@t`O{s8QA`j)SJ&aDO5j=g!ztHm3wc)Tia+`PsOd{4r`E3- zq%NFYdo{9{ig-XYmo|F*260_}g*e`KK3MvGZSg@h2VXZ!k-#k^lez literal 0 HcmV?d00001 diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 39dfcd9dc5..60ab866015 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -1,4 +1,8 @@ /* Bundle name */ "CFBundleName" = "SimpleX"; /* Privacy - Camera Usage Description */ -"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other app users"; +"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; +/* Privacy - Microphone Usage Description */ +"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls."; +/* Privacy - Photo Library Additions Usage Description */ +"NSPhotoLibraryAddUsageDescription" = "SimpleX needs access to Photo Library for saving captured and received media"; diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index db6e5fbd4f..774579c711 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -125,11 +125,21 @@ Все контакты, которые соединились через этот адрес, сохранятся. No comment provided by engineer. + + Answer + Ответить + accept incoming call via notification + Attach Прикрепить No comment provided by engineer. + + Call already ended! + Звонок уже завершен! + No comment provided by engineer. + Cancel Отменить @@ -160,6 +170,11 @@ Проверяются новые сообщения... notification + + Choose file + Выбрать файл + Attachment option to choose file. + Choose from library Выбрать из библиотеки @@ -285,6 +300,11 @@ Создать профиль No comment provided by engineer. + + Currently maximum supported file size is %@. + Максимальный размер файла - %@. + No comment provided by engineer. + Delete Удалить @@ -395,6 +415,11 @@ Ошибка: неверная ссылка No comment provided by engineer. + + File will be received when your contact is online, please wait or check later! + Файл будет принят, когда ваш контакт будет в сети, подождите или проверьте позже! + No comment provided by engineer. + Full name (optional) Полное имя (не обязательно) @@ -440,6 +465,26 @@ Если вы получили ссылку с приглашением из SimpleX Chat, вы можете открыть её в браузере: No comment provided by engineer. + + Ignore + Не отвечать + ignore incoming call via notification + + + Image will be received when your contact is online, please wait or check later! + Изображение будет принято, когда ваш контакт будет в сети, подождите или проверьте позже! + No comment provided by engineer. + + + Incoming %@ call + Входящий звонок + notification body + + + Incoming call + Входящий звонок + notification + Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) [SimpleX Chat для терминала](https://github.com/simplex-chat/simplex-chat) @@ -450,6 +495,11 @@ Ошибка в ссылке контакта No comment provided by engineer. + + Large file! + Большой файл! + No comment provided by engineer. + Make sure SMP server addresses are in correct format, line separated and are not duplicated. Пожалуйста, проверьте, что адреса SMP серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется. @@ -700,6 +750,16 @@ To connect, please ask your contact to create another connection link and check Используются серверы, предоставленные SimpleX Chat. No comment provided by engineer. + + Waiting for file + Ожидается прием файла + No comment provided by engineer. + + + Waiting for image + Ожидается прием изображения + No comment provided by engineer. + Welcome %@! Здравствуйте %@! @@ -802,6 +862,11 @@ You can cancel this connection and remove the contact (and try later with a new Вы можете отменить соединение и удалить контакт (и попробовать позже с другой ссылкой). No comment provided by engineer. + + Your contact sent a file that is larger than currently supported maximum size (%@). + Ваш контакт отправил файл, размер которого превышает максимальный размер (%@). + No comment provided by engineer. + Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. @@ -844,11 +909,21 @@ SimpleX серверы не могут получить доступ к ваше наверху, затем: No comment provided by engineer. + + accepted + принятый звонок + call status + bold жирный No comment provided by engineer. + + calling… + входящий звонок… + call status + colored цвет @@ -859,10 +934,15 @@ SimpleX серверы не могут получить доступ к ваше соединитесь с разработчиками. No comment provided by engineer. + + connected + соединение установлено + No comment provided by engineer. + connecting… соединяется… - chat list item title + call status chat list item title connection established @@ -879,6 +959,26 @@ SimpleX серверы не могут получить доступ к ваше удалено deleted chat item + + end-to-end encrypted + end-to-end зашифрованный звонок + No comment provided by engineer. + + + ended %1$d:%2$d + звонок завершён %1$d:%2$d + call status + + + error + ошибка соединения + call status + + + in progress + активный звонок + call status + invited to connect приглашение соединиться @@ -889,11 +989,36 @@ SimpleX серверы не могут получить доступ к ваше курсив No comment provided by engineer. + + missed + пропущенный звонок + call status + + + no end-to-end encryption + end-to-end шифрование не поддерживается + No comment provided by engineer. + + + received answer… + получен ответ… + No comment provided by engineer. + + + rejected + отклоненный звонок + call status + secret секрет No comment provided by engineer. + + starting… + инициализация… + No comment provided by engineer. + strike зачеркнуть @@ -914,6 +1039,16 @@ SimpleX серверы не могут получить доступ к ваше через одноразовую ссылку chat list item description + + waiting for answer… + ожидается ответ… + No comment provided by engineer. + + + waiting for confirmation… + ожидается подтверждение… + No comment provided by engineer. + wants to connect to you! хочет соединиться с вами! @@ -952,10 +1087,150 @@ SimpleX серверы не могут получить доступ к ваше Bundle name - SimpleX needs camera access to scan QR codes to connect to other app users - SimpleX использует камеру для сканирования QR кодов при соединении с другими пользователями + SimpleX needs camera access to scan QR codes to connect to other users and for video calls. + SimpleX использует камеру для сканирования QR кодов при соединении с другими пользователями и для видео звонков. Privacy - Camera Usage Description + + SimpleX needs microphone access for audio and video calls. + SimpleX использует микрофон для аудио и видео звонков. + Privacy - Microphone Usage Description + + + SimpleX needs access to Photo Library for saving captured and received media + SimpleX использует доступ к Photo Library для сохранения сделанных и полученных фотографий + Privacy - Photo Library Additions Usage Description + + + + +
+ +
+ + + SimpleX NSE + SimpleX NSE + Bundle display name + + + SimpleX NSE + SimpleX NSE + Bundle name + + + Copyright © 2022 SimpleX Chat. All rights reserved. + Copyright © 2022 SimpleX Chat. Все права зарезервированы. + Copyright (human-readable) + + +
+ +
+ +
+ + + %@ is connected! + соединение с %@ установлено! + notification title + + + %@ wants to connect! + %@ хочет соединиться! + notification title + + + Accept contact request from %@? + Принять запрос на соединение от %@? + notification body + + + Incoming %@ call + Входящий звонок + notification body + + + You can now send messages to %@ + Вы можете отправлять сообщения %@ + notification body + + + accepted + принятый звонок + call status + + + calling… + входящий звонок… + call status + + + connecting… + соединяется… + call status + chat list item title + + + connection established + соединение установлено + chat list item title (it should not be shown + + + connection:%@ + connection:%@ + connection information + + + deleted + удалено + deleted chat item + + + ended %1$d:%2$d + звонок завершён %1$d:%2$d + call status + + + error + ошибка соединения + call status + + + in progress + активный звонок + call status + + + invited to connect + приглашение соединиться + chat list item title + + + missed + пропущенный звонок + call status + + + rejected + отклоненный звонок + call status + + + via contact address link + через ссылку-контакт + chat list item description + + + via one-time link + через одноразовую ссылку + chat list item description + + + you shared one-time link + вы создали одноразовую ссылку + chat list item description +
diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/SimpleX NSE/en.lproj/InfoPlist.strings b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/SimpleX NSE/en.lproj/InfoPlist.strings new file mode 100644 index 0000000000..124ddbcc33 --- /dev/null +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/SimpleX NSE/en.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ +/* Bundle display name */ +"CFBundleDisplayName" = "SimpleX NSE"; +/* Bundle name */ +"CFBundleName" = "SimpleX NSE"; +/* Copyright (human-readable) */ +"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved."; diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings new file mode 100644 index 0000000000000000000000000000000000000000..e0e63e6f6ff0c7c44c65a72418931493b4148ff5 GIT binary patch literal 2690 zcmb_e+fKtU4E6KAA|OBr2@QAzA>ffu;IUgfR$v`;9TPtfoRciXZMUugR5kN*v5)Oz zr(d5#IT0g;%%zfCCV0n^Sb2_FW2H)7B!he+Lm9}v9Lg>J<+8vy#>oOt2C2fB${uzY zxyIXP=M^*#p!c^ZRzO?8GO<*^(nQoo)8|A#R3q3+DbDK*#ht2qRQKB1qxTcwE)h-L1w2wOni+Lt(+rLFl=Rau4^_5set=whx6It|Z0VkB1I=6sA!=I;rwzeNN7 z_DWrES7#!>7tmP(G4G@t`O{s8QA`j)SJ&aDO5j=g!ztHm3wc)Tia+`PsOd{4r`E3- zq%NFYdo{9{ig-XYmo|F*260_}g*e`KK3MvGZSg@h2VXZ!k-#k^lez literal 0 HcmV?d00001 diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 39dfcd9dc5..60ab866015 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -1,4 +1,8 @@ /* Bundle name */ "CFBundleName" = "SimpleX"; /* Privacy - Camera Usage Description */ -"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other app users"; +"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; +/* Privacy - Microphone Usage Description */ +"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls."; +/* Privacy - Photo Library Additions Usage Description */ +"NSPhotoLibraryAddUsageDescription" = "SimpleX needs access to Photo Library for saving captured and received media"; diff --git a/apps/ios/SimpleX NSE/ru.lproj/InfoPlist.strings b/apps/ios/SimpleX NSE/ru.lproj/InfoPlist.strings new file mode 100644 index 0000000000..d41717da3c --- /dev/null +++ b/apps/ios/SimpleX NSE/ru.lproj/InfoPlist.strings @@ -0,0 +1,9 @@ +/* Bundle display name */ +"CFBundleDisplayName" = "SimpleX NSE"; + +/* Bundle name */ +"CFBundleName" = "SimpleX NSE"; + +/* Copyright (human-readable) */ +"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. Все права зарезервированы."; + diff --git a/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings b/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings new file mode 100644 index 0000000000..44b29f3ae0 --- /dev/null +++ b/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings @@ -0,0 +1,61 @@ +/* notification title */ +"%@ is connected!" = "соединение с %@ установлено!"; + +/* notification title */ +"%@ wants to connect!" = "%@ хочет соединиться!"; + +/* notification body */ +"Accept contact request from %@?" = "Принять запрос на соединение от %@?"; + +/* call status */ +"accepted" = "принятый звонок"; + +/* call status */ +"calling…" = "входящий звонок…"; + +/* call status + chat list item title */ +"connecting…" = "соединяется…"; + +/* chat list item title (it should not be shown */ +"connection established" = "соединение установлено"; + +/* connection information */ +"connection:%@" = "connection:%@"; + +/* deleted chat item */ +"deleted" = "удалено"; + +/* call status */ +"ended %02d:%02d" = "звонок завершён %1$d:%2$d"; + +/* call status */ +"error" = "ошибка соединения"; + +/* call status */ +"in progress" = "активный звонок"; + +/* notification body */ +"Incoming %@ call" = "Входящий звонок"; + +/* chat list item title */ +"invited to connect" = "приглашение соединиться"; + +/* call status */ +"missed" = "пропущенный звонок"; + +/* call status */ +"rejected" = "отклоненный звонок"; + +/* chat list item description */ +"via contact address link" = "через ссылку-контакт"; + +/* chat list item description */ +"via one-time link" = "через одноразовую ссылку"; + +/* notification body */ +"You can now send messages to %@" = "Вы можете отправлять сообщения %@"; + +/* chat list item description */ +"you shared one-time link" = "вы создали одноразовую ссылку"; + diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 2b4dea2111..98d363265b 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -57,6 +57,8 @@ 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C4279559F40002BEB4 /* ContentView.swift */; }; 5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5CA059C5279559F40002BEB4 /* Assets.xcassets */; }; 5CA05A4C27974EB60002BEB4 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */; }; + 5CB0BA882826CB3A00B3292C /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */; }; + 5CB0BA8B2826CB3A00B3292C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CB0BA892826CB3A00B3292C /* Localizable.strings */; }; 5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D327A853F100ACCCDD /* SettingsButton.swift */; }; 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D627A8563F00ACCCDD /* SettingsView.swift */; }; 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E027A867BA00ACCCDD /* UserProfile.swift */; }; @@ -190,6 +192,8 @@ 5CA059DB279559F40002BEB4 /* Tests_iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOS.swift; sourceTree = ""; }; 5CA059DD279559F40002BEB4 /* Tests_iOSLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOSLaunchTests.swift; sourceTree = ""; }; 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; + 5CB0BA872826CB3A00B3292C /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; + 5CB0BA8A2826CB3A00B3292C /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 5CB924D327A853F100ACCCDD /* SettingsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsButton.swift; sourceTree = ""; }; 5CB924D627A8563F00ACCCDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 5CB924E027A867BA00ACCCDD /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; }; @@ -460,6 +464,8 @@ 5CDCAD5128186DE400503DA2 /* SimpleX NSE.entitlements */, 5CDCAD472818589900503DA2 /* NotificationService.swift */, 5CDCAD492818589900503DA2 /* Info.plist */, + 5CB0BA892826CB3A00B3292C /* Localizable.strings */, + 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */, 5CDCAD5728187C7500503DA2 /* dummy.m */, 5CDCAD5628187C7500503DA2 /* SimpleX NSE-Bridging-Header.h */, ); @@ -634,6 +640,8 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5CB0BA882826CB3A00B3292C /* InfoPlist.strings in Resources */, + 5CB0BA8B2826CB3A00B3292C /* Localizable.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -753,6 +761,22 @@ /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ + 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + 5CB0BA872826CB3A00B3292C /* ru */, + ); + name = InfoPlist.strings; + sourceTree = ""; + }; + 5CB0BA892826CB3A00B3292C /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + 5CB0BA8A2826CB3A00B3292C /* ru */, + ); + name = Localizable.strings; + sourceTree = ""; + }; 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */ = { isa = PBXVariantGroup; children = ( diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 0df64ae7b9..3b49cb0de8 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -82,6 +82,9 @@ /* notification body */ "Accept contact request from %@?" = "Принять запрос на соединение от %@?"; +/* call status */ +"accepted" = "принятый звонок"; + /* No comment provided by engineer. */ "Add contact" = "Добавить контакт"; @@ -91,12 +94,21 @@ /* No comment provided by engineer. */ "All your contacts will remain connected" = "Все контакты, которые соединились через этот адрес, сохранятся."; +/* accept incoming call via notification */ +"Answer" = "Ответить"; + /* No comment provided by engineer. */ "Attach" = "Прикрепить"; /* No comment provided by engineer. */ "bold" = "жирный"; +/* No comment provided by engineer. */ +"Call already ended!" = "Звонок уже завершен!"; + +/* call status */ +"calling…" = "входящий звонок…"; + /* No comment provided by engineer. */ "Cancel" = "Отменить"; @@ -115,6 +127,9 @@ /* notification */ "Checking new messages..." = "Проверяются новые сообщения..."; +/* No comment provided by engineer. */ +"Choose file" = "Выбрать файл"; + /* No comment provided by engineer. */ "Choose from library" = "Выбрать из библиотеки"; @@ -145,13 +160,17 @@ /* No comment provided by engineer. */ "Connect via one-time link?" = "Соединиться через одноразовую ссылку?"; +/* No comment provided by engineer. */ +"connected" = "соединение установлено"; + /* No comment provided by engineer. */ "Connecting server…" = "Устанавливается соединение с сервером…"; /* No comment provided by engineer. */ "Connecting server… (error: %@)" = "Устанавливается соединение с сервером… (ошибка: %@)"; -/* chat list item title */ +/* call status + chat list item title */ "connecting…" = "соединяется…"; /* No comment provided by engineer. */ @@ -205,6 +224,9 @@ /* No comment provided by engineer. */ "Create profile" = "Создать профиль"; +/* No comment provided by engineer. */ +"Currently maximum supported file size is %@." = "Максимальный размер файла - %@."; + /* No comment provided by engineer. */ "Delete" = "Удалить"; @@ -256,9 +278,18 @@ /* No comment provided by engineer. */ "Enable notifications? (BETA)" = "Включить уведомления? (БЕТА)"; +/* No comment provided by engineer. */ +"end-to-end encrypted" = "end-to-end зашифрованный звонок"; + +/* call status */ +"ended %02d:%02d" = "звонок завершён %1$d:%2$d"; + /* No comment provided by engineer. */ "Enter one SMP server per line:" = "Введите SMP серверы, каждый на отдельной строке:"; +/* call status */ +"error" = "ошибка соединения"; + /* No comment provided by engineer. */ "Error deleting token" = "Ошибка удаления токена"; @@ -274,6 +305,9 @@ /* No comment provided by engineer. */ "Error: URL is invalid" = "Ошибка: неверная ссылка"; +/* No comment provided by engineer. */ +"File will be received when your contact is online, please wait or check later!" = "Файл будет принят, когда ваш контакт будет в сети, подождите или проверьте позже!"; + /* No comment provided by engineer. */ "Full name (optional)" = "Полное имя (не обязательно)"; @@ -301,6 +335,21 @@ /* No comment provided by engineer. */ "If you received SimpleX Chat invitation link you can open it in your browser:" = "Если вы получили ссылку с приглашением из SimpleX Chat, вы можете открыть её в браузере:"; +/* ignore incoming call via notification */ +"Ignore" = "Не отвечать"; + +/* No comment provided by engineer. */ +"Image will be received when your contact is online, please wait or check later!" = "Изображение будет принято, когда ваш контакт будет в сети, подождите или проверьте позже!"; + +/* call status */ +"in progress" = "активный звонок"; + +/* notification body */ +"Incoming %@ call" = "Входящий звонок"; + +/* notification */ +"Incoming call" = "Входящий звонок"; + /* No comment provided by engineer. */ "Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "[SimpleX Chat для терминала](https://github.com/simplex-chat/simplex-chat)"; @@ -313,6 +362,9 @@ /* No comment provided by engineer. */ "italic" = "курсив"; +/* No comment provided by engineer. */ +"Large file!" = "Большой файл!"; + /* No comment provided by engineer. */ "Make sure SMP server addresses are in correct format, line separated and are not duplicated." = "Пожалуйста, проверьте, что адреса SMP серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется."; @@ -322,6 +374,9 @@ /* No comment provided by engineer. */ "Message delivery error" = "Ошибка доставки сообщения"; +/* call status */ +"missed" = "пропущенный звонок"; + /* No comment provided by engineer. */ "Most likely this contact has deleted the connection with you." = "Скорее всего, этот контакт удалил соединение с вами."; @@ -331,6 +386,9 @@ /* notification */ "New message" = "Новое сообщение"; +/* No comment provided by engineer. */ +"no end-to-end encryption" = "end-to-end шифрование не поддерживается"; + /* No comment provided by engineer. */ "Notifications are disabled!" = "Уведомления выключены"; @@ -358,6 +416,9 @@ /* No comment provided by engineer. */ "Read" = "Прочитано"; +/* No comment provided by engineer. */ +"received answer…" = "получен ответ…"; + /* No comment provided by engineer. */ "Reject" = "Отклонить"; @@ -367,6 +428,9 @@ /* No comment provided by engineer. */ "Reject contact request" = "Отклонить запрос"; +/* call status */ +"rejected" = "отклоненный звонок"; + /* No comment provided by engineer. */ "Reply" = "Ответить"; @@ -409,6 +473,9 @@ /* No comment provided by engineer. */ "SMP servers" = "SMP серверы"; +/* No comment provided by engineer. */ +"starting…" = "инициализация…"; + /* No comment provided by engineer. */ "strike" = "зачеркнуть"; @@ -475,6 +542,18 @@ /* chat list item description */ "via one-time link" = "через одноразовую ссылку"; +/* No comment provided by engineer. */ +"waiting for answer…" = "ожидается ответ…"; + +/* No comment provided by engineer. */ +"waiting for confirmation…" = "ожидается подтверждение…"; + +/* No comment provided by engineer. */ +"Waiting for file" = "Ожидается прием файла"; + +/* No comment provided by engineer. */ +"Waiting for image" = "Ожидается прием изображения"; + /* No comment provided by engineer. */ "wants to connect to you!" = "хочет соединиться с вами!"; @@ -535,6 +614,9 @@ /* No comment provided by engineer. */ "Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)." = "Ваш контакт должен быть в сети чтобы установить соединение.\nВы можете отменить соединение и удалить контакт (и попробовать позже с другой ссылкой)."; +/* No comment provided by engineer. */ +"Your contact sent a file that is larger than currently supported maximum size (%@)." = "Ваш контакт отправил файл, размер которого превышает максимальный размер (%@)."; + /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "Ваш профиль хранится на вашем устройстве и отправляется только вашим контактам.\nSimpleX серверы не могут получить доступ к вашему профилю."; diff --git a/apps/ios/ru.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/ru.lproj/SimpleX--iOS--InfoPlist.strings index a8fa77d9f6..4dc64e9475 100644 --- a/apps/ios/ru.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/ru.lproj/SimpleX--iOS--InfoPlist.strings @@ -2,5 +2,11 @@ "CFBundleName" = "SimpleX"; /* Privacy - Camera Usage Description */ -"NSCameraUsageDescription" = "SimpleX использует камеру для сканирования QR кодов при соединении с другими пользователями"; +"NSCameraUsageDescription" = "SimpleX использует камеру для сканирования QR кодов при соединении с другими пользователями и для видео звонков."; + +/* Privacy - Microphone Usage Description */ +"NSMicrophoneUsageDescription" = "SimpleX использует микрофон для аудио и видео звонков."; + +/* Privacy - Photo Library Additions Usage Description */ +"NSPhotoLibraryAddUsageDescription" = "SimpleX использует доступ к Photo Library для сохранения сделанных и полученных фотографий"; From 8773a058bfd8b35cb01c80fa774066fdf69156eb Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Sat, 7 May 2022 21:25:29 +0400 Subject: [PATCH 69/95] delete from files; delete from chat_items --- src/Simplex/Chat/Store.hs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 3da0994e01..3f3cf3e9ff 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -443,6 +443,10 @@ deleteContact st userId Contact {contactId, localDisplayName} = do ) |] (userId, contactId) + liftIO $ putStrLn "DB.execute db \"DELETE FROM files WHERE user_id = ? AND contact_id = ?\" (userId, contactId)" + DB.execute db "DELETE FROM files WHERE user_id = ? AND contact_id = ?" (userId, contactId) + liftIO $ putStrLn "DB.execute db \"DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?\" (userId, contactId)" + DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId) liftIO $ putStrLn "DB.execute db \"DELETE FROM contacts WHERE user_id = ? AND contact_id = ?\" (userId, contactId)" DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId) liftIO $ putStrLn "DB.execute db \"DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?\" (userId, localDisplayName)" From 58b37bf1146105e6a10093a2fca66a191b912769 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 7 May 2022 21:09:57 +0100 Subject: [PATCH 70/95] mobile: add v2 to "choose file" label (#616) * ios: add v2 to "choose file" label * android: add v2 to "choose file" label * fix translations --- .../app/src/main/res/values-ru/strings.xml | 6 +++--- .../app/src/main/res/values/strings.xml | 2 +- .../en.xcloc/Localized Contents/en.xliff | 4 ++-- .../SimpleX NSE/en.lproj/Localizable.strings | Bin 2690 -> 2686 bytes .../ru.xcloc/Localized Contents/ru.xliff | 11 ++++++----- .../SimpleX NSE/en.lproj/Localizable.strings | Bin 2690 -> 2686 bytes apps/ios/en.lproj/Localizable.strings | 3 +++ apps/ios/ru.lproj/Localizable.strings | 2 +- 8 files changed, 16 insertions(+), 12 deletions(-) diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index 0531c2b4b6..8e2d39232a 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -141,9 +141,9 @@ Разрешение не получено! - Использовать камеру - Открыть галерею - Выбрать файл + Камера + Галерея + Файлы\n(v2.0) Спасибо, что установили SimpleX Chat! diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 774d6e8c2f..e6bcd98aa3 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -144,7 +144,7 @@ Permission Denied! Use Camera From Gallery - Choose file + Choose file\n(new in v2.0) Thank you for installing SimpleX Chat! diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 4e728c6920..70fcfcdd2d 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -172,7 +172,7 @@ Choose file - Choose file + Choose file (new in v2.0) No comment provided by engineer. @@ -944,7 +944,7 @@ SimpleX servers cannot see your profile. connecting… connecting… call status -chat list item title + chat list item title connection established diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings index e0e63e6f6ff0c7c44c65a72418931493b4148ff5..7a817163129dde4c113706b83d4d2009a2ea0f2f 100644 GIT binary patch delta 38 scmZn?{U@@)iCs{ML4m;*h?N*}88R7)8Hy*bXBVAp$0WD8i~R=^0I?4Vz5oCK delta 43 vcmew-(j>aUiCs!fL5V?u!4`;>7;+gh8H#~0l_7;eO<}Sgliubw_8&|D&YcOm diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 774579c711..4b34919995 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -172,8 +172,8 @@ Choose file - Выбрать файл - Attachment option to choose file. + Выбрать файл (v2.0) + No comment provided by engineer. Choose from library @@ -942,7 +942,8 @@ SimpleX серверы не могут получить доступ к ваше connecting… соединяется… - call status chat list item title + call status + chat list item title connection established @@ -1103,7 +1104,7 @@ SimpleX серверы не могут получить доступ к ваше - +
@@ -1125,7 +1126,7 @@ SimpleX серверы не могут получить доступ к ваше
- +
diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings index e0e63e6f6ff0c7c44c65a72418931493b4148ff5..7a817163129dde4c113706b83d4d2009a2ea0f2f 100644 GIT binary patch delta 38 scmZn?{U@@)iCs{ML4m;*h?N*}88R7)8Hy*bXBVAp$0WD8i~R=^0I?4Vz5oCK delta 43 vcmew-(j>aUiCs!fL5V?u!4`;>7;+gh8H#~0l_7;eO<}Sgliubw_8&|D&YcOm diff --git a/apps/ios/en.lproj/Localizable.strings b/apps/ios/en.lproj/Localizable.strings index 97efd9d6d7..400ef08d21 100644 --- a/apps/ios/en.lproj/Localizable.strings +++ b/apps/ios/en.lproj/Localizable.strings @@ -1,3 +1,6 @@ +/* No comment provided by engineer. */ +"Choose file" = "Choose file (new in v2.0)"; + /* No comment provided by engineer. */ "Connecting server…" = "Connecting to server…"; diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 3b49cb0de8..69ce78f781 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -128,7 +128,7 @@ "Checking new messages..." = "Проверяются новые сообщения..."; /* No comment provided by engineer. */ -"Choose file" = "Выбрать файл"; +"Choose file" = "Выбрать файл (v2.0)"; /* No comment provided by engineer. */ "Choose from library" = "Выбрать из библиотеки"; From ca4d1910db21cc2c1b64bff48a22313a8d7d3050 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Sun, 8 May 2022 22:03:45 +0400 Subject: [PATCH 71/95] split delete transaction (#620) --- src/Simplex/Chat/Store.hs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 3f3cf3e9ff..7c9cc4b28e 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -429,7 +429,7 @@ getContactGroupNames st userId Contact {contactId} = deleteContact :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> m () deleteContact st userId Contact {contactId, localDisplayName} = do - liftIO $ putStrLn "in deleteContact, liftIO . withTransaction st $ \\db -> do" + liftIO $ putStrLn "in deleteContact, first liftIO . withTransaction st $ \\db -> do" liftIO . withTransaction st $ \db -> do liftIO $ putStrLn "DB.execute DELETE FROM connections WHERE connection_id IN ..." DB.execute @@ -445,6 +445,8 @@ deleteContact st userId Contact {contactId, localDisplayName} = do (userId, contactId) liftIO $ putStrLn "DB.execute db \"DELETE FROM files WHERE user_id = ? AND contact_id = ?\" (userId, contactId)" DB.execute db "DELETE FROM files WHERE user_id = ? AND contact_id = ?" (userId, contactId) + liftIO $ putStrLn "in deleteContact, second liftIO . withTransaction st $ \\db -> do" + liftIO . withTransaction st $ \db -> do liftIO $ putStrLn "DB.execute db \"DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?\" (userId, contactId)" DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId) liftIO $ putStrLn "DB.execute db \"DELETE FROM contacts WHERE user_id = ? AND contact_id = ?\" (userId, contactId)" From 1dc3190159846a5915c7c80ccc59c483723acf2f Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Mon, 9 May 2022 10:55:56 +0400 Subject: [PATCH 72/95] remove debug logs on deletion; android: context menu on contact link (#621) --- .../app/views/chat/item/ChatItemView.kt | 2 +- .../app/views/chatlist/ChatListNavLinkView.kt | 69 +++++++++++-------- src/Simplex/Chat.hs | 57 +++------------ src/Simplex/Chat/Store.hs | 8 +-- 4 files changed, 54 insertions(+), 82 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index 8aed21baf4..b140897837 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -118,7 +118,7 @@ fun ChatItemView( } @Composable -private fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = MaterialTheme.colors.onBackground) { +fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = MaterialTheme.colors.onBackground) { DropdownMenuItem(onClick) { Row { Text( diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt index 95bd343b1b..888447965b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt @@ -1,20 +1,22 @@ package chat.simplex.app.views.chatlist import android.content.res.Configuration -import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.material.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.toMutableStateList +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.chat.item.ItemAction import chat.simplex.app.views.helpers.* import kotlinx.datetime.Clock @@ -34,27 +36,22 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { } } }, - longClick = { - when (chat.chatInfo) { - is ChatInfo.Direct -> { - AlertManager.shared.showAlertMsg( - title = generalGetString(R.string.delete_contact__question), - text = generalGetString(R.string.delete_contact_all_messages_deleted_cannot_undo_warning), - confirmText = generalGetString(R.string.delete_verb), - onConfirm = { - val cInfo = chat.chatInfo - withApi { - val r = chatModel.controller.apiDeleteChat(cInfo.chatType, cInfo.apiId) - if (r) { - chatModel.removeChat(cInfo.id) - chatModel.chatId.value = null - } - } + deleteContact = { + AlertManager.shared.showAlertMsg( + title = generalGetString(R.string.delete_contact__question), + text = generalGetString(R.string.delete_contact_all_messages_deleted_cannot_undo_warning), + confirmText = generalGetString(R.string.delete_verb), + onConfirm = { + val cInfo = chat.chatInfo + withApi { + val r = chatModel.controller.apiDeleteChat(cInfo.chatType, cInfo.apiId) + if (r) { + chatModel.removeChat(cInfo.id) + chatModel.chatId.value = null } - ) + } } - else -> {} - } + ) }, ) } @@ -162,13 +159,14 @@ fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) { } @Composable -fun ChatListNavLinkLayout(chat: Chat, click: () -> Unit, longClick: () -> Unit) { +fun ChatListNavLinkLayout(chat: Chat, click: () -> Unit, deleteContact: () -> Unit) { + val showMenu = remember { mutableStateOf(false) } Surface( modifier = Modifier .fillMaxWidth() .combinedClickable( onClick = click, - onLongClick = longClick + onLongClick = { if (chat.chatInfo is ChatInfo.Direct) showMenu.value = true } ) .height(88.dp) ) { @@ -187,6 +185,23 @@ fun ChatListNavLinkLayout(chat: Chat, click: () -> Unit, longClick: () -> Unit) is ChatInfo.ContactConnection -> ContactConnectionView(chat.chatInfo.contactConnection) } } + if (chat.chatInfo is ChatInfo.Direct) { + DropdownMenu( + expanded = showMenu.value, + onDismissRequest = { showMenu.value = false }, + Modifier.width(220.dp) + ) { + ItemAction( + stringResource(R.string.delete_verb), + Icons.Outlined.Delete, + onClick = { + deleteContact() + showMenu.value = false + }, + color = Color.Red + ) + } + } } Divider(Modifier.padding(horizontal = 8.dp)) } @@ -214,7 +229,7 @@ fun PreviewChatListNavLinkDirect() { chatStats = Chat.ChatStats() ), click = {}, - longClick = {} + deleteContact = {} ) } } @@ -242,7 +257,7 @@ fun PreviewChatListNavLinkGroup() { chatStats = Chat.ChatStats() ), click = {}, - longClick = {} + deleteContact = {} ) } } @@ -263,7 +278,7 @@ fun PreviewChatListNavLinkContactRequest() { chatStats = Chat.ChatStats() ), click = {}, - longClick = {} + deleteContact = {} ) } } diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index eb69eae86c..234d17298a 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -376,25 +376,15 @@ processChatCommand = \case ct@Contact {localDisplayName} <- withStore $ \st -> getContact st userId chatId withStore (\st -> getContactGroupNames st userId ct) >>= \case [] -> do - liftIO $ putStrLn "### files <- withStore $ \\st -> getContactFiles st userId ct" files <- withStore $ \st -> getContactFiles st userId ct - liftIO $ putStrLn "### conns <- withStore $ \\st -> getContactConnections st userId ct" conns <- withStore $ \st -> getContactConnections st userId ct - liftIO $ putStrLn "### withChatLock . procCmd $ do" withChatLock . procCmd $ do - liftIO $ putStrLn "### cancelFiles user (map (\\(fId, fStatus, _) -> (fId, fStatus)) files)" cancelFiles user (map (\(fId, fStatus, _) -> (fId, fStatus)) files) - liftIO $ putStrLn "### withFilesFolder $ \\filesFolder -> do" - withFilesFolder $ \filesFolder -> do - liftIO $ putStrLn "### deleteFiles filesFolder (map (\\(_, _, fPath) -> fPath) files)" + withFilesFolder $ \filesFolder -> deleteFiles filesFolder (map (\(_, _, fPath) -> fPath) files) - liftIO $ putStrLn "### withAgent $ \\a -> forM_ conns $ \\conn ->" - withAgent $ \a -> forM_ conns $ \conn -> do - liftIO $ putStrLn "### deleteConnection a (aConnId conn) `catchError` \\(_ :: AgentErrorType) -> pure ()" + withAgent $ \a -> forM_ conns $ \conn -> deleteConnection a (aConnId conn) `catchError` \(_ :: AgentErrorType) -> pure () - liftIO $ putStrLn "### withStore $ \\st -> deleteContact st userId ct" withStore $ \st -> deleteContact st userId ct - liftIO $ putStrLn "### unsetActive $ ActiveC localDisplayName" unsetActive $ ActiveC localDisplayName pure $ CRContactDeleted ct gs -> throwChatError $ CEContactGroups ct gs @@ -744,14 +734,11 @@ processChatCommand = \case withFilesFolder :: (FilePath -> m ()) -> m () withFilesFolder action = asks filesFolder >>= readTVarIO >>= mapM_ action deleteFiles :: FilePath -> [Maybe FilePath] -> m () - deleteFiles filesFolder filePaths = do + deleteFiles filesFolder filePaths = forM_ filePaths $ \filePath_ -> forM_ filePath_ $ \filePath -> do let fsFilePath = filesFolder <> "/" <> filePath - liftIO $ putStrLn ("in deleteFiles - fsFilePath " <> fsFilePath) - liftIO $ putStrLn "removeFile fsFilePath `E.catch` \\(_ :: E.SomeException) ->" - removeFile fsFilePath `E.catch` \(_ :: E.SomeException) -> do - liftIO $ putStrLn "removePathForcibly fsFilePath `E.catch` \\(_ :: E.SomeException) -> pure ()" + removeFile fsFilePath `E.catch` \(_ :: E.SomeException) -> removePathForcibly fsFilePath `E.catch` \(_ :: E.SomeException) -> pure () cancelFiles :: User -> [(Int64, ACIFileStatus)] -> m () cancelFiles user@User {userId} files = mapM_ maybeCancelFile files @@ -769,39 +756,26 @@ processChatCommand = \case AFS _ CIFSRcvComplete -> pure () cancelById :: Int64 -> m () cancelById fileId = do - liftIO $ putStrLn ("cancelById - fileId " <> show fileId) - liftIO $ putStrLn "ft <- withStore (\\st -> getFileTransfer st userId fileId)" ft <- withStore (\st -> getFileTransfer st userId fileId) - liftIO $ putStrLn "void $ cancelFile user fileId ft" void $ cancelFile user fileId ft cancelFile :: User -> Int64 -> FileTransfer -> m ChatResponse cancelFile user@User {userId} fileId ft = case ft of FTSnd ftm fts -> do - liftIO $ putStrLn "cancelFileTransfer CIFSSndCancelled" cancelFileTransfer CIFSSndCancelled - liftIO $ putStrLn "forM_ fts $ \\ft' -> cancelSndFileTransfer ft'" forM_ fts $ \ft' -> cancelSndFileTransfer ft' - liftIO $ putStrLn "ci <- withStore $ \\st -> getChatItemByFileId st user fileId" ci <- withStore $ \st -> getChatItemByFileId st user fileId - liftIO $ putStrLn "pure $ CRSndGroupFileCancelled ci ftm fts" pure $ CRSndGroupFileCancelled ci ftm fts FTRcv ftr -> do - liftIO $ putStrLn "cancelFileTransfer CIFSRcvCancelled" cancelFileTransfer CIFSRcvCancelled - liftIO $ putStrLn "cancelRcvFileTransfer ftr" cancelRcvFileTransfer ftr - liftIO $ putStrLn "pure $ CRRcvFileCancelled ftr" pure $ CRRcvFileCancelled ftr where cancelFileTransfer :: MsgDirectionI d => CIFileStatus d -> m () - cancelFileTransfer ciFileStatus = do - liftIO $ putStrLn "unless (fileTransferCancelled ft) $" + cancelFileTransfer ciFileStatus = unless (fileTransferCancelled ft) $ withStore $ \st -> do - liftIO $ putStrLn "updateFileCancelled st userId fileId" updateFileCancelled st userId fileId - liftIO $ putStrLn "updateCIFileStatus st userId fileId ciFileStatus" updateCIFileStatus st userId fileId ciFileStatus withCurrentCall :: ContactId -> (UserId -> Contact -> Call -> m (Maybe Call)) -> m ChatResponse withCurrentCall ctId action = withUser $ \User {userId} -> do @@ -1897,36 +1871,25 @@ isFileActive fileId files = do cancelRcvFileTransfer :: ChatMonad m => RcvFileTransfer -> m () cancelRcvFileTransfer ft@RcvFileTransfer {fileId, fileStatus} = do - liftIO $ putStrLn "closeFileHandle fileId rcvFiles" closeFileHandle fileId rcvFiles - liftIO $ putStrLn "withStore $ \\st -> do" withStore $ \st -> do - liftIO $ putStrLn "updateRcvFileStatus st ft FSCancelled" updateRcvFileStatus st ft FSCancelled - liftIO $ putStrLn "deleteRcvFileChunks st ft" deleteRcvFileChunks st ft case fileStatus of - RFSAccepted RcvFileInfo {agentConnId = AgentConnId acId} -> do - liftIO $ putStrLn "withAgent (`deleteConnection` acId)" + RFSAccepted RcvFileInfo {agentConnId = AgentConnId acId} -> withAgent (`deleteConnection` acId) - RFSConnected RcvFileInfo {agentConnId = AgentConnId acId} -> do - liftIO $ putStrLn "withAgent (`deleteConnection` acId)" + RFSConnected RcvFileInfo {agentConnId = AgentConnId acId} -> withAgent (`deleteConnection` acId) _ -> pure () cancelSndFileTransfer :: ChatMonad m => SndFileTransfer -> m () -cancelSndFileTransfer ft@SndFileTransfer {agentConnId = AgentConnId acId, fileStatus} = do - liftIO $ putStrLn "unless (fileStatus == FSCancelled || fileStatus == FSComplete) $ do" +cancelSndFileTransfer ft@SndFileTransfer {agentConnId = AgentConnId acId, fileStatus} = unless (fileStatus == FSCancelled || fileStatus == FSComplete) $ do withStore $ \st -> do - liftIO $ putStrLn "updateSndFileStatus st ft FSCancelled" updateSndFileStatus st ft FSCancelled - liftIO $ putStrLn "deleteSndFileChunks st ft" deleteSndFileChunks st ft withAgent $ \a -> do - liftIO $ putStrLn "void (sendMessage a acId $ smpEncode FileChunkCancel) `catchError` \\_ -> pure ()" void (sendMessage a acId $ smpEncode FileChunkCancel) `catchError` \_ -> pure () - liftIO $ putStrLn "deleteConnection a acId" deleteConnection a acId closeFileHandle :: ChatMonad m => Int64 -> (ChatController -> TVar (Map Int64 Handle)) -> m () @@ -2137,9 +2100,9 @@ withStore :: m a withStore action = asks chatStore - -- >>= runExceptT . action + >>= runExceptT . action -- use this line instead of above to log query errors - >>= (\st -> runExceptT $ action st `E.catch` \(e :: E.SomeException) -> liftIO (print e) >> E.throwIO e) + -- >>= (\st -> runExceptT $ action st `E.catch` \(e :: E.SomeException) -> liftIO (print e) >> E.throwIO e) >>= liftEither . first ChatErrorStore chatCommandP :: Parser ChatCommand diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 7c9cc4b28e..f9aafdbade 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -429,9 +429,7 @@ getContactGroupNames st userId Contact {contactId} = deleteContact :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> m () deleteContact st userId Contact {contactId, localDisplayName} = do - liftIO $ putStrLn "in deleteContact, first liftIO . withTransaction st $ \\db -> do" liftIO . withTransaction st $ \db -> do - liftIO $ putStrLn "DB.execute DELETE FROM connections WHERE connection_id IN ..." DB.execute db [sql| @@ -443,15 +441,11 @@ deleteContact st userId Contact {contactId, localDisplayName} = do ) |] (userId, contactId) - liftIO $ putStrLn "DB.execute db \"DELETE FROM files WHERE user_id = ? AND contact_id = ?\" (userId, contactId)" DB.execute db "DELETE FROM files WHERE user_id = ? AND contact_id = ?" (userId, contactId) - liftIO $ putStrLn "in deleteContact, second liftIO . withTransaction st $ \\db -> do" + -- in separate transaction to prevent crashes on android (race condition on integrity check?) liftIO . withTransaction st $ \db -> do - liftIO $ putStrLn "DB.execute db \"DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?\" (userId, contactId)" DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId) - liftIO $ putStrLn "DB.execute db \"DELETE FROM contacts WHERE user_id = ? AND contact_id = ?\" (userId, contactId)" DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId) - liftIO $ putStrLn "DB.execute db \"DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?\" (userId, localDisplayName)" DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) updateUserProfile :: StoreMonad m => SQLiteStore -> User -> Profile -> m () From 3d2315a11717e3bda2fa2ea39ea7b6e4e7918b7d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 9 May 2022 09:12:32 +0100 Subject: [PATCH 73/95] ios: update icons for edit/quote messages --- apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift | 4 ++-- .../Shared/Views/Chat/ComposeMessage/ContextItemView.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index dcd57ce578..1561426845 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -264,13 +264,13 @@ struct ComposeView: View { case let .quotedItem(chatItem: quotedItem): ContextItemView( contextItem: quotedItem, - contextIcon: "arrowshape.turn.up.left.circle", + contextIcon: "arrowshape.turn.up.left", cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) } ) case let .editingItem(chatItem: editingItem): ContextItemView( contextItem: editingItem, - contextIcon: "pencil.circle", + contextIcon: "pencil", cancelContextItem: { clearState() } ) } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index 0f22862d05..e59d7bfeef 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -19,7 +19,7 @@ struct ContextItemView: View { Image(systemName: contextIcon) .resizable() .aspectRatio(contentMode: .fit) - .frame(width: 20, height: 20) + .frame(width: 16, height: 16) .foregroundColor(.secondary) contextText(contextItem).lineLimit(3) Spacer() From dcaefd6566d6c7147701705e85cb3b533fd56a88 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 9 May 2022 09:52:09 +0100 Subject: [PATCH 74/95] mobile: onboarding (#618) * mobile: onboarding * ios onboarding: create profile and make connection * how SimpleX works * connect via link * remove separate view for connecting via link, fix bugs * remove unused files * fix help on small screens, update how it works page * layout * add About to settings, tidy up * rename function * update layout * translations * translation corrections Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> * correction Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> * corrections Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> * fix translations/layout Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> --- apps/ios/Shared/ContentView.swift | 62 +++-- apps/ios/Shared/Model/ChatModel.swift | 1 + apps/ios/Shared/Model/SimpleXAPI.swift | 40 ++- apps/ios/Shared/Views/ChatList/ChatHelp.swift | 21 +- .../Shared/Views/ChatList/ChatListView.swift | 45 +-- .../Shared/Views/NewChat/AddContactView.swift | 21 +- .../Shared/Views/NewChat/NewChatButton.swift | 46 +-- .../Views/NewChat/PasteToConnectView.swift | 12 +- .../Views/NewChat/ScanToConnectView.swift | 22 +- .../Views/Onboarding/CreateProfile.swift | 124 +++++++++ .../Shared/Views/Onboarding/HowItWorks.swift | 54 ++++ .../Views/Onboarding/MakeConnection.swift | 146 ++++++++++ .../Views/Onboarding/OnboardingView.swift | 35 +++ .../Shared/Views/Onboarding/SimpleXInfo.swift | 102 +++++++ .../Views/UserSettings/SettingsView.swift | 65 ++--- apps/ios/Shared/Views/WelcomeView.swift | 80 ------ .../en.xcloc/Localized Contents/en.xliff | 262 +++++++++++++----- .../en.lproj/Localizable.strings | 5 + .../ru.xcloc/Localized Contents/ru.xliff | 257 ++++++++++++----- .../en.lproj/Localizable.strings | 5 + apps/ios/SimpleX.xcodeproj/project.pbxproj | 32 ++- apps/ios/en.lproj/Localizable.strings | 2 + apps/ios/ru.lproj/Localizable.strings | 138 +++++++-- 23 files changed, 1170 insertions(+), 407 deletions(-) create mode 100644 apps/ios/Shared/Views/Onboarding/CreateProfile.swift create mode 100644 apps/ios/Shared/Views/Onboarding/HowItWorks.swift create mode 100644 apps/ios/Shared/Views/Onboarding/MakeConnection.swift create mode 100644 apps/ios/Shared/Views/Onboarding/OnboardingView.swift create mode 100644 apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift delete mode 100644 apps/ios/Shared/Views/WelcomeView.swift diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index b5c5b8392a..64eeb2fc1f 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -13,27 +13,22 @@ struct ContentView: View { @State private var showNotificationAlert = false var body: some View { - if let user = chatModel.currentUser { - ChatListView(user: user) - .onAppear { - do { - try apiStartChat() - try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) - chatModel.userAddress = try apiGetUserAddress() - chatModel.userSMPServers = try getUserSMPServers() - chatModel.chats = try apiGetChats() - } catch { - fatalError("Failed to start or load chats: \(error)") + ZStack { + if let step = chatModel.onboardingStage { + if case .onboardingComplete = step, + let user = chatModel.currentUser { + ChatListView(user: user) + .onAppear { + NtfManager.shared.requestAuthorization(onDeny: { + alertManager.showAlert(notificationAlert()) + }) } - ChatReceiver.shared.start() - NtfManager.shared.requestAuthorization(onDeny: { - alertManager.showAlert(notificationAlert()) - }) + } else { + OnboardingView(onboarding: step) } - .alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! } - } else { - WelcomeView() + } } + .alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! } } func notificationAlert() -> Alert { @@ -50,6 +45,37 @@ struct ContentView: View { } } +func connectViaUrl() { + let m = ChatModel.shared + if let url = m.appOpenUrl { + m.appOpenUrl = nil + AlertManager.shared.showAlert(connectViaUrlAlert(url)) + } +} + +func connectViaUrlAlert(_ url: URL) -> Alert { + var path = url.path + logger.debug("ChatListView.connectViaUrlAlert path: \(path)") + if (path == "/contact" || path == "/invitation") { + path.removeFirst() + let action: ConnReqType = path == "contact" ? .contact : .invitation + let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") + let title: LocalizedStringKey + if case .contact = action { title = "Connect via contact link?" } + else { title = "Connect via one-time link?" } + return Alert( + title: Text(title), + message: Text("Your profile will be sent to the contact that you received this link from"), + primaryButton: .default(Text("Connect")) { + connectViaLink(link) + }, + secondaryButton: .cancel() + ) + } else { + return Alert(title: Text("Error: URL is invalid")) + } +} + final class AlertManager: ObservableObject { static let shared = AlertManager() @Published var presentAlert = false diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 3fcc40b16a..63b9f04ff8 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -11,6 +11,7 @@ import Combine import SwiftUI final class ChatModel: ObservableObject { + @Published var onboardingStage: OnboardingStage? @Published var currentUser: User? // list of chat "previews" @Published var chats: [Chat] = [] diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 394fb6c266..9f02f52206 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -10,6 +10,7 @@ import Foundation import UIKit import Dispatch import BackgroundTasks +import SwiftUI private var chatController: chat_ctrl? @@ -261,6 +262,16 @@ func apiConnect(connReq: String) async throws -> ConnReqType? { message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." ) return nil + case let .chatCmdError(.errorAgent(.INTERNAL(internalErr))): + if internalErr == "SEUniqueID" { + am.showAlertMsg( + title: "Already connected?", + message: "It seems like you are already connected via this link. If it is not the case, there was an error (\(responseError(r)))." + ) + return nil + } else { + throw r + } default: throw r } } @@ -424,13 +435,40 @@ private func sendCommandOkResp(_ cmd: ChatCommand) async throws { } func initializeChat() { + logger.debug("initializeChat") do { - ChatModel.shared.currentUser = try apiGetActiveUser() + let m = ChatModel.shared + m.currentUser = try apiGetActiveUser() + if m.currentUser == nil { + m.onboardingStage = .step1_SimpleXInfo + } else { + startChat() + } } catch { fatalError("Failed to initialize chat controller or database: \(error)") } } +func startChat() { + logger.debug("startChat") + do { + let m = ChatModel.shared + try apiStartChat() + try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) + m.userAddress = try apiGetUserAddress() + m.userSMPServers = try getUserSMPServers() + m.chats = try apiGetChats() + withAnimation { + m.onboardingStage = m.chats.isEmpty + ? .step3_MakeConnection + : .onboardingComplete + } + ChatReceiver.shared.start() + } catch { + fatalError("Failed to start or load chats: \(error)") + } +} + class ChatReceiver { private var receiveLoop: Task? private var receiveMessages = true diff --git a/apps/ios/Shared/Views/ChatList/ChatHelp.swift b/apps/ios/Shared/Views/ChatList/ChatHelp.swift index 5f83f22d95..7c02bd64e0 100644 --- a/apps/ios/Shared/Views/ChatList/ChatHelp.swift +++ b/apps/ios/Shared/Views/ChatList/ChatHelp.swift @@ -28,30 +28,19 @@ struct ChatHelp: View { } VStack(alignment: .leading, spacing: 10) { - Text("To start a new chat") + Text("To make a new connection") .font(.title2) .fontWeight(.bold) HStack(spacing: 8) { Text("Tap button ") NewChatButton() - Text("above, then:") + Text("above, then choose:") } - Text("**Add new contact**: to create your one-time QR Code for your contact.") - Text("**Scan QR code**: to connect to your contact who shows QR code to you.") - } - .padding(.top, 24) - - VStack(alignment: .leading, spacing: 10) { - Text("To connect via link") - .font(.title2) - .fontWeight(.bold) - - Text("If you received SimpleX Chat invitation link you can open it in your browser:") - - Text("💻 desktop: scan displayed QR code from the app, via **Scan QR code**.") - Text("📱 mobile: tap **Open in mobile app**, then tap **Connect** in the app.") + Text("**Create link / QR code** for your contact to use.") + Text("**Paste received link** or open it in the browser and tap **Open in mobile app**.") + Text("**Scan QR code**: to connect to your contact in person or via video call.") } .padding(.top, 24) } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 0761350b5b..6b0af5da76 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -21,13 +21,9 @@ struct ChatListView: View { var body: some View { let v = NavigationView { List { - if chatModel.chats.isEmpty { - ChatHelp(showSettings: $showSettings) - } else { - ForEach(filteredChats()) { chat in - ChatListNavLink(chat: chat, showCallView: $showCallView) - .padding(.trailing, -16) - } + ForEach(filteredChats()) { chat in + ChatListNavLink(chat: chat, showCallView: $showCallView) + .padding(.trailing, -16) } } .onChange(of: chatModel.chatId) { _ in @@ -36,15 +32,15 @@ struct ChatListView: View { chatModel.popChat(chatId) } } - .onChange(of: chatModel.appOpenUrl) { _ in - if let url = chatModel.appOpenUrl { - chatModel.appOpenUrl = nil - AlertManager.shared.showAlert(connectViaUrlAlert(url)) - } + .onChange(of: chatModel.chats.isEmpty) { empty in + if !empty { return } + withAnimation { chatModel.onboardingStage = .step3_MakeConnection } } + .onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() } + .onAppear() { connectViaUrl() } .offset(x: -8) .listStyle(.plain) - .navigationTitle(chatModel.chats.isEmpty ? "Welcome \(user.displayName)!" : "Your chats") + .navigationTitle("Your chats") .navigationBarTitleDisplayMode(chatModel.chats.count > 8 ? .inline : .large) .toolbar { ToolbarItem(placement: .navigationBarLeading) { @@ -101,29 +97,6 @@ struct ChatListView: View { } } - private func connectViaUrlAlert(_ url: URL) -> Alert { - var path = url.path - logger.debug("ChatListView.connectViaUrlAlert path: \(path)") - if (path == "/contact" || path == "/invitation") { - path.removeFirst() - let action: ConnReqType = path == "contact" ? .contact : .invitation - let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") - let title: LocalizedStringKey - if case .contact = action { title = "Connect via contact link?" } - else { title = "Connect via one-time link?" } - return Alert( - title: Text(title), - message: Text("Your profile will be sent to the contact that you received this link from"), - primaryButton: .default(Text("Connect")) { - connectViaLink(link) - }, - secondaryButton: .cancel() - ) - } else { - return Alert(title: Text("Error: URL is invalid")) - } - } - private func answerCallAlert(_ contact: Contact, _ invitation: CallInvitation) { AlertManager.shared.showAlert(Alert( title: Text("Incoming call"), diff --git a/apps/ios/Shared/Views/NewChat/AddContactView.swift b/apps/ios/Shared/Views/NewChat/AddContactView.swift index ecd3815fc4..99f62e65c8 100644 --- a/apps/ios/Shared/Views/NewChat/AddContactView.swift +++ b/apps/ios/Shared/Views/NewChat/AddContactView.swift @@ -12,26 +12,25 @@ import CoreImage.CIFilterBuiltins struct AddContactView: View { var connReqInvitation: String var body: some View { - VStack { - Text("Add contact") + VStack(alignment: .leading) { + Text("One-time invitation link") .font(.title) + .padding(.vertical) + Text("Your contact can scan it from the app") .padding(.bottom) - Text("Show QR code to your contact\nto scan from the app") - .font(.title2) - .multilineTextAlignment(.center) QRCode(uri: connReqInvitation) - .padding() - Text("If you cannot meet in person, you can **show QR code in the video call**, or you can share the invitation link via any other channel.") - .font(.subheadline) - .multilineTextAlignment(.center) - .padding(.horizontal) + .padding(.bottom) + Text("If you can't meet in person, **show QR code in the video call**, or share the link.") + .padding(.bottom) Button { showShareSheet(items: [connReqInvitation]) } label: { Label("Share invitation link", systemImage: "square.and.arrow.up") } - .padding() + .frame(maxWidth: .infinity, alignment: .center) } + .padding() + .frame(maxHeight: .infinity, alignment: .top) } } diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift index ad0a133a56..ddd693c8f6 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatButton.swift @@ -8,12 +8,18 @@ import SwiftUI +enum NewChatAction: Identifiable { + case createLink + case pasteLink + case scanQRCode + + var id: NewChatAction { get { self } } +} + struct NewChatButton: View { @State private var showAddChat = false - @State private var addContact = false - @State private var connReqInvitation: String = "" - @State private var scanToConnect = false - @State private var pasteToConnect = false + @State private var connReq: String = "" + @State private var actionSheet: NewChatAction? var body: some View { Button { showAddChat = true } label: { @@ -21,24 +27,22 @@ struct NewChatButton: View { } .confirmationDialog("Add contact to start a new chat", isPresented: $showAddChat, titleVisibility: .visible) { Button("Create link / QR code") { addContactAction() } - Button("Paste received link") { pasteToConnect = true } - Button("Scan QR code") { scanToConnect = true } + Button("Paste received link") { actionSheet = .pasteLink } + Button("Scan QR code") { actionSheet = .scanQRCode } + } + .sheet(item: $actionSheet) { sheet in + switch sheet { + case .createLink: AddContactView(connReqInvitation: connReq) + case .pasteLink: PasteToConnectView(openedSheet: $actionSheet) + case .scanQRCode: ScanToConnectView(openedSheet: $actionSheet) + } } - .sheet(isPresented: $addContact, content: { - AddContactView(connReqInvitation: connReqInvitation) - }) - .sheet(isPresented: $scanToConnect, content: { - ScanToConnectView(openedSheet: $scanToConnect) - }) - .sheet(isPresented: $pasteToConnect, content: { - PasteToConnectView(openedSheet: $pasteToConnect) - }) } func addContactAction() { do { - connReqInvitation = try apiAddContact() - addContact = true + connReq = try apiAddContact() + actionSheet = .createLink } catch { DispatchQueue.global().async { connectionErrorAlert(error) @@ -53,12 +57,12 @@ enum ConnReqType: Equatable { case invitation } -func connectViaLink(_ connectionLink: String, _ openedSheet: Binding? = nil) { +func connectViaLink(_ connectionLink: String, _ openedSheet: Binding? = nil) { Task { do { let res = try await apiConnect(connReq: connectionLink) DispatchQueue.main.async { - openedSheet?.wrappedValue = false + openedSheet?.wrappedValue = nil if let connReqType = res { connectionReqSentAlert(connReqType) } @@ -66,7 +70,7 @@ func connectViaLink(_ connectionLink: String, _ openedSheet: Binding? = ni } catch { logger.error("connectViaLink apiConnect error: \(responseError(error))") DispatchQueue.main.async { - openedSheet?.wrappedValue = false + openedSheet?.wrappedValue = nil connectionErrorAlert(error) } } @@ -74,7 +78,7 @@ func connectViaLink(_ connectionLink: String, _ openedSheet: Binding? = ni } func connectionErrorAlert(_ error: Error) { - AlertManager.shared.showAlertMsg(title: "Connection error", message: "Error: \(error.localizedDescription)") + AlertManager.shared.showAlertMsg(title: "Connection error", message: "Error: \(responseError(error))") } func connectionReqSentAlert(_ type: ConnReqType) { diff --git a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift index e3f9f354ab..2d167671bf 100644 --- a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift +++ b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift @@ -9,23 +9,19 @@ import SwiftUI struct PasteToConnectView: View { - @Binding var openedSheet: Bool + @Binding var openedSheet: NewChatAction? @State private var connectionLink: String = "" var body: some View { VStack(alignment: .leading) { Text("Connect via link") .font(.title) - .padding([.bottom]) - .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical) Text("Paste the link you received into the box below to connect with your contact.") - .multilineTextAlignment(.leading) Text("Your profile will be sent to the contact that you received this link from") - .multilineTextAlignment(.leading) .padding(.bottom) TextEditor(text: $connectionLink) .onSubmit(connect) - .font(.body) .textInputAutocapitalization(.never) .disableAutocorrection(true) .allowsTightening(false) @@ -60,9 +56,9 @@ struct PasteToConnectView: View { .padding(.bottom) Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button") - .multilineTextAlignment(.leading) } .padding() + .frame(maxHeight: .infinity, alignment: .top) } private func connect() { @@ -72,7 +68,7 @@ struct PasteToConnectView: View { struct PasteToConnectView_Previews: PreviewProvider { static var previews: some View { - @State var openedSheet: Bool = true + @State var openedSheet: NewChatAction? = nil return PasteToConnectView(openedSheet: $openedSheet) } } diff --git a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift index aed53a2d5b..80db91d528 100644 --- a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift +++ b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift @@ -10,28 +10,26 @@ import SwiftUI import CodeScanner struct ScanToConnectView: View { - @Binding var openedSheet: Bool + @Binding var openedSheet: NewChatAction? var body: some View { - VStack { + VStack(alignment: .leading) { Text("Scan QR code") .font(.title) - .padding(.bottom) + .padding(.vertical) Text("Your chat profile will be sent to your contact") - .font(.title2) - .multilineTextAlignment(.center) - .padding() + .padding(.bottom) ZStack { CodeScannerView(codeTypes: [.qr], completion: processQRCode) .aspectRatio(1, contentMode: .fit) .border(.gray) } - .padding(12) + .padding(.bottom) Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.") - .font(.subheadline) - .multilineTextAlignment(.center) - .padding(.horizontal) + .padding(.bottom) } + .padding() + .frame(maxHeight: .infinity, alignment: .top) } func processQRCode(_ resp: Result) { @@ -40,14 +38,14 @@ struct ScanToConnectView: View { Task { connectViaLink(r.string, $openedSheet) } case let .failure(e): logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)") - openedSheet = false + openedSheet = nil } } } struct ConnectContactView_Previews: PreviewProvider { static var previews: some View { - @State var openedSheet: Bool = true + @State var openedSheet: NewChatAction? = nil return ScanToConnectView(openedSheet: $openedSheet) } } diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift new file mode 100644 index 0000000000..b6e2bbe7ed --- /dev/null +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -0,0 +1,124 @@ +// +// CreateProfile.swift +// SimpleX (iOS) +// +// Created by Evgeny on 07/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct CreateProfile: View { + @EnvironmentObject var m: ChatModel + @State private var displayName: String = "" + @State private var fullName: String = "" + @FocusState private var focusDisplayName + @FocusState private var focusFullName + + var body: some View { + GeometryReader { g in + VStack(alignment: .leading) { + Text("Create your profile") + .font(.largeTitle) + .padding(.bottom, 4) + Text("Your profile, contacts and delivered messages are stored on your device.") + .padding(.bottom, 4) + Text("The profile is only shared with your contacts.") + .padding(.bottom) + ZStack(alignment: .topLeading) { + if !validDisplayName(displayName) { + Image(systemName: "exclamationmark.circle") + .foregroundColor(.red) + .padding(.top, 4) + } + textField("Display name", text: $displayName) + .focused($focusDisplayName) + .submitLabel(.next) + .onSubmit { + if canCreateProfile() { focusFullName = true } + else { focusDisplayName = true } + } + } + textField("Full name (optional)", text: $fullName) + .focused($focusFullName) + .submitLabel(.go) + .onSubmit { + if canCreateProfile() { createProfile() } + else { focusFullName = true } + } + + Spacer() + + HStack { + Button { + hideKeyboard() + withAnimation { m.onboardingStage = .step1_SimpleXInfo } + } label: { + HStack { + Image(systemName: "lessthan") + Text("About SimpleX") + } + } + + Spacer() + + HStack { + Button { + createProfile() + } label: { + Text("Create") + Image(systemName: "greaterthan") + } + .disabled(!canCreateProfile()) + } + } + } + .onAppear() { + focusDisplayName = true + } + } + .padding() + } + + func textField(_ placeholder: LocalizedStringKey, text: Binding) -> some View { + TextField(placeholder, text: text) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .padding(.leading, 28) + .padding(.bottom) + } + + func createProfile() { + hideKeyboard() + let profile = Profile( + displayName: displayName, + fullName: fullName + ) + do { + m.currentUser = try apiCreateActiveUser(profile) + startChat() + withAnimation { m.onboardingStage = .step3_MakeConnection } + + } catch { + fatalError("Failed to create user: \(error)") + } + } + + func hideKeyboard() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + + func validDisplayName(_ name: String) -> Bool { + name.firstIndex(of: " ") == nil + } + + func canCreateProfile() -> Bool { + displayName != "" && validDisplayName(displayName) + } +} + +struct CreateProfile_Previews: PreviewProvider { + static var previews: some View { + CreateProfile() + } +} diff --git a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift new file mode 100644 index 0000000000..fdd73d2632 --- /dev/null +++ b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift @@ -0,0 +1,54 @@ +// +// HowItWorks.swift +// SimpleX (iOS) +// +// Created by Evgeny on 08/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct HowItWorks: View { + @EnvironmentObject var m: ChatModel + var onboarding: Bool + + var body: some View { + VStack(alignment: .leading) { + Text("How SimpleX works") + .font(.largeTitle) + .padding(.vertical) + ScrollView { + VStack(alignment: .leading) { + Group { + Text("Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?*") + Text("To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts.") + Text("You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them.") + Text("Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**.") + if onboarding { + Text("Read more in our GitHub repository.") + } else { + Text("Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme).") + } + } + .padding(.bottom) + } + } + + Spacer() + + if onboarding { + OnboardingActionButton() + .padding(.bottom, 8) + } + } + .lineLimit(10) + .padding() + .frame(maxHeight: .infinity, alignment: .top) + } +} + +struct HowItWorks_Previews: PreviewProvider { + static var previews: some View { + HowItWorks(onboarding: true) + } +} diff --git a/apps/ios/Shared/Views/Onboarding/MakeConnection.swift b/apps/ios/Shared/Views/Onboarding/MakeConnection.swift new file mode 100644 index 0000000000..2d0b7b88e4 --- /dev/null +++ b/apps/ios/Shared/Views/Onboarding/MakeConnection.swift @@ -0,0 +1,146 @@ +// +// MakeConnection.swift +// SimpleX (iOS) +// +// Created by Evgeny on 07/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct MakeConnection: View { + @EnvironmentObject var m: ChatModel + @State private var connReq: String = "" + @State private var actionSheet: NewChatAction? + + var body: some View { + VStack(alignment: .leading) { + SettingsButton().padding(.bottom, 1) + + if let user = m.currentUser { + Text("Welcome \(user.displayName)!") + .font(.largeTitle) + .multilineTextAlignment(.leading) + .padding(.bottom, 8) + } else { + Text("Make a private connection") + .font(.largeTitle) + .padding(.bottom) + } + + ScrollView { + VStack(alignment: .leading) { + Text("To make your first private connection, choose **one of the following**:") + .padding(.bottom) + + actionRow( + icon: "qrcode", + title: "Create 1-time link / QR code", + text: "It's secure to share - only one contact can use it." + ) { addContactAction() } + + actionRow( + icon: "link", + title: "Paste the link you received", + text: "Or open the link in the browser and tap **Open in mobile**." + ) { actionSheet = .pasteLink } + + actionRow( + icon: "qrcode.viewfinder", + title: "Scan contact's QR code", + text: "In person or via a video call – the most secure way to connect." + ) { actionSheet = .scanQRCode } + + Text("or") + .padding(.bottom) + .frame(maxWidth: .infinity) + + actionRow( + icon: "number", + title: "Connect with the developers", + text: "To ask any questions and to receive SimpleX Chat updates." + ) { + DispatchQueue.main.async { + UIApplication.shared.open(simplexTeamURL) + } + } + } + } + + Spacer() + + Button { + withAnimation { m.onboardingStage = .step1_SimpleXInfo } + } label: { + HStack { + Image(systemName: "lessthan") + Text("About SimpleX") + } + } + .padding(.bottom, 8) + .padding(.bottom) + } + .sheet(item: $actionSheet) { sheet in + switch sheet { + case .createLink: AddContactView(connReqInvitation: connReq) + case .pasteLink: PasteToConnectView(openedSheet: $actionSheet) + case .scanQRCode: ScanToConnectView(openedSheet: $actionSheet) + } + } + .onChange(of: actionSheet) { _ in checkOnboarding() } + .onChange(of: m.chats.isEmpty) { _ in checkOnboarding() } + .onChange(of: m.appOpenUrl) { _ in connectViaUrl() } + .onAppear() { connectViaUrl() } + .padding(.horizontal) + .frame(maxHeight: .infinity, alignment: .top) + } + + private func checkOnboarding() { + if actionSheet == nil && !m.chats.isEmpty { + withAnimation { m.onboardingStage = .onboardingComplete } + } + } + + private func addContactAction() { + do { + connReq = try apiAddContact() + actionSheet = .createLink + } catch { + DispatchQueue.global().async { + connectionErrorAlert(error) + } + logger.error("NewChatButton.addContactAction apiAddContact error: \(error.localizedDescription)") + } + } + + private func actionRow(icon: String, title: LocalizedStringKey, text: LocalizedStringKey, action: @escaping () -> Void) -> some View { + HStack(alignment: .top, spacing: 20) { + Button(action: action, label: { + Image(systemName: icon) + .resizable() + .scaledToFit() + .frame(width: 30, height: 30) + .padding(.leading, 6) + .padding(.top, 6) + }) + VStack(alignment: .leading) { + Button(action: action, label: { + Text(title) + .font(.headline) + .multilineTextAlignment(.leading) + }) + Text(text) + } + } + .padding(.bottom) + } +} + +struct MakeConnection_Previews: PreviewProvider { + static var previews: some View { + let chatModel = ChatModel() + chatModel.currentUser = User.sampleData + return MakeConnection() + .environmentObject(chatModel) + } +} diff --git a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift new file mode 100644 index 0000000000..04fcbf42e5 --- /dev/null +++ b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift @@ -0,0 +1,35 @@ +// +// OnboardingStepsView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 07/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct OnboardingView: View { + var onboarding: OnboardingStage + + var body: some View { + switch onboarding { + case .step1_SimpleXInfo: SimpleXInfo(onboarding: true) + case .step2_CreateProfile: CreateProfile() + case .step3_MakeConnection: MakeConnection() + case .onboardingComplete: EmptyView() + } + } +} + +enum OnboardingStage { + case step1_SimpleXInfo + case step2_CreateProfile + case step3_MakeConnection + case onboardingComplete +} + +struct OnboardingStepsView_Previews: PreviewProvider { + static var previews: some View { + OnboardingView(onboarding: .step1_SimpleXInfo) + } +} diff --git a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift new file mode 100644 index 0000000000..2468b7290a --- /dev/null +++ b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift @@ -0,0 +1,102 @@ +// +// SimpleXInfo.swift +// SimpleX (iOS) +// +// Created by Evgeny on 07/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct SimpleXInfo: View { + @EnvironmentObject var m: ChatModel + @State private var showHowItWorks = false + var onboarding: Bool + + var body: some View { + GeometryReader { g in + VStack(alignment: .leading) { + Image("logo") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: g.size.width * 0.7) + .padding(.bottom) + Text("The next generation of private messaging") + .font(.title) + .padding(.bottom) + + infoRow("🎭", "Privacy redefined", + "The 1st platform without any user identifiers – private by design.") + infoRow("📭", "Immune to spam and abuse", + "People can connect to you only via the links you share.") + infoRow("🤝", "Decentralized", + "Open-source protocol and code – anybody can run the servers.") + + Spacer() + + if onboarding { + OnboardingActionButton() + Spacer() + } + + Button { + showHowItWorks = true + } label: { + Label("How it works", systemImage: "info.circle") + .font(.subheadline) + } + .padding(.bottom, 8) + .frame(maxWidth: .infinity) + } + .sheet(isPresented: $showHowItWorks) { + HowItWorks(onboarding: onboarding) + } + } + .padding() + } + + private func infoRow(_ emoji: String, _ title: LocalizedStringKey, _ text: LocalizedStringKey) -> some View { + HStack(alignment: .top) { + Text(emoji) + .font(mediumEmojiFont) + .frame(width: 40) + VStack(alignment: .leading) { + Text(title).font(.headline) + Text(text) + } + } + .padding(.bottom) + } +} + +struct OnboardingActionButton: View { + @EnvironmentObject var m: ChatModel + + var body: some View { + if m.currentUser == nil { + actionButton("Create your profile", onboarding: .step2_CreateProfile) + } else { + actionButton("Make a private connection", onboarding: .step3_MakeConnection) + } + } + + private func actionButton(_ label: LocalizedStringKey, onboarding: OnboardingStage) -> some View { + Button { + withAnimation { + m.onboardingStage = onboarding + } + } label: { + HStack { + Text(label).font(.title2) + Image(systemName: "greaterthan") + } + } + .frame(maxWidth: .infinity) + } +} + +struct SimpleXInfo_Previews: PreviewProvider { + static var previews: some View { + SimpleXInfo(onboarding: true) + } +} diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 5e716b76bb..05af85086b 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -17,6 +17,8 @@ let appBuild = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? let DEFAULT_USE_NOTIFICATIONS = "useNotifications" let DEFAULT_PENDING_CONNECTIONS = "pendingConnections" +private var indent: CGFloat = 36 + struct SettingsView: View { @Environment(\.colorScheme) var colorScheme @EnvironmentObject var chatModel: ChatModel @@ -54,29 +56,19 @@ struct SettingsView: View { UserAddress() .navigationTitle("Your chat address") } label: { - HStack { - Image(systemName: "qrcode") - .padding(.trailing, 8) - Text("Your SimpleX contact address") - } + settingsRow("qrcode") { Text("Your SimpleX contact address") } } } Section("Settings") { - HStack { - Image(systemName: "link") - .padding(.trailing, 8) + settingsRow("link") { Toggle("Show pending connections", isOn: $pendingConnections) } NavigationLink { SMPServers() .navigationTitle("Your SMP servers") } label: { - HStack { - Image(systemName: "server.rack") - .padding(.trailing, 4) - Text("SMP servers") - } + settingsRow("server.rack") { Text("SMP servers") } } } @@ -86,26 +78,23 @@ struct SettingsView: View { .navigationTitle("Welcome \(user.displayName)!") .frame(maxHeight: .infinity, alignment: .top) } label: { - HStack { - Image(systemName: "questionmark.circle") - .padding(.trailing, 8) - Text("How to use SimpleX Chat") - } + settingsRow("questionmark") { Text("How to use it") } + } + NavigationLink { + SimpleXInfo(onboarding: false) + .navigationBarTitle("", displayMode: .inline) + .frame(maxHeight: .infinity, alignment: .top) + } label: { + settingsRow("info") { Text("About SimpleX Chat") } } NavigationLink { MarkdownHelp() .navigationTitle("How to use markdown") .frame(maxHeight: .infinity, alignment: .top) } label: { - HStack { - Image(systemName: "textformat") - .padding(.trailing, 4) - Text("Markdown in messages") - } + settingsRow("textformat") { Text("Markdown in messages") } } - HStack { - Image(systemName: "number") - .padding(.trailing, 8) + settingsRow("number") { Button { showSettings = false DispatchQueue.main.async { @@ -115,30 +104,21 @@ struct SettingsView: View { Text("Chat with the developers") } } - HStack { - Image(systemName: "envelope") - .padding(.trailing, 4) - Text("[Send us email](mailto:chat@simplex.chat)") - } + settingsRow("envelope") { Text("[Send us email](mailto:chat@simplex.chat)") } } Section("Develop") { NavigationLink { TerminalView() } label: { - HStack { - Image(systemName: "terminal") - .frame(maxWidth: 24) - .padding(.trailing, 8) - Text("Chat console") - } + settingsRow("terminal") { Text("Chat console") } } - HStack { + ZStack(alignment: .leading) { Image(colorScheme == .dark ? "github_light" : "github") .resizable() .frame(width: 24, height: 24) - .padding(.trailing, 8) Text("Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)") + .padding(.leading, indent) } // if let token = chatModel.deviceToken { // HStack { @@ -158,6 +138,13 @@ struct SettingsView: View { } } + private func settingsRow(_ icon: String, content: @escaping () -> Content) -> some View { + ZStack(alignment: .leading) { + Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center) + content().padding(.leading, indent) + } + } + enum NotificationAlert { case enable case error(LocalizedStringKey, String) diff --git a/apps/ios/Shared/Views/WelcomeView.swift b/apps/ios/Shared/Views/WelcomeView.swift deleted file mode 100644 index 3f2d80000f..0000000000 --- a/apps/ios/Shared/Views/WelcomeView.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// WelcomeView.swift -// SimpleX -// -// Created by Evgeny Poberezkin on 18/01/2022. -// - -import SwiftUI - -struct WelcomeView: View { - @EnvironmentObject var chatModel: ChatModel - @State var displayName: String = "" - @State var fullName: String = "" - - var body: some View { - GeometryReader { g in - VStack(alignment: .leading) { - Image("logo") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: g.size.width * 0.7) - .padding(.vertical) - Text("You control your chat!") - .font(.title) - .padding(.bottom) - Text("The messaging and application platform 100% private by design!") - .padding(.bottom, 8) - Text("Your profile, contacts and messages (once delivered) are only stored locally on your device.") - .padding(.bottom, 8) - Text("Create profile") - .font(.largeTitle) - .padding(.bottom, 4) - Text("(shared only with your contacts)") - .padding(.bottom) - ZStack(alignment: .topLeading) { - if !validDisplayName(displayName) { - Image(systemName: "exclamationmark.circle") - .foregroundColor(.red) - .padding(.top, 4) - } - TextField("Display name", text: $displayName) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .padding(.leading, 28) - .padding(.bottom, 2) - } - .padding(.bottom) - TextField("Full name (optional)", text: $fullName) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .padding(.leading, 28) - .padding(.bottom) - Button("Create") { - let profile = Profile( - displayName: displayName, - fullName: fullName - ) - do { - let user = try apiCreateActiveUser(profile) - chatModel.currentUser = user - } catch { - fatalError("Failed to create user: \(error)") - } - } - .disabled(!validDisplayName(displayName) || displayName == "") - } - } - .padding() - } - - func validDisplayName(_ name: String) -> Bool { - name.firstIndex(of: " ") == nil - } -} - -struct WelcomeView_Previews: PreviewProvider { - static var previews: some View { - WelcomeView() - } -} diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 70fcfcdd2d..f4bd247669 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -50,24 +50,29 @@ %lldk No comment provided by engineer. - - (shared only with your contacts) - (shared only with your contacts) - No comment provided by engineer. - ) ) No comment provided by engineer. - **Add new contact**: to create your one-time QR Code for your contact. - **Add new contact**: to create your one-time QR Code for your contact. + **Add new contact**: to create your one-time QR Code or link for your contact. + **Add new contact**: to create your one-time QR Code or link for your contact. No comment provided by engineer. - - **Scan QR code**: to connect to your contact who shows QR code to you. - **Scan QR code**: to connect to your contact who shows QR code to you. + + **Create link / QR code** for your contact to use. + **Create link / QR code** for your contact to use. + No comment provided by engineer. + + + **Paste received link** or open it in the browser and tap **Open in mobile app**. + **Paste received link** or open it in the browser and tap **Open in mobile app**. + No comment provided by engineer. + + + **Scan QR code**: to connect to your contact in person or via video call. + **Scan QR code**: to connect to your contact in person or via video call. No comment provided by engineer. @@ -95,6 +100,16 @@ : %@ No comment provided by engineer. + + About SimpleX + About SimpleX + No comment provided by engineer. + + + About SimpleX Chat + About SimpleX Chat + No comment provided by engineer. + Accept Accept @@ -110,11 +125,6 @@ Accept contact request from %@? notification body - - Add contact - Add contact - No comment provided by engineer. - Add contact to start a new chat Add contact to start a new chat @@ -125,6 +135,11 @@ All your contacts will remain connected No comment provided by engineer. + + Already connected? + Already connected? + No comment provided by engineer. + Answer Answer @@ -171,7 +186,7 @@ notification - Choose file + Choose file (new in v2.0) Choose file (new in v2.0) No comment provided by engineer. @@ -215,6 +230,11 @@ Connect via one-time link? No comment provided by engineer. + + Connect with the developers + Connect with the developers + No comment provided by engineer. + Connecting to server… Connecting to server… @@ -285,6 +305,11 @@ Create No comment provided by engineer. + + Create 1-time link / QR code + Create 1-time link / QR code + No comment provided by engineer. + Create address Create address @@ -295,9 +320,9 @@ Create link / QR code No comment provided by engineer. - - Create profile - Create profile + + Create your profile + Create your profile No comment provided by engineer. @@ -305,6 +330,11 @@ Currently maximum supported file size is %@. No comment provided by engineer. + + Decentralized + Decentralized + No comment provided by engineer. + Delete Delete @@ -435,14 +465,24 @@ Help No comment provided by engineer. + + How SimpleX works + How SimpleX works + No comment provided by engineer. + + + How it works + How it works + No comment provided by engineer. + How to How to No comment provided by engineer. - - How to use SimpleX Chat - How to use SimpleX Chat + + How to use it + How to use it No comment provided by engineer. @@ -450,21 +490,16 @@ How to use markdown No comment provided by engineer. + + If you can't meet in person, **show QR code in the video call**, or share the link. + If you can't meet in person, **show QR code in the video call**, or share the link. + No comment provided by engineer. + If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link. If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link. No comment provided by engineer. - - If you cannot meet in person, you can **show QR code in the video call**, or you can share the invitation link via any other channel. - If you cannot meet in person, you can **show QR code in the video call**, or you can share the invitation link via any other channel. - No comment provided by engineer. - - - If you received SimpleX Chat invitation link you can open it in your browser: - If you received SimpleX Chat invitation link you can open it in your browser: - No comment provided by engineer. - Ignore Ignore @@ -475,6 +510,16 @@ Image will be received when your contact is online, please wait or check later! No comment provided by engineer. + + Immune to spam and abuse + Immune to spam and abuse + No comment provided by engineer. + + + In person or via a video call – the most secure way to connect. + In person or via a video call – the most secure way to connect. + No comment provided by engineer. + Incoming %@ call Incoming %@ call @@ -495,16 +540,36 @@ Invalid connection link No comment provided by engineer. + + It seems like you are already connected via this link. If it is not the case, there was an error (%@). + It seems like you are already connected via this link. If it is not the case, there was an error (%@). + No comment provided by engineer. + + + It's secure to share - only one contact can use it. + It's secure to share - only one contact can use it. + No comment provided by engineer. + Large file! Large file! No comment provided by engineer. + + Make a private connection + Make a private connection + No comment provided by engineer. + Make sure SMP server addresses are in correct format, line separated and are not duplicated. Make sure SMP server addresses are in correct format, line separated and are not duplicated. No comment provided by engineer. + + Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?* + Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?* + No comment provided by engineer. + Markdown in messages Markdown in messages @@ -535,11 +600,31 @@ Notifications are disabled! No comment provided by engineer. + + One-time invitation link + One-time invitation link + No comment provided by engineer. + + + Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**. + Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**. + No comment provided by engineer. + Open Settings Open Settings No comment provided by engineer. + + Open-source protocol – anybody can run the servers. + Open-source protocol – anybody can run the servers. + No comment provided by engineer. + + + Or open the link in the browser and tap **Open in mobile**. + Or open the link in the browser and tap **Open in mobile**. + No comment provided by engineer. + Paste Paste @@ -550,11 +635,21 @@ Paste received link No comment provided by engineer. + + Paste the link you received + Paste the link you received + No comment provided by engineer. + Paste the link you received into the box below to connect with your contact. Paste the link you received into the box below to connect with your contact. No comment provided by engineer. + + People can connect to you only via the links you share. + People can connect to you only via the links you share. + No comment provided by engineer. + Please check that you used the correct link or ask your contact to send you another one. Please check that you used the correct link or ask your contact to send you another one. @@ -565,6 +660,11 @@ Please check your network connection and try again. No comment provided by engineer. + + Privacy redefined + Privacy redefined + No comment provided by engineer. + Profile image Profile image @@ -575,6 +675,16 @@ Read No comment provided by engineer. + + Read more in our GitHub repository. + Read more in our GitHub repository. + No comment provided by engineer. + + + Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). + Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). + No comment provided by engineer. + Reject Reject @@ -620,6 +730,11 @@ Scan QR code No comment provided by engineer. + + Scan contact's QR code + Scan contact's QR code + No comment provided by engineer. + Server connected Server connected @@ -645,13 +760,6 @@ Share link No comment provided by engineer. - - Show QR code to your contact -to scan from the app - Show QR code to your contact -to scan from the app - No comment provided by engineer. - Show pending connections Show pending connections @@ -672,6 +780,11 @@ to scan from the app Thank you for installing SimpleX Chat! No comment provided by engineer. + + The 1st platform without any user identifiers – private by design. + The 1st platform without any user identifiers – private by design. + No comment provided by engineer. + The app can notify you when you receive messages or contact requests - please open settings to enable. The app can notify you when you receive messages or contact requests - please open settings to enable. @@ -694,9 +807,14 @@ to scan from the app The contact you shared this link with will NOT be able to connect! No comment provided by engineer. - - The messaging and application platform 100% private by design! - The messaging and application platform 100% private by design! + + The next generation of private messaging + The next generation of private messaging + No comment provided by engineer. + + + The profile is only shared with your contacts. + The profile is only shared with your contacts. No comment provided by engineer. @@ -704,19 +822,29 @@ to scan from the app The sender will NOT be notified No comment provided by engineer. + + To ask any questions and to receive SimpleX Chat updates. + To ask any questions and to receive SimpleX Chat updates. + No comment provided by engineer. + To ask any questions and to receive updates: To ask any questions and to receive updates: No comment provided by engineer. - - To connect via link - To connect via link + + To make a new connection + To make a new connection No comment provided by engineer. - - To start a new chat - To start a new chat + + To make your first private connection, choose **one of the following**: + To make your first private connection, choose **one of the following**: + No comment provided by engineer. + + + To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts. + To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts. No comment provided by engineer. @@ -806,9 +934,9 @@ To connect, please ask your contact to create another connection link and check You can use markdown to format messages: No comment provided by engineer. - - You control your chat! - You control your chat! + + You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them. + You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them. No comment provided by engineer. @@ -856,6 +984,11 @@ To connect, please ask your contact to create another connection link and check Your chats No comment provided by engineer. + + Your contact can scan it from the app + Your contact can scan it from the app + No comment provided by engineer. + Your contact needs to be online for the connection to complete. You can cancel this connection and remove the contact (and try later with a new link). @@ -880,9 +1013,9 @@ SimpleX servers cannot see your profile. Your profile will be sent to the contact that you received this link from No comment provided by engineer. - - Your profile, contacts and messages (once delivered) are only stored locally on your device. - Your profile, contacts and messages (once delivered) are only stored locally on your device. + + Your profile, contacts and delivered messages are stored on your device. + Your profile, contacts and delivered messages are stored on your device. No comment provided by engineer. @@ -905,9 +1038,9 @@ SimpleX servers cannot see your profile. `a + b` No comment provided by engineer. - - above, then: - above, then: + + above, then choose: + above, then choose: No comment provided by engineer. @@ -1001,6 +1134,11 @@ SimpleX servers cannot see your profile. no end-to-end encryption No comment provided by engineer. + + or + or + No comment provided by engineer. + received answer… received answer… @@ -1066,16 +1204,6 @@ SimpleX servers cannot see your profile. ~strike~ No comment provided by engineer. - - 💻 desktop: scan displayed QR code from the app, via **Scan QR code**. - 💻 desktop: scan displayed QR code from the app, via **Scan QR code**. - No comment provided by engineer. - - - 📱 mobile: tap **Open in mobile app**, then tap **Connect** in the app. - 📱 mobile: tap **Open in mobile app**, then tap **Connect** in the app. - No comment provided by engineer. -
diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/Localizable.strings index 97efd9d6d7..7bbeebb05a 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/Localizable.strings +++ b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/Localizable.strings @@ -1,6 +1,11 @@ +/* No comment provided by engineer. */ +"Choose file" = "Choose file (new in v2.0)"; + /* No comment provided by engineer. */ "Connecting server…" = "Connecting to server…"; /* No comment provided by engineer. */ "Connecting server… (error: %@)" = "Connecting to server… (error: %@)"; +/* No comment provided by engineer. */ +"**Add new contact**: to create your one-time QR Code for your contact." = "**Add new contact**: to create your one-time QR Code or link for your contact."; diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 4b34919995..4544bbaefb 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -50,24 +50,29 @@ %lldk No comment provided by engineer. - - (shared only with your contacts) - (отправляется только вашим контактам) - No comment provided by engineer. - ) ) No comment provided by engineer. - **Add new contact**: to create your one-time QR Code for your contact. + **Add new contact**: to create your one-time QR Code or link for your contact. **Добавить новый контакт**: чтобы создать одноразовый QR код или ссылку для вашего контакта. No comment provided by engineer. - - **Scan QR code**: to connect to your contact who shows QR code to you. - **Сканировать QR код**: чтобы соединиться с вашим контактом (который показывает вам QR код). + + **Create link / QR code** for your contact to use. + **Создать ссылку / QR код** для вашего контакта. + No comment provided by engineer. + + + **Paste received link** or open it in the browser and tap **Open in mobile app**. + **Вставить полученную ссылку**, или откройте её в браузере и нажмите **Open in mobile app**. + No comment provided by engineer. + + + **Scan QR code**: to connect to your contact in person or via video call. + **Сканировать QR код**: соединиться с вашим контактом при встрече или во время видеозвонка. No comment provided by engineer. @@ -95,6 +100,16 @@ : %@ No comment provided by engineer. + + About SimpleX + О SimpleX + No comment provided by engineer. + + + About SimpleX Chat + Информация о SimpleX Chat + No comment provided by engineer. + Accept Принять @@ -110,11 +125,6 @@ Принять запрос на соединение от %@? notification body - - Add contact - Добавить контакт - No comment provided by engineer. - Add contact to start a new chat Добавьте контакт, чтобы начать разговор @@ -125,6 +135,11 @@ Все контакты, которые соединились через этот адрес, сохранятся. No comment provided by engineer. + + Already connected? + Соединение уже установлено? + No comment provided by engineer. + Answer Ответить @@ -171,7 +186,7 @@ notification - Choose file + Choose file (new in v2.0) Выбрать файл (v2.0) No comment provided by engineer. @@ -215,6 +230,11 @@ Соединиться через одноразовую ссылку? No comment provided by engineer. + + Connect with the developers + Соединиться с разработчиками + No comment provided by engineer. + Connecting to server… Устанавливается соединение с сервером… @@ -285,6 +305,11 @@ Создать No comment provided by engineer. + + Create 1-time link / QR code + Создать ссылку / QR код + No comment provided by engineer. + Create address Создать адрес @@ -295,8 +320,8 @@ Создать ссылку / QR код No comment provided by engineer. - - Create profile + + Create your profile Создать профиль No comment provided by engineer. @@ -305,6 +330,11 @@ Максимальный размер файла - %@. No comment provided by engineer. + + Decentralized + Децентрализованный + No comment provided by engineer. + Delete Удалить @@ -435,14 +465,24 @@ Помощь No comment provided by engineer. + + How SimpleX works + Как SimpleX работает + No comment provided by engineer. + + + How it works + Как это работает + No comment provided by engineer. + How to Инфо No comment provided by engineer. - - How to use SimpleX Chat - Как использовать SimpleX Chat + + How to use it + Как использовать No comment provided by engineer. @@ -450,21 +490,16 @@ Как форматировать No comment provided by engineer. + + If you can't meet in person, **show QR code in the video call**, or share the link. + Если вы не можете встретиться лично, вы можете **показать QR код во время видеозвонка**, или поделиться ссылкой. + No comment provided by engineer. + If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link. Если вы не можете встретиться лично, вы можете **сосканировать QR код во время видеозвонка**, или ваш контакт может отправить вам ссылку. No comment provided by engineer. - - If you cannot meet in person, you can **show QR code in the video call**, or you can share the invitation link via any other channel. - Если вы не можете встретиться лично, вы можете **показать QR код во время видеозвонка** или отправить ссылку через любой другой канал связи. - No comment provided by engineer. - - - If you received SimpleX Chat invitation link you can open it in your browser: - Если вы получили ссылку с приглашением из SimpleX Chat, вы можете открыть её в браузере: - No comment provided by engineer. - Ignore Не отвечать @@ -475,6 +510,16 @@ Изображение будет принято, когда ваш контакт будет в сети, подождите или проверьте позже! No comment provided by engineer. + + Immune to spam and abuse + Защищен от спама + No comment provided by engineer. + + + In person or via a video call – the most secure way to connect. + При встрече или в видеозвонке – самый безопасный способ установить соединение + No comment provided by engineer. + Incoming %@ call Входящий звонок @@ -495,16 +540,36 @@ Ошибка в ссылке контакта No comment provided by engineer. + + It seems like you are already connected via this link. If it is not the case, there was an error (%@). + Возможно, вы уже соединились через эту ссылку. Если это не так, то это ошибка (%@). + No comment provided by engineer. + + + It's secure to share - only one contact can use it. + Ей безопасно поделиться - только один контакт может использовать её. + No comment provided by engineer. + Large file! Большой файл! No comment provided by engineer. + + Make a private connection + Создайте соединение + No comment provided by engineer. + Make sure SMP server addresses are in correct format, line separated and are not duplicated. Пожалуйста, проверьте, что адреса SMP серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется. No comment provided by engineer. + + Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?* + Много пользователей спросили: *как SimpleX доставляет сообщения без идентификаторов пользователей?* + No comment provided by engineer. + Markdown in messages Форматирование сообщений @@ -535,11 +600,31 @@ Уведомления выключены No comment provided by engineer. + + One-time invitation link + Одноразовая ссылка + No comment provided by engineer. + + + Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**. + Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются **с двухуровневым end-to-end шифрованием** + No comment provided by engineer. + Open Settings Открыть Настройки No comment provided by engineer. + + Open-source protocol and code – anybody can run the servers. + Открытый протокол и код - кто угодно может запускать серверы. + No comment provided by engineer. + + + Or open the link in the browser and tap **Open in mobile**. + Или откройте ссылку в браузере и нажмите **Open in mobile**. + No comment provided by engineer. + Paste Вставить @@ -550,11 +635,21 @@ Вставить полученную ссылку No comment provided by engineer. + + Paste the link you received + Вставьте полученную ссылку + No comment provided by engineer. + Paste the link you received into the box below to connect with your contact. Чтобы соединиться, вставьте в это поле ссылку, полученную от вашего контакта. No comment provided by engineer. + + People can connect to you only via the links you share. + С вами можно соединиться только через созданные вами ссылки. + No comment provided by engineer. + Please check that you used the correct link or ask your contact to send you another one. Пожалуйста, проверьте, что вы использовали правильную ссылку или попросите, чтобы ваш контакт отправил вам другую ссылку. @@ -565,6 +660,11 @@ Пожалуйста, проверьте ваше соединение с сетью и попробуйте еще раз. No comment provided by engineer. + + Privacy redefined + Более конфиденциальный + No comment provided by engineer. + Profile image Аватар @@ -575,6 +675,16 @@ Прочитано No comment provided by engineer. + + Read more in our GitHub repository. + Узнайте больше из нашего GitHub репозитория. + No comment provided by engineer. + + + Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). + Узнайте больше из нашего [GitHub репозитория](https://github.com/simplex-chat/simplex-chat#readme). + No comment provided by engineer. + Reject Отклонить @@ -620,6 +730,11 @@ Сканировать QR код No comment provided by engineer. + + Scan contact's QR code + Сосканировать QR код контакта + No comment provided by engineer. + Server connected Установлено соединение с сервером @@ -645,12 +760,6 @@ Поделиться ссылкой No comment provided by engineer. - - Show QR code to your contact -to scan from the app - Покажите QR код вашему контакту для сканирования в приложении - No comment provided by engineer. - Show pending connections Показать ожидаемые соединения @@ -671,6 +780,11 @@ to scan from the app Спасибо, что установили SimpleX Chat! No comment provided by engineer. + + The 1st platform without any user identifiers – private by design. + Первая в мире платформа без идентификации пользователей. + No comment provided by engineer. + The app can notify you when you receive messages or contact requests - please open settings to enable. Приложение может посылать вам уведомления о сообщениях и запросах на соединение - уведомления можно включить в Настройках. @@ -693,9 +807,14 @@ to scan from the app Контакт, которому вы отправили эту ссылку, не сможет соединиться! No comment provided by engineer. - - The messaging and application platform 100% private by design! - Платформа для сообщений и приложений, которая защищает вашу личную информацию и безопасность. + + The next generation of private messaging + Новое поколение приватных сообщений + No comment provided by engineer. + + + The profile is only shared with your contacts. + Профиль отправляется только вашим контактам. No comment provided by engineer. @@ -703,19 +822,29 @@ to scan from the app Отправитель не будет уведомлён No comment provided by engineer. + + To ask any questions and to receive SimpleX Chat updates. + Чтобы задать вопросы и получать уведомления о SimpleX Chat. + No comment provided by engineer. + To ask any questions and to receive updates: Чтобы задать вопросы и получать уведомления о новых версиях, No comment provided by engineer. - - To connect via link - Соединиться через ссылку + + To make a new connection + Чтобы соединиться No comment provided by engineer. - - To start a new chat - Начать новый разговор + + To make your first private connection, choose **one of the following**: + Чтобы сделать ваше первое приватное соединение, выберите **одно из**: + No comment provided by engineer. + + + To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts. + Чтобы защитить вашу конфиденциальность, вместо ID пользователей, которые есть в других платформах, SimpleX использует ID для очередей сообщений, разные для каждого контакта. No comment provided by engineer. @@ -805,9 +934,9 @@ To connect, please ask your contact to create another connection link and check Вы можете форматировать сообщения: No comment provided by engineer. - - You control your chat! - Вы котролируете Ваш чат! + + You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them. + Вы определяете через какие серверы вы **получаете сообщения**, ваши контакты - серверы, которые вы используете для отправки. No comment provided by engineer. @@ -855,6 +984,11 @@ To connect, please ask your contact to create another connection link and check Ваши чаты No comment provided by engineer. + + Your contact can scan it from the app + Ваш контакт может сосканировать QR в приложении + No comment provided by engineer. + Your contact needs to be online for the connection to complete. You can cancel this connection and remove the contact (and try later with a new link). @@ -879,9 +1013,9 @@ SimpleX серверы не могут получить доступ к ваше Ваш профиль будет отправлен контакту, от которого вы получили эту ссылку. No comment provided by engineer. - - Your profile, contacts and messages (once delivered) are only stored locally on your device. - Ваш профиль, контакты и сообщения (после доставки) хранятся только на вашем устройстве. + + Your profile, contacts and delivered messages are stored on your device. + Ваш профиль, контакты и доставленные сообщения хранятся на вашем устройстве. No comment provided by engineer. @@ -904,9 +1038,9 @@ SimpleX серверы не могут получить доступ к ваше \`a + b` No comment provided by engineer. - - above, then: - наверху, затем: + + above, then choose: + наверху, затем выберите: No comment provided by engineer. @@ -1000,6 +1134,11 @@ SimpleX серверы не могут получить доступ к ваше end-to-end шифрование не поддерживается No comment provided by engineer. + + or + или + No comment provided by engineer. + received answer… получен ответ… @@ -1065,16 +1204,6 @@ SimpleX серверы не могут получить доступ к ваше \~зачеркнуть~ No comment provided by engineer. - - 💻 desktop: scan displayed QR code from the app, via **Scan QR code**. - 💻 на компьютере: сосканируйте QR код из приложения через **Сканировать QR код**. - No comment provided by engineer. - - - 📱 mobile: tap **Open in mobile app**, then tap **Connect** in the app. - 📱 на мобильном: намжите кнопку **Open in mobile app** на веб странице, затем нажмите **Соединиться** в приложении. - No comment provided by engineer. - diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/Localizable.strings index 97efd9d6d7..7bbeebb05a 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/Localizable.strings +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/Localizable.strings @@ -1,6 +1,11 @@ +/* No comment provided by engineer. */ +"Choose file" = "Choose file (new in v2.0)"; + /* No comment provided by engineer. */ "Connecting server…" = "Connecting to server…"; /* No comment provided by engineer. */ "Connecting server… (error: %@)" = "Connecting to server… (error: %@)"; +/* No comment provided by engineer. */ +"**Add new contact**: to create your one-time QR Code for your contact." = "**Add new contact**: to create your one-time QR Code or link for your contact."; diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 98d363265b..d4832f9178 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -56,9 +56,13 @@ 5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C3279559F40002BEB4 /* SimpleXApp.swift */; }; 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C4279559F40002BEB4 /* ContentView.swift */; }; 5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5CA059C5279559F40002BEB4 /* Assets.xcassets */; }; - 5CA05A4C27974EB60002BEB4 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */; }; 5CB0BA882826CB3A00B3292C /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */; }; 5CB0BA8B2826CB3A00B3292C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CB0BA892826CB3A00B3292C /* Localizable.strings */; }; + 5CB0BA8E2827126500B3292C /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA8D2827126500B3292C /* OnboardingView.swift */; }; + 5CB0BA90282713D900B3292C /* SimpleXInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */; }; + 5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA91282713FD00B3292C /* CreateProfile.swift */; }; + 5CB0BA962827143500B3292C /* MakeConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA952827143500B3292C /* MakeConnection.swift */; }; + 5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA992827FD8800B3292C /* HowItWorks.swift */; }; 5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D327A853F100ACCCDD /* SettingsButton.swift */; }; 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D627A8563F00ACCCDD /* SettingsView.swift */; }; 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E027A867BA00ACCCDD /* UserProfile.swift */; }; @@ -191,9 +195,13 @@ 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Tests iOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 5CA059DB279559F40002BEB4 /* Tests_iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOS.swift; sourceTree = ""; }; 5CA059DD279559F40002BEB4 /* Tests_iOSLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOSLaunchTests.swift; sourceTree = ""; }; - 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; 5CB0BA872826CB3A00B3292C /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; 5CB0BA8A2826CB3A00B3292C /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; + 5CB0BA8D2827126500B3292C /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; + 5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXInfo.swift; sourceTree = ""; }; + 5CB0BA91282713FD00B3292C /* CreateProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateProfile.swift; sourceTree = ""; }; + 5CB0BA952827143500B3292C /* MakeConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MakeConnection.swift; sourceTree = ""; }; + 5CB0BA992827FD8800B3292C /* HowItWorks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HowItWorks.swift; sourceTree = ""; }; 5CB924D327A853F100ACCCDD /* SettingsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsButton.swift; sourceTree = ""; }; 5CB924D627A8563F00ACCCDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 5CB924E027A867BA00ACCCDD /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; }; @@ -287,6 +295,7 @@ 5C2E260D27A30E2400F70299 /* Views */ = { isa = PBXGroup; children = ( + 5CB0BA8C282711BC00B3292C /* Onboarding */, 3C714775281C080100CB4D4B /* Call */, 5C971E1F27AEBF7000C8A3CE /* Helpers */, 5C5F4AC227A5E9AF00B51EF1 /* Chat */, @@ -294,7 +303,6 @@ 5CB924DD27A8622200ACCCDD /* NewChat */, 5CB924DF27A8678B00ACCCDD /* UserSettings */, 5C2E261127A30FEA00F70299 /* TerminalView.swift */, - 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */, ); path = Views; sourceTree = ""; @@ -419,6 +427,18 @@ path = "Tests iOS"; sourceTree = ""; }; + 5CB0BA8C282711BC00B3292C /* Onboarding */ = { + isa = PBXGroup; + children = ( + 5CB0BA8D2827126500B3292C /* OnboardingView.swift */, + 5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */, + 5CB0BA91282713FD00B3292C /* CreateProfile.swift */, + 5CB0BA952827143500B3292C /* MakeConnection.swift */, + 5CB0BA992827FD8800B3292C /* HowItWorks.swift */, + ); + path = Onboarding; + sourceTree = ""; + }; 5CB924DD27A8622200ACCCDD /* NewChat */ = { isa = PBXGroup; children = ( @@ -660,6 +680,7 @@ 5CDCAD7728188D3800503DA2 /* ChatTypes.swift in Sources */, 5C36027327F47AD5009F19D9 /* AppDelegate.swift in Sources */, 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */, + 5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */, 5CDCAD5328186F9500503DA2 /* GroupDefaults.swift in Sources */, 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */, 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */, @@ -672,10 +693,12 @@ 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */, 5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */, 640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */, + 5CB0BA90282713D900B3292C /* SimpleXInfo.swift in Sources */, 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */, 5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */, 3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */, 5C9D13A3282187BB00AB8B43 /* WebRTC.swift in Sources */, + 5CB0BA8E2827126500B3292C /* OnboardingView.swift in Sources */, 5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */, 5CDCAD7628188D3600503DA2 /* APITypes.swift in Sources */, 5C9FD96B27A56D4D0075386C /* JSON.swift in Sources */, @@ -687,10 +710,10 @@ 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */, 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */, 5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */, + 5CB0BA962827143500B3292C /* MakeConnection.swift in Sources */, 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */, 5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */, 5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */, - 5CA05A4C27974EB60002BEB4 /* WelcomeView.swift in Sources */, 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */, 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */, 5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */, @@ -710,6 +733,7 @@ 5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */, 5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */, 3C714777281C081000CB4D4B /* WebRTCView.swift in Sources */, + 5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */, 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */, 5C5E5D3D282447AB00B0488A /* CallTypes.swift in Sources */, 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */, diff --git a/apps/ios/en.lproj/Localizable.strings b/apps/ios/en.lproj/Localizable.strings index 400ef08d21..7bbeebb05a 100644 --- a/apps/ios/en.lproj/Localizable.strings +++ b/apps/ios/en.lproj/Localizable.strings @@ -7,3 +7,5 @@ /* No comment provided by engineer. */ "Connecting server… (error: %@)" = "Connecting to server… (error: %@)"; +/* No comment provided by engineer. */ +"**Add new contact**: to create your one-time QR Code for your contact." = "**Add new contact**: to create your one-time QR Code or link for your contact."; diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 69ce78f781..fb69b87010 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -22,9 +22,6 @@ /* No comment provided by engineer. */ "!1 colored!" = "!1 цвет!"; -/* No comment provided by engineer. */ -"(shared only with your contacts)" = "(отправляется только вашим контактам)"; - /* No comment provided by engineer. */ ")" = ")"; @@ -35,7 +32,13 @@ "**Add new contact**: to create your one-time QR Code for your contact." = "**Добавить новый контакт**: чтобы создать одноразовый QR код или ссылку для вашего контакта."; /* No comment provided by engineer. */ -"**Scan QR code**: to connect to your contact who shows QR code to you." = "**Сканировать QR код**: чтобы соединиться с вашим контактом (который показывает вам QR код)."; +"**Create link / QR code** for your contact to use." = "**Создать ссылку / QR код** для вашего контакта."; + +/* No comment provided by engineer. */ +"**Paste received link** or open it in the browser and tap **Open in mobile app**." = "**Вставить полученную ссылку**, или откройте её в браузере и нажмите **Open in mobile app**."; + +/* No comment provided by engineer. */ +"**Scan QR code**: to connect to your contact in person or via video call." = "**Сканировать QR код**: соединиться с вашим контактом при встрече или во время видеозвонка."; /* No comment provided by engineer. */ "*bold*" = "\\*жирный*"; @@ -61,17 +64,17 @@ /* No comment provided by engineer. */ "~strike~" = "\\~зачеркнуть~"; -/* No comment provided by engineer. */ -"💻 desktop: scan displayed QR code from the app, via **Scan QR code**." = "💻 на компьютере: сосканируйте QR код из приложения через **Сканировать QR код**."; - -/* No comment provided by engineer. */ -"📱 mobile: tap **Open in mobile app**, then tap **Connect** in the app." = "📱 на мобильном: намжите кнопку **Open in mobile app** на веб странице, затем нажмите **Соединиться** в приложении."; - /* No comment provided by engineer. */ "6" = "6"; /* No comment provided by engineer. */ -"above, then:" = "наверху, затем:"; +"About SimpleX" = "О SimpleX"; + +/* No comment provided by engineer. */ +"About SimpleX Chat" = "Информация о SimpleX Chat"; + +/* No comment provided by engineer. */ +"above, then choose:" = "наверху, затем выберите:"; /* accept contact request via notification */ "Accept" = "Принять"; @@ -85,15 +88,15 @@ /* call status */ "accepted" = "принятый звонок"; -/* No comment provided by engineer. */ -"Add contact" = "Добавить контакт"; - /* No comment provided by engineer. */ "Add contact to start a new chat" = "Добавьте контакт, чтобы начать разговор"; /* No comment provided by engineer. */ "All your contacts will remain connected" = "Все контакты, которые соединились через этот адрес, сохранятся."; +/* No comment provided by engineer. */ +"Already connected?" = "Соединение уже установлено?"; + /* accept incoming call via notification */ "Answer" = "Ответить"; @@ -160,6 +163,9 @@ /* No comment provided by engineer. */ "Connect via one-time link?" = "Соединиться через одноразовую ссылку?"; +/* No comment provided by engineer. */ +"Connect with the developers" = "Соединиться с разработчиками"; + /* No comment provided by engineer. */ "connected" = "соединение установлено"; @@ -215,6 +221,9 @@ /* No comment provided by engineer. */ "Create" = "Создать"; +/* No comment provided by engineer. */ +"Create 1-time link / QR code" = "Создать ссылку / QR код"; + /* No comment provided by engineer. */ "Create address" = "Создать адрес"; @@ -222,11 +231,14 @@ "Create link / QR code" = "Создать ссылку / QR код"; /* No comment provided by engineer. */ -"Create profile" = "Создать профиль"; +"Create your profile" = "Создать профиль"; /* No comment provided by engineer. */ "Currently maximum supported file size is %@." = "Максимальный размер файла - %@."; +/* No comment provided by engineer. */ +"Decentralized" = "Децентрализованный"; + /* No comment provided by engineer. */ "Delete" = "Удалить"; @@ -317,30 +329,39 @@ /* No comment provided by engineer. */ "Help" = "Помощь"; +/* No comment provided by engineer. */ +"How it works" = "Как это работает"; + +/* No comment provided by engineer. */ +"How SimpleX works" = "Как SimpleX работает"; + /* No comment provided by engineer. */ "How to" = "Инфо"; +/* No comment provided by engineer. */ +"How to use it" = "Как использовать"; + /* No comment provided by engineer. */ "How to use markdown" = "Как форматировать"; /* No comment provided by engineer. */ -"How to use SimpleX Chat" = "Как использовать SimpleX Chat"; +"If you can't meet in person, **show QR code in the video call**, or share the link." = "Если вы не можете встретиться лично, вы можете **показать QR код во время видеозвонка**, или поделиться ссылкой."; /* No comment provided by engineer. */ "If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link." = "Если вы не можете встретиться лично, вы можете **сосканировать QR код во время видеозвонка**, или ваш контакт может отправить вам ссылку."; -/* No comment provided by engineer. */ -"If you cannot meet in person, you can **show QR code in the video call**, or you can share the invitation link via any other channel." = "Если вы не можете встретиться лично, вы можете **показать QR код во время видеозвонка** или отправить ссылку через любой другой канал связи."; - -/* No comment provided by engineer. */ -"If you received SimpleX Chat invitation link you can open it in your browser:" = "Если вы получили ссылку с приглашением из SimpleX Chat, вы можете открыть её в браузере:"; - /* ignore incoming call via notification */ "Ignore" = "Не отвечать"; /* No comment provided by engineer. */ "Image will be received when your contact is online, please wait or check later!" = "Изображение будет принято, когда ваш контакт будет в сети, подождите или проверьте позже!"; +/* No comment provided by engineer. */ +"Immune to spam and abuse" = "Защищен от спама"; + +/* No comment provided by engineer. */ +"In person or via a video call – the most secure way to connect." = "При встрече или в видеозвонке – самый безопасный способ установить соединение"; + /* call status */ "in progress" = "активный звонок"; @@ -359,15 +380,27 @@ /* chat list item title */ "invited to connect" = "приглашение соединиться"; +/* No comment provided by engineer. */ +"It seems like you are already connected via this link. If it is not the case, there was an error (%@)." = "Возможно, вы уже соединились через эту ссылку. Если это не так, то это ошибка (%@)."; + +/* No comment provided by engineer. */ +"It's secure to share - only one contact can use it." = "Ей безопасно поделиться - только один контакт может использовать её."; + /* No comment provided by engineer. */ "italic" = "курсив"; /* No comment provided by engineer. */ "Large file!" = "Большой файл!"; +/* No comment provided by engineer. */ +"Make a private connection" = "Создайте соединение"; + /* No comment provided by engineer. */ "Make sure SMP server addresses are in correct format, line separated and are not duplicated." = "Пожалуйста, проверьте, что адреса SMP серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется."; +/* No comment provided by engineer. */ +"Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?*" = "Много пользователей спросили: *как SimpleX доставляет сообщения без идентификаторов пользователей?*"; + /* No comment provided by engineer. */ "Markdown in messages" = "Форматирование сообщений"; @@ -392,30 +425,60 @@ /* No comment provided by engineer. */ "Notifications are disabled!" = "Уведомления выключены"; +/* No comment provided by engineer. */ +"One-time invitation link" = "Одноразовая ссылка"; + +/* No comment provided by engineer. */ +"Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." = "Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются **с двухуровневым end-to-end шифрованием**"; + /* No comment provided by engineer. */ "Open Settings" = "Открыть Настройки"; +/* No comment provided by engineer. */ +"Open-source protocol and code – anybody can run the servers." = "Открытый протокол и код - кто угодно может запускать серверы."; + +/* No comment provided by engineer. */ +"or" = "или"; + +/* No comment provided by engineer. */ +"Or open the link in the browser and tap **Open in mobile**." = "Или откройте ссылку в браузере и нажмите **Open in mobile**."; + /* No comment provided by engineer. */ "Paste" = "Вставить"; /* No comment provided by engineer. */ "Paste received link" = "Вставить полученную ссылку"; +/* No comment provided by engineer. */ +"Paste the link you received" = "Вставьте полученную ссылку"; + /* No comment provided by engineer. */ "Paste the link you received into the box below to connect with your contact." = "Чтобы соединиться, вставьте в это поле ссылку, полученную от вашего контакта."; +/* No comment provided by engineer. */ +"People can connect to you only via the links you share." = "С вами можно соединиться только через созданные вами ссылки."; + /* No comment provided by engineer. */ "Please check that you used the correct link or ask your contact to send you another one." = "Пожалуйста, проверьте, что вы использовали правильную ссылку или попросите, чтобы ваш контакт отправил вам другую ссылку."; /* No comment provided by engineer. */ "Please check your network connection and try again." = "Пожалуйста, проверьте ваше соединение с сетью и попробуйте еще раз."; +/* No comment provided by engineer. */ +"Privacy redefined" = "Более конфиденциальный"; + /* No comment provided by engineer. */ "Profile image" = "Аватар"; /* No comment provided by engineer. */ "Read" = "Прочитано"; +/* No comment provided by engineer. */ +"Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme)." = "Узнайте больше из нашего [GitHub репозитория](https://github.com/simplex-chat/simplex-chat#readme)."; + +/* No comment provided by engineer. */ +"Read more in our GitHub repository." = "Узнайте больше из нашего GitHub репозитория."; + /* No comment provided by engineer. */ "received answer…" = "получен ответ…"; @@ -443,6 +506,9 @@ /* No comment provided by engineer. */ "Saved SMP servers will be removed" = "Сохраненные SMP серверы будут удалены"; +/* No comment provided by engineer. */ +"Scan contact's QR code" = "Сосканировать QR код контакта"; + /* No comment provided by engineer. */ "Scan QR code" = "Сканировать QR код"; @@ -467,9 +533,6 @@ /* No comment provided by engineer. */ "Show pending connections" = "Показать ожидаемые соединения"; -/* No comment provided by engineer. */ -"Show QR code to your contact\nto scan from the app" = "Покажите QR код вашему контакту для сканирования в приложении"; - /* No comment provided by engineer. */ "SMP servers" = "SMP серверы"; @@ -488,6 +551,9 @@ /* No comment provided by engineer. */ "Thank you for installing SimpleX Chat!" = "Спасибо, что установили SimpleX Chat!"; +/* No comment provided by engineer. */ +"The 1st platform without any user identifiers – private by design." = "Первая в мире платформа без идентификации пользователей."; + /* No comment provided by engineer. */ "The app can notify you when you receive messages or contact requests - please open settings to enable." = "Приложение может посылать вам уведомления о сообщениях и запросах на соединение - уведомления можно включить в Настройках."; @@ -501,19 +567,28 @@ "The contact you shared this link with will NOT be able to connect!" = "Контакт, которому вы отправили эту ссылку, не сможет соединиться!"; /* No comment provided by engineer. */ -"The messaging and application platform 100% private by design!" = "Платформа для сообщений и приложений, которая защищает вашу личную информацию и безопасность."; +"The next generation of private messaging" = "Новое поколение приватных сообщений"; + +/* No comment provided by engineer. */ +"The profile is only shared with your contacts." = "Профиль отправляется только вашим контактам."; /* No comment provided by engineer. */ "The sender will NOT be notified" = "Отправитель не будет уведомлён"; +/* No comment provided by engineer. */ +"To ask any questions and to receive SimpleX Chat updates." = "Чтобы задать вопросы и получать уведомления о SimpleX Chat."; + /* No comment provided by engineer. */ "To ask any questions and to receive updates:" = "Чтобы задать вопросы и получать уведомления о новых версиях,"; /* No comment provided by engineer. */ -"To connect via link" = "Соединиться через ссылку"; +"To make a new connection" = "Чтобы соединиться"; /* No comment provided by engineer. */ -"To start a new chat" = "Начать новый разговор"; +"To make your first private connection, choose **one of the following**:" = "Чтобы сделать ваше первое приватное соединение, выберите **одно из**:"; + +/* No comment provided by engineer. */ +"To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts." = "Чтобы защитить вашу конфиденциальность, вместо ID пользователей, которые есть в других платформах, SimpleX использует ID для очередей сообщений, разные для каждого контакта."; /* No comment provided by engineer. */ "Trying to connect to the server used to receive messages from this contact (error: %@)." = "Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта (ошибка: %@)."; @@ -585,7 +660,7 @@ "You can use markdown to format messages:" = "Вы можете форматировать сообщения:"; /* No comment provided by engineer. */ -"You control your chat!" = "Вы котролируете Ваш чат!"; +"You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them." = "Вы определяете через какие серверы вы **получаете сообщения**, ваши контакты - серверы, которые вы используете для отправки."; /* No comment provided by engineer. */ "You invited your contact" = "Вы пригласили ваш контакт"; @@ -611,6 +686,9 @@ /* No comment provided by engineer. */ "Your chats" = "Ваши чаты"; +/* No comment provided by engineer. */ +"Your contact can scan it from the app" = "Ваш контакт может сосканировать QR в приложении"; + /* No comment provided by engineer. */ "Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)." = "Ваш контакт должен быть в сети чтобы установить соединение.\nВы можете отменить соединение и удалить контакт (и попробовать позже с другой ссылкой)."; @@ -624,7 +702,7 @@ "Your profile will be sent to the contact that you received this link from" = "Ваш профиль будет отправлен контакту, от которого вы получили эту ссылку."; /* No comment provided by engineer. */ -"Your profile, contacts and messages (once delivered) are only stored locally on your device." = "Ваш профиль, контакты и сообщения (после доставки) хранятся только на вашем устройстве."; +"Your profile, contacts and delivered messages are stored on your device." = "Ваш профиль, контакты и доставленные сообщения хранятся на вашем устройстве."; /* No comment provided by engineer. */ "Your settings" = "Настройки"; From 5e9d72d30998661fc2cf7adcc6293a957e7807cb Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 9 May 2022 10:28:16 +0100 Subject: [PATCH 75/95] ios: translations --- .../ru.xcloc/Localized Contents/ru.xliff | 10 +++++----- apps/ios/ru.lproj/Localizable.strings | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 4544bbaefb..35578b63b4 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -642,7 +642,7 @@ Paste the link you received into the box below to connect with your contact. - Чтобы соединиться, вставьте в это поле ссылку, полученную от вашего контакта. + Чтобы соединиться, вставьте ссылку, полученную от вашего контакта. No comment provided by engineer. @@ -916,7 +916,7 @@ To connect, please ask your contact to create another connection link and check You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button - Вы также можете соединиться, открыв ссылку там, где вы её получили. Если ссылка откроется в браузере, нажмите кнопку **Open in mobile app**. + Вы также можете соединиться, открыв ссылку. Если ссылка откроется в браузере, нажмите кнопку **Open in mobile app**. No comment provided by engineer. @@ -1010,7 +1010,7 @@ SimpleX серверы не могут получить доступ к ваше Your profile will be sent to the contact that you received this link from - Ваш профиль будет отправлен контакту, от которого вы получили эту ссылку. + Ваш профиль будет отправлен вашему контакту. No comment provided by engineer. @@ -1116,7 +1116,7 @@ SimpleX серверы не могут получить доступ к ваше invited to connect - приглашение соединиться + приглашение chat list item title @@ -1196,7 +1196,7 @@ SimpleX серверы не могут получить доступ к ваше you shared one-time link - вы создали одноразовую ссылку + вы создали ссылку chat list item description diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index fb69b87010..2ba8851997 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -378,7 +378,7 @@ "Invalid connection link" = "Ошибка в ссылке контакта"; /* chat list item title */ -"invited to connect" = "приглашение соединиться"; +"invited to connect" = "приглашение"; /* No comment provided by engineer. */ "It seems like you are already connected via this link. If it is not the case, there was an error (%@)." = "Возможно, вы уже соединились через эту ссылку. Если это не так, то это ошибка (%@)."; @@ -453,7 +453,7 @@ "Paste the link you received" = "Вставьте полученную ссылку"; /* No comment provided by engineer. */ -"Paste the link you received into the box below to connect with your contact." = "Чтобы соединиться, вставьте в это поле ссылку, полученную от вашего контакта."; +"Paste the link you received into the box below to connect with your contact." = "Чтобы соединиться, вставьте ссылку, полученную от вашего контакта."; /* No comment provided by engineer. */ "People can connect to you only via the links you share." = "С вами можно соединиться только через созданные вами ссылки."; @@ -648,7 +648,7 @@ "You are connected to the server used to receive messages from this contact." = "Установлено соединение с сервером, через который вы получаете сообщения от этого контакта."; /* No comment provided by engineer. */ -"You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button" = "Вы также можете соединиться, открыв ссылку там, где вы её получили. Если ссылка откроется в браузере, нажмите кнопку **Open in mobile app**."; +"You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button" = "Вы также можете соединиться, открыв ссылку. Если ссылка откроется в браузере, нажмите кнопку **Open in mobile app**."; /* notification body */ "You can now send messages to %@" = "Вы теперь можете отправлять сообщения %@"; @@ -666,7 +666,7 @@ "You invited your contact" = "Вы пригласили ваш контакт"; /* chat list item description */ -"you shared one-time link" = "вы создали одноразовую ссылку"; +"you shared one-time link" = "вы создали ссылку"; /* No comment provided by engineer. */ "You will be connected when your connection request is accepted, please wait or check later!" = "Соединение будет установлено, когда ваш запрос будет принят. Пожалуйста, подождите или проверьте позже!"; @@ -699,7 +699,7 @@ "Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "Ваш профиль хранится на вашем устройстве и отправляется только вашим контактам.\nSimpleX серверы не могут получить доступ к вашему профилю."; /* No comment provided by engineer. */ -"Your profile will be sent to the contact that you received this link from" = "Ваш профиль будет отправлен контакту, от которого вы получили эту ссылку."; +"Your profile will be sent to the contact that you received this link from" = "Ваш профиль будет отправлен вашему контакту."; /* No comment provided by engineer. */ "Your profile, contacts and delivered messages are stored on your device." = "Ваш профиль, контакты и доставленные сообщения хранятся на вашем устройстве."; From c61707a35847a77bca9a09af7b43c41e7e17ddf6 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Mon, 9 May 2022 14:09:10 +0400 Subject: [PATCH 76/95] android: save images to Gallery (#622) --- apps/android/app/src/main/AndroidManifest.xml | 1 + .../app/views/chat/item/ChatItemView.kt | 6 ++- .../chat/simplex/app/views/helpers/Share.kt | 46 +++++++++++++------ .../app/src/main/res/values-ru/strings.xml | 1 + .../app/src/main/res/values/strings.xml | 1 + 5 files changed, 41 insertions(+), 14 deletions(-) diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index 113ffa93e2..5062b90ed6 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -15,6 +15,7 @@ + saveImage(context, cItem.file) + is MsgContent.MCFile -> saveFileLauncher.launch(cItem.file?.fileName) + else -> {} + } showMenu.value = false }) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt index 6c66b2bbb8..b02f0c535f 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt @@ -2,6 +2,7 @@ package chat.simplex.app.views.helpers import android.content.* import android.net.Uri +import android.provider.MediaStore import android.widget.Toast import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult @@ -10,8 +11,7 @@ import androidx.compose.runtime.Composable import androidx.core.content.ContextCompat import chat.simplex.app.R import chat.simplex.app.model.CIFile -import java.io.File -import java.io.IOException +import java.io.* fun shareText(cxt: Context, text: String) { val sendIntent: Intent = Intent().apply { @@ -33,20 +33,16 @@ fun rememberSaveFileLauncher(cxt: Context, ciFile: CIFile?): ManagedActivityResu rememberLauncherForActivityResult( contract = ActivityResultContracts.CreateDocument(), onResult = { destination -> - if (destination != null) { + destination?.let { val filePath = getLoadedFilePath(cxt, ciFile) if (filePath != null) { val contentResolver = cxt.contentResolver - val file = File(filePath) - try { - val outputStream = contentResolver.openOutputStream(destination) - if (outputStream != null) { - outputStream.write(file.readBytes()) - outputStream.close() - Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show() - } - } catch (e: IOException) { - Toast.makeText(cxt, generalGetString(R.string.error_saving_file), Toast.LENGTH_SHORT).show() + contentResolver.openOutputStream(destination)?.let { stream -> + val outputStream = BufferedOutputStream(stream) + val file = File(filePath) + outputStream.write(file.readBytes()) + outputStream.close() + Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show() } } else { Toast.makeText(cxt, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show() @@ -54,3 +50,27 @@ fun rememberSaveFileLauncher(cxt: Context, ciFile: CIFile?): ManagedActivityResu } } ) + +fun saveImage(cxt: Context, ciFile: CIFile?) { + val filePath = getLoadedFilePath(cxt, ciFile) + val fileName = ciFile?.fileName + if (filePath != null && fileName != null) { + val values = ContentValues() + values.put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis()) + values.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + values.put(MediaStore.MediaColumns.TITLE, fileName) + val uri = cxt.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) + uri?.let { + cxt.contentResolver.openOutputStream(uri)?.let { stream -> + val outputStream = BufferedOutputStream(stream) + val file = File(filePath) + outputStream.write(file.readBytes()) + outputStream.close() + Toast.makeText(cxt, generalGetString(R.string.image_saved), Toast.LENGTH_SHORT).show() + } + } + } else { + Toast.makeText(cxt, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show() + } +} diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index 8e2d39232a..7ab856193a 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -99,6 +99,7 @@ Изображение отправлено Ожидается прием изображения Изображение будет принято, когда ваш контакт будет в сети, подождите или проверьте позже! + Изображение сохранено в Галерею Файл diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index e6bcd98aa3..6e66cb3bb8 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -99,6 +99,7 @@ Image sent Waiting for image Image will be received when your contact is online, please wait or check later! + Image saved to Gallery File From 95c79c1b5c4d3cb01fa6d5f0c8f6d3cecf3b8284 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Mon, 9 May 2022 14:38:18 +0400 Subject: [PATCH 77/95] ios: version 2.0 (41) --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index d4832f9178..beaa8f97f0 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -943,7 +943,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 40; + CURRENT_PROJECT_VERSION = 41; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -965,7 +965,7 @@ ); "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; - MARKETING_VERSION = 1.7; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -986,7 +986,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 40; + CURRENT_PROJECT_VERSION = 41; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1008,7 +1008,7 @@ ); "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; - MARKETING_VERSION = 1.7; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; From 3e4826395e325fde000a7460a5730729afdc8884 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 9 May 2022 13:13:53 +0100 Subject: [PATCH 78/95] ios: fix onboarding info layout for small screen (#623) * ios: fix onboarding info layout for small screen * update make connection onboarding view --- .../Views/Onboarding/MakeConnection.swift | 23 ++++++++------- .../Shared/Views/Onboarding/SimpleXInfo.swift | 28 +++++++++++-------- 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/apps/ios/Shared/Views/Onboarding/MakeConnection.swift b/apps/ios/Shared/Views/Onboarding/MakeConnection.swift index 2d0b7b88e4..4b077a1729 100644 --- a/apps/ios/Shared/Views/Onboarding/MakeConnection.swift +++ b/apps/ios/Shared/Views/Onboarding/MakeConnection.swift @@ -114,24 +114,23 @@ struct MakeConnection: View { } private func actionRow(icon: String, title: LocalizedStringKey, text: LocalizedStringKey, action: @escaping () -> Void) -> some View { - HStack(alignment: .top, spacing: 20) { - Button(action: action, label: { + Button(action: action, label: { + HStack(alignment: .top, spacing: 20) { Image(systemName: icon) .resizable() .scaledToFit() .frame(width: 30, height: 30) - .padding(.leading, 6) + .padding(.leading, 4) .padding(.top, 6) - }) - VStack(alignment: .leading) { - Button(action: action, label: { - Text(title) - .font(.headline) - .multilineTextAlignment(.leading) - }) - Text(text) + VStack(alignment: .leading) { + Group { + Text(title).font(.headline) + Text(text).foregroundColor(.primary) + } + .multilineTextAlignment(.leading) + } } - } + }) .padding(.bottom) } } diff --git a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift index 2468b7290a..e234e48cce 100644 --- a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift +++ b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift @@ -20,17 +20,22 @@ struct SimpleXInfo: View { .resizable() .aspectRatio(contentMode: .fit) .frame(width: g.size.width * 0.7) - .padding(.bottom) - Text("The next generation of private messaging") - .font(.title) - .padding(.bottom) + .padding(.bottom, 8) - infoRow("🎭", "Privacy redefined", - "The 1st platform without any user identifiers – private by design.") - infoRow("📭", "Immune to spam and abuse", - "People can connect to you only via the links you share.") - infoRow("🤝", "Decentralized", - "Open-source protocol and code – anybody can run the servers.") + ScrollView { + VStack(alignment: .leading) { + Text("The next generation of private messaging") + .font(.title) + .padding(.bottom) + infoRow("🎭", "Privacy redefined", + "The 1st platform without any user identifiers – private by design.") + infoRow("📭", "Immune to spam and abuse", + "People can connect to you only via the links you share.") + infoRow("🤝", "Decentralized", + "Open-source protocol and code – anybody can run the servers.") + } + } + .padding(.bottom) Spacer() @@ -65,7 +70,7 @@ struct SimpleXInfo: View { Text(text) } } - .padding(.bottom) + .padding(.bottom, 8) } } @@ -92,6 +97,7 @@ struct OnboardingActionButton: View { } } .frame(maxWidth: .infinity) + .padding(.bottom) } } From a3e6582a648027cb591503eecdf17a6c6224bdc3 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 9 May 2022 13:17:30 +0100 Subject: [PATCH 79/95] ios: update version 2.0 (42) --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index beaa8f97f0..016b03a0a0 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -943,7 +943,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 41; + CURRENT_PROJECT_VERSION = 42; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -986,7 +986,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 41; + CURRENT_PROJECT_VERSION = 42; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1067,7 +1067,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 40; + CURRENT_PROJECT_VERSION = 42; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1088,7 +1088,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 1.7; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -1107,7 +1107,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 40; + CURRENT_PROJECT_VERSION = 42; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1128,7 +1128,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 1.7; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; From ee3d7ae97e36cae2e93e6d2aeb7e4596594bddd6 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Mon, 9 May 2022 16:24:24 +0400 Subject: [PATCH 80/95] core: update simplexmq hash (v2.0.0) (#625) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- stack.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cabal.project b/cabal.project index fa47eee7b2..d07117a669 100644 --- a/cabal.project +++ b/cabal.project @@ -3,7 +3,7 @@ packages: . source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 1064e9c315bb4b9c09ba62141f86e21690876d76 + tag: 964daf5442e1069634762450bc28cfd69a2968a1 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 523616273e..984e8afc7a 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."1064e9c315bb4b9c09ba62141f86e21690876d76" = "10acs17k6rp6xx58rsfwz0xgq6yhfkiy2rxj86pyxan7867dnawr"; + "https://github.com/simplex-chat/simplexmq.git"."964daf5442e1069634762450bc28cfd69a2968a1" = "1vsbiawqlvi6v48ws2rmg5cmp5qphnry3ymg6458p2w8wdm2gsng"; "https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp"; "https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; "https://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97"; diff --git a/stack.yaml b/stack.yaml index cfa84cd45c..5b6b00b395 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 1064e9c315bb4b9c09ba62141f86e21690876d76 + commit: 964daf5442e1069634762450bc28cfd69a2968a1 # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 - github: simplex-chat/aeson commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7 From 4dcf1f8d15120b685c74e6105e8e01686b46a614 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Mon, 9 May 2022 18:53:39 +0400 Subject: [PATCH 81/95] test: add missing fields to ServerConfig --- tests/ChatClient.hs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 688ccabf77..b5a598bbcd 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -213,7 +213,9 @@ serverCfg = inactiveClientExpiration = Just defaultInactiveClientExpiration, caCertificateFile = "tests/fixtures/tls/ca.crt", privateKeyFile = "tests/fixtures/tls/server.key", - certificateFile = "tests/fixtures/tls/server.crt" + certificateFile = "tests/fixtures/tls/server.crt", + logStatsInterval = Just 86400, + logStatsStartTime = 0 } withSmpServer :: IO a -> IO a From 69e21781df1f1f6e4c89d96e655ac2cbb600119e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 9 May 2022 17:40:39 +0100 Subject: [PATCH 82/95] ios: update libs, fix background refresh crash, remove NSE from app, v2.0 (43) (#626) * ios: update libs, fix background refresh crash, remove NSE from app, v2.0 (43) * remove geometryreader * update translations * update translations --- apps/ios/Shared/Model/SimpleXAPI.swift | 30 +++--- .../Views/Onboarding/CreateProfile.swift | 94 +++++++++--------- .../Shared/Views/Onboarding/SimpleXInfo.swift | 24 ++--- .../ru.xcloc/Localized Contents/ru.xliff | 6 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 99 +++++++------------ apps/ios/ru.lproj/Localizable.strings | 6 +- 6 files changed, 114 insertions(+), 145 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 9f02f52206..b6573f1bbb 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -133,10 +133,13 @@ func apiCreateActiveUser(_ p: Profile) throws -> User { throw r } -func apiStartChat() throws { +func apiStartChat() throws -> Bool { let r = chatSendCmdSync(.startChat) - if case .chatStarted = r { return } - throw r + switch r { + case .chatStarted: return true + case .chatRunning: return false + default: throw r + } } func apiSetFilesFolder(filesFolder: String) throws { @@ -453,15 +456,18 @@ func startChat() { logger.debug("startChat") do { let m = ChatModel.shared - try apiStartChat() - try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) - m.userAddress = try apiGetUserAddress() - m.userSMPServers = try getUserSMPServers() - m.chats = try apiGetChats() - withAnimation { - m.onboardingStage = m.chats.isEmpty - ? .step3_MakeConnection - : .onboardingComplete + // TODO set file folder once, before chat is started + let justStarted = try apiStartChat() + if justStarted { + try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) + m.userAddress = try apiGetUserAddress() + m.userSMPServers = try getUserSMPServers() + m.chats = try apiGetChats() + withAnimation { + m.onboardingStage = m.chats.isEmpty + ? .step3_MakeConnection + : .onboardingComplete + } } ChatReceiver.shared.start() } catch { diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift index b6e2bbe7ed..cae263e938 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -16,66 +16,64 @@ struct CreateProfile: View { @FocusState private var focusFullName var body: some View { - GeometryReader { g in - VStack(alignment: .leading) { - Text("Create your profile") - .font(.largeTitle) - .padding(.bottom, 4) - Text("Your profile, contacts and delivered messages are stored on your device.") - .padding(.bottom, 4) - Text("The profile is only shared with your contacts.") - .padding(.bottom) - ZStack(alignment: .topLeading) { - if !validDisplayName(displayName) { - Image(systemName: "exclamationmark.circle") - .foregroundColor(.red) - .padding(.top, 4) - } - textField("Display name", text: $displayName) - .focused($focusDisplayName) - .submitLabel(.next) - .onSubmit { - if canCreateProfile() { focusFullName = true } - else { focusDisplayName = true } - } + VStack(alignment: .leading) { + Text("Create your profile") + .font(.largeTitle) + .padding(.bottom, 4) + Text("Your profile, contacts and delivered messages are stored on your device.") + .padding(.bottom, 4) + Text("The profile is only shared with your contacts.") + .padding(.bottom) + ZStack(alignment: .topLeading) { + if !validDisplayName(displayName) { + Image(systemName: "exclamationmark.circle") + .foregroundColor(.red) + .padding(.top, 4) } - textField("Full name (optional)", text: $fullName) - .focused($focusFullName) - .submitLabel(.go) + textField("Display name", text: $displayName) + .focused($focusDisplayName) + .submitLabel(.next) .onSubmit { - if canCreateProfile() { createProfile() } - else { focusFullName = true } + if canCreateProfile() { focusFullName = true } + else { focusDisplayName = true } } + } + textField("Full name (optional)", text: $fullName) + .focused($focusFullName) + .submitLabel(.go) + .onSubmit { + if canCreateProfile() { createProfile() } + else { focusFullName = true } + } + + Spacer() + + HStack { + Button { + hideKeyboard() + withAnimation { m.onboardingStage = .step1_SimpleXInfo } + } label: { + HStack { + Image(systemName: "lessthan") + Text("About SimpleX") + } + } Spacer() HStack { Button { - hideKeyboard() - withAnimation { m.onboardingStage = .step1_SimpleXInfo } + createProfile() } label: { - HStack { - Image(systemName: "lessthan") - Text("About SimpleX") - } - } - - Spacer() - - HStack { - Button { - createProfile() - } label: { - Text("Create") - Image(systemName: "greaterthan") - } - .disabled(!canCreateProfile()) + Text("Create") + Image(systemName: "greaterthan") } + .disabled(!canCreateProfile()) } } - .onAppear() { - focusDisplayName = true - } + } + .onAppear() { + focusDisplayName = true } .padding() } diff --git a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift index e234e48cce..2d05852640 100644 --- a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift +++ b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift @@ -22,23 +22,19 @@ struct SimpleXInfo: View { .frame(width: g.size.width * 0.7) .padding(.bottom, 8) - ScrollView { - VStack(alignment: .leading) { - Text("The next generation of private messaging") - .font(.title) - .padding(.bottom) - infoRow("🎭", "Privacy redefined", - "The 1st platform without any user identifiers – private by design.") - infoRow("📭", "Immune to spam and abuse", - "People can connect to you only via the links you share.") - infoRow("🤝", "Decentralized", - "Open-source protocol and code – anybody can run the servers.") - } + VStack(alignment: .leading) { + Text("The next generation of private messaging") + .font(.title) + .padding(.bottom) + infoRow("🎭", "Privacy redefined", + "The 1st platform without any user identifiers – private by design.") + infoRow("📭", "Immune to spam and abuse", + "People can connect to you only via the links you share.") + infoRow("🤝", "Decentralized", + "Open-source protocol and code – anybody can run the servers.") } - .padding(.bottom) Spacer() - if onboarding { OnboardingActionButton() Spacer() diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 35578b63b4..d924c6dbea 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -557,7 +557,7 @@ Make a private connection - Создайте соединение + Добавьте контакт No comment provided by engineer. @@ -617,7 +617,7 @@ Open-source protocol and code – anybody can run the servers. - Открытый протокол и код - кто угодно может запускать серверы. + Открытый протокол и код - кто угодно может запустить сервер. No comment provided by engineer. @@ -839,7 +839,7 @@ To make your first private connection, choose **one of the following**: - Чтобы сделать ваше первое приватное соединение, выберите **одно из**: + Чтобы добавить ваш первый контакт, выберите **одно из**: No comment provided by engineer. diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 016b03a0a0..2618f76c7a 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -16,6 +16,16 @@ 5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; }; 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C13730A28156D2700F43030 /* ContactConnectionView.swift */; }; 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; }; + 5C293B582829490C00CB16C0 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C293B532829490C00CB16C0 /* libffi.a */; }; + 5C293B592829490C00CB16C0 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C293B532829490C00CB16C0 /* libffi.a */; }; + 5C293B5A2829490C00CB16C0 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C293B542829490C00CB16C0 /* libgmpxx.a */; }; + 5C293B5B2829490C00CB16C0 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C293B542829490C00CB16C0 /* libgmpxx.a */; }; + 5C293B5C2829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C293B552829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M-ghc8.10.7.a */; }; + 5C293B5D2829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C293B552829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M-ghc8.10.7.a */; }; + 5C293B5E2829490C00CB16C0 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C293B562829490C00CB16C0 /* libgmp.a */; }; + 5C293B5F2829490C00CB16C0 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C293B562829490C00CB16C0 /* libgmp.a */; }; + 5C293B602829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C293B572829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M.a */; }; + 5C293B612829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C293B572829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M.a */; }; 5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */; }; 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260A27A30CFA00F70299 /* ChatListView.swift */; }; 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260E27A30FDC00F70299 /* ChatView.swift */; }; @@ -26,11 +36,6 @@ 5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */; }; 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */; }; 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; - 5C545C8C2812A834007A6B96 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C872812A833007A6B96 /* libgmpxx.a */; }; - 5C545C8D2812A834007A6B96 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C882812A833007A6B96 /* libgmp.a */; }; - 5C545C8E2812A834007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C892812A833007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK.a */; }; - 5C545C8F2812A834007A6B96 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C8A2812A833007A6B96 /* libffi.a */; }; - 5C545C902812A834007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C8B2812A833007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK-ghc8.10.7.a */; }; 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; }; 5C5E5D3B2824468B00B0488A /* ActiveCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5E5D3A2824468B00B0488A /* ActiveCallView.swift */; }; 5C5E5D3D282447AB00B0488A /* CallTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5E5D3C282447AB00B0488A /* CallTypes.swift */; }; @@ -76,15 +81,9 @@ 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */; }; 5CCD403A27A5F9BE00368C90 /* CreateGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */; }; 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD472818589900503DA2 /* NotificationService.swift */; }; - 5CDCAD4C2818589900503DA2 /* SimpleX NSE.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 5CDCAD5328186F9500503DA2 /* GroupDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD5228186F9500503DA2 /* GroupDefaults.swift */; }; 5CDCAD5428186F9700503DA2 /* GroupDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD5228186F9500503DA2 /* GroupDefaults.swift */; }; 5CDCAD5828187C7500503DA2 /* dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD5728187C7500503DA2 /* dummy.m */; }; - 5CDCAD5928187CF200503DA2 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C882812A833007A6B96 /* libgmp.a */; }; - 5CDCAD5A28187CF200503DA2 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C8A2812A833007A6B96 /* libffi.a */; }; - 5CDCAD5B28187CF200503DA2 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C8B2812A833007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK-ghc8.10.7.a */; }; - 5CDCAD5C28187CF200503DA2 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C872812A833007A6B96 /* libgmpxx.a */; }; - 5CDCAD5D28187CF200503DA2 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C892812A833007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK.a */; }; 5CDCAD5F28187D6900503DA2 /* libiconv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDCAD5E28187D4A00503DA2 /* libiconv.tbd */; }; 5CDCAD6128187D8000503DA2 /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDCAD6028187D7900503DA2 /* libz.tbd */; }; 5CDCAD682818876500503DA2 /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96A27A56D4D0075386C /* JSON.swift */; }; @@ -119,29 +118,8 @@ remoteGlobalIDString = 5CA059C9279559F40002BEB4; remoteInfo = "SimpleX (iOS)"; }; - 5CDCAD4A2818589900503DA2 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 5CA059BE279559F40002BEB4 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 5CDCAD442818589900503DA2; - remoteInfo = "SimpleX NSE"; - }; /* End PBXContainerItemProxy section */ -/* Begin PBXCopyFilesBuildPhase section */ - 5CDCAD4D2818589900503DA2 /* Embed App Extensions */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 13; - files = ( - 5CDCAD4C2818589900503DA2 /* SimpleX NSE.appex in Embed App Extensions */, - ); - name = "Embed App Extensions"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - /* Begin PBXFileReference section */ 3C714776281C081000CB4D4B /* WebRTCView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTCView.swift; sourceTree = ""; }; 3C714779281C0F6800CB4D4B /* www */ = {isa = PBXFileReference; lastKnownFileType = folder; name = www; path = ../android/app/src/main/assets/www; sourceTree = ""; }; @@ -153,6 +131,11 @@ 5C13730A28156D2700F43030 /* ContactConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactConnectionView.swift; sourceTree = ""; }; 5C13730C2815740A00F43030 /* DebugJSON.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = DebugJSON.playground; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemView.swift; sourceTree = ""; }; + 5C293B532829490C00CB16C0 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C293B542829490C00CB16C0 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5C293B552829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M-ghc8.10.7.a"; sourceTree = ""; }; + 5C293B562829490C00CB16C0 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5C293B572829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M.a"; sourceTree = ""; }; 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXAPI.swift; sourceTree = ""; }; 5C2E260A27A30CFA00F70299 /* ChatListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListView.swift; sourceTree = ""; }; 5C2E260E27A30FDC00F70299 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; @@ -164,11 +147,6 @@ 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FramedItemView.swift; sourceTree = ""; }; 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; }; 5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = ""; }; - 5C545C872812A833007A6B96 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5C545C882812A833007A6B96 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C545C892812A833007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK.a"; sourceTree = ""; }; - 5C545C8A2812A833007A6B96 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C545C8B2812A833007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK-ghc8.10.7.a"; sourceTree = ""; }; 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = ""; }; 5C5E5D3A2824468B00B0488A /* ActiveCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveCallView.swift; sourceTree = ""; }; 5C5E5D3C282447AB00B0488A /* CallTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallTypes.swift; sourceTree = ""; }; @@ -247,14 +225,14 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5C545C8C2812A834007A6B96 /* libgmpxx.a in Frameworks */, - 5C545C8D2812A834007A6B96 /* libgmp.a in Frameworks */, 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */, + 5C293B5E2829490C00CB16C0 /* libgmp.a in Frameworks */, + 5C293B5C2829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M-ghc8.10.7.a in Frameworks */, + 5C293B5A2829490C00CB16C0 /* libgmpxx.a in Frameworks */, + 5C293B602829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M.a in Frameworks */, 5C764E83279C748B000C6508 /* libz.tbd in Frameworks */, - 5C545C8F2812A834007A6B96 /* libffi.a in Frameworks */, - 5C545C8E2812A834007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK.a in Frameworks */, 5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */, - 5C545C902812A834007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK-ghc8.10.7.a in Frameworks */, + 5C293B582829490C00CB16C0 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -269,13 +247,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5C293B5B2829490C00CB16C0 /* libgmpxx.a in Frameworks */, 5CDCAD5F28187D6900503DA2 /* libiconv.tbd in Frameworks */, - 5CDCAD5C28187CF200503DA2 /* libgmpxx.a in Frameworks */, + 5C293B592829490C00CB16C0 /* libffi.a in Frameworks */, + 5C293B5D2829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M-ghc8.10.7.a in Frameworks */, + 5C293B5F2829490C00CB16C0 /* libgmp.a in Frameworks */, + 5C293B612829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M.a in Frameworks */, 5CDCAD6128187D8000503DA2 /* libz.tbd in Frameworks */, - 5CDCAD5B28187CF200503DA2 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK-ghc8.10.7.a in Frameworks */, - 5CDCAD5928187CF200503DA2 /* libgmp.a in Frameworks */, - 5CDCAD5A28187CF200503DA2 /* libffi.a in Frameworks */, - 5CDCAD5D28187CF200503DA2 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -324,11 +302,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C545C8A2812A833007A6B96 /* libffi.a */, - 5C545C882812A833007A6B96 /* libgmp.a */, - 5C545C872812A833007A6B96 /* libgmpxx.a */, - 5C545C8B2812A833007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK-ghc8.10.7.a */, - 5C545C892812A833007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK.a */, + 5C293B532829490C00CB16C0 /* libffi.a */, + 5C293B562829490C00CB16C0 /* libgmp.a */, + 5C293B542829490C00CB16C0 /* libgmpxx.a */, + 5C293B552829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M-ghc8.10.7.a */, + 5C293B572829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M.a */, ); path = Libraries; sourceTree = ""; @@ -538,12 +516,10 @@ 5CA059C6279559F40002BEB4 /* Sources */, 5CA059C7279559F40002BEB4 /* Frameworks */, 5CA059C8279559F40002BEB4 /* Resources */, - 5CDCAD4D2818589900503DA2 /* Embed App Extensions */, ); buildRules = ( ); dependencies = ( - 5CDCAD4B2818589900503DA2 /* PBXTargetDependency */, ); name = "SimpleX (iOS)"; packageProductDependencies = ( @@ -777,11 +753,6 @@ target = 5CA059C9279559F40002BEB4 /* SimpleX (iOS) */; targetProxy = 5CA059D8279559F40002BEB4 /* PBXContainerItemProxy */; }; - 5CDCAD4B2818589900503DA2 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 5CDCAD442818589900503DA2 /* SimpleX NSE */; - targetProxy = 5CDCAD4A2818589900503DA2 /* PBXContainerItemProxy */; - }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -937,13 +908,12 @@ 5CA059F4279559F40002BEB4 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 42; + CURRENT_PROJECT_VERSION = 43; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -980,13 +950,12 @@ 5CA059F5279559F40002BEB4 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 42; + CURRENT_PROJECT_VERSION = 43; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1067,7 +1036,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 42; + CURRENT_PROJECT_VERSION = 43; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1107,7 +1076,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 42; + CURRENT_PROJECT_VERSION = 43; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 2ba8851997..40439a9b77 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -393,7 +393,7 @@ "Large file!" = "Большой файл!"; /* No comment provided by engineer. */ -"Make a private connection" = "Создайте соединение"; +"Make a private connection" = "Добавьте контакт"; /* No comment provided by engineer. */ "Make sure SMP server addresses are in correct format, line separated and are not duplicated." = "Пожалуйста, проверьте, что адреса SMP серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется."; @@ -435,7 +435,7 @@ "Open Settings" = "Открыть Настройки"; /* No comment provided by engineer. */ -"Open-source protocol and code – anybody can run the servers." = "Открытый протокол и код - кто угодно может запускать серверы."; +"Open-source protocol and code – anybody can run the servers." = "Открытый протокол и код - кто угодно может запустить сервер."; /* No comment provided by engineer. */ "or" = "или"; @@ -585,7 +585,7 @@ "To make a new connection" = "Чтобы соединиться"; /* No comment provided by engineer. */ -"To make your first private connection, choose **one of the following**:" = "Чтобы сделать ваше первое приватное соединение, выберите **одно из**:"; +"To make your first private connection, choose **one of the following**:" = "Чтобы добавить ваш первый контакт, выберите **одно из**:"; /* No comment provided by engineer. */ "To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts." = "Чтобы защитить вашу конфиденциальность, вместо ID пользователей, которые есть в других платформах, SimpleX использует ID для очередей сообщений, разные для каждого контакта."; From 412982cc012ded64abed042594bfcceacf63cd82 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 10 May 2022 08:03:43 +0100 Subject: [PATCH 83/95] android: onboarding (#624) * android: onboarding views * create profile * creating profile works * make connection view * onboarding layout * add translations --- .../java/chat/simplex/app/MainActivity.kt | 24 +- .../main/java/chat/simplex/app/SimplexApp.kt | 5 +- .../java/chat/simplex/app/SimplexService.kt | 5 +- .../java/chat/simplex/app/model/ChatModel.kt | 2 + .../java/chat/simplex/app/model/SimpleXAPI.kt | 5 +- .../chat/simplex/app/views/WelcomeView.kt | 255 ++++++++---------- .../chat/simplex/app/views/call/WebRTC.kt | 2 +- .../simplex/app/views/chat/ComposeFileView.kt | 5 +- .../simplex/app/views/chat/ComposeView.kt | 6 +- .../simplex/app/views/chat/ContextItemView.kt | 2 +- .../simplex/app/views/chat/SendMsgView.kt | 5 +- .../app/views/chat/item/CIImageView.kt | 9 +- .../app/views/chat/item/FramedItemView.kt | 3 +- .../views/chat/item/ImageFullScreenView.kt | 3 +- .../app/views/chatlist/ChatListView.kt | 4 +- .../views/chatlist/ContactConnectionView.kt | 6 +- .../app/views/chatlist/ContactRequestView.kt | 3 +- .../app/views/helpers/ChatInfoImage.kt | 1 - .../chat/simplex/app/views/helpers/Share.kt | 3 +- .../simplex/app/views/helpers/SimpleButton.kt | 19 +- .../app/views/onboarding/HowItWorks.kt | 70 +++++ .../app/views/onboarding/MakeConnection.kt | 172 ++++++++++++ .../app/views/onboarding/OnboardingView.kt | 47 ++++ .../app/views/onboarding/SimpleXInfo.kt | 139 ++++++++++ .../app/views/usersettings/SettingsView.kt | 10 + .../app/src/main/res/values-ru/strings.xml | 40 ++- .../app/src/main/res/values/strings.xml | 42 ++- .../ru.xcloc/Localized Contents/ru.xliff | 2 +- apps/ios/ru.lproj/Localizable.strings | 2 +- 29 files changed, 699 insertions(+), 192 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/onboarding/HowItWorks.kt create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/onboarding/MakeConnection.kt create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/onboarding/OnboardingView.kt create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/onboarding/SimpleXInfo.kt diff --git a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt index d1b106b29f..4be64a5c68 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt @@ -9,25 +9,27 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.* import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.AndroidViewModel import androidx.work.* -import chat.simplex.app.model.* +import chat.simplex.app.model.ChatModel +import chat.simplex.app.model.NtfManager import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.SplashView -import chat.simplex.app.views.WelcomeView import chat.simplex.app.views.chat.ChatView import chat.simplex.app.views.chatlist.ChatListView import chat.simplex.app.views.chatlist.openChat import chat.simplex.app.views.helpers.* import chat.simplex.app.views.newchat.connectViaUri import chat.simplex.app.views.newchat.withUriAction +import chat.simplex.app.views.onboarding.* import java.util.concurrent.TimeUnit + //import kotlinx.serialization.decodeFromString class MainActivity: ComponentActivity() { @@ -83,12 +85,18 @@ class SimplexViewModel(application: Application): AndroidViewModel(application) @Composable fun MainPage(chatModel: ChatModel) { Box { - when (chatModel.userCreated.value) { - null -> SplashView() - false -> WelcomeView(chatModel) - true -> + val onboarding = chatModel.onboardingStage.value + val userCreated = chatModel.userCreated.value + when { + onboarding == null || userCreated == null -> SplashView() + onboarding == OnboardingStage.OnboardingComplete && userCreated -> if (chatModel.chatId.value == null) ChatListView(chatModel) else ChatView(chatModel) + onboarding == OnboardingStage.Step1_SimpleXInfo -> + Box(Modifier.padding(horizontal = 20.dp)) { + SimpleXInfo(chatModel, onboarding = true) + } + onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel) } ModalManager.shared.showInView() AlertManager.shared.showInView() diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt index 770cb0c643..e560e98053 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.* import chat.simplex.app.model.* import chat.simplex.app.views.helpers.getFilesDirectory import chat.simplex.app.views.helpers.withApi +import chat.simplex.app.views.onboarding.OnboardingStage import java.io.BufferedReader import java.io.InputStreamReader import java.util.* @@ -46,7 +47,9 @@ class SimplexApp: Application(), LifecycleEventObserver { ProcessLifecycleOwner.get().lifecycle.addObserver(this) withApi { val user = chatController.apiGetActiveUser() - if (user != null) { + if (user == null) { + chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo + } else { chatController.startChat(user) SimplexService.start(applicationContext) chatController.showBackgroundServiceNotice() diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt index 34907f9a48..94ae6a7371 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt @@ -8,6 +8,7 @@ import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import androidx.work.* import chat.simplex.app.views.helpers.withApi +import chat.simplex.app.views.onboarding.OnboardingStage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -65,7 +66,9 @@ class SimplexService: Service() { withApi { try { val user = chatController.apiGetActiveUser() - if (user != null) { + if (user == null) { + chatController.chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo + } else { Log.w(TAG, "Starting foreground service") chatController.startChat(user) chatController.startReceiver() diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index acf8469c83..1e928074d0 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -12,6 +12,7 @@ import chat.simplex.app.ui.theme.SecretColor import chat.simplex.app.ui.theme.SimplexBlue import chat.simplex.app.views.call.* import chat.simplex.app.views.helpers.generalGetString +import chat.simplex.app.views.onboarding.OnboardingStage import kotlinx.datetime.* import kotlinx.serialization.* import kotlinx.serialization.descriptors.* @@ -20,6 +21,7 @@ import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.* class ChatModel(val controller: ChatController) { + val onboardingStage = mutableStateOf(null) val currentUser = mutableStateOf(null) val userCreated = mutableStateOf(null) val chats = mutableStateListOf() diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index c34d68da19..9f2e2835e8 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Bolt -import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -18,6 +17,7 @@ import chat.simplex.app.* import chat.simplex.app.R import chat.simplex.app.views.call.* import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.onboarding.OnboardingStage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.datetime.Clock @@ -49,6 +49,7 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt chatModel.chats.addAll(chats) chatModel.currentUser.value = user chatModel.userCreated.value = true + chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete Log.d(TAG, "started chat") } catch(e: Error) { Log.e(TAG, "failed starting chat $e") @@ -949,7 +950,9 @@ sealed class ChatErrorType { val string: String get() = when (this) { is InvalidConnReq -> "invalidConnReq" is ContactGroups -> "groupNames $groupNames" + is NoActiveUser -> "noActiveUser" } + @Serializable @SerialName("noActiveUser") class NoActiveUser: ChatErrorType() @Serializable @SerialName("invalidConnReq") class InvalidConnReq: ChatErrorType() @Serializable @SerialName("contactGroups") class ContactGroups(val contact: Contact, val groupNames: List): ChatErrorType() } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt index da3d6c07cf..9bbb445e2e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt @@ -1,15 +1,22 @@ package chat.simplex.app.views -import androidx.compose.foundation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ArrowBackIosNew +import androidx.compose.material.icons.outlined.ArrowForwardIos import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp @@ -18,69 +25,12 @@ import chat.simplex.app.R import chat.simplex.app.SimplexService import chat.simplex.app.model.ChatModel import chat.simplex.app.model.Profile -import chat.simplex.app.views.helpers.getKeyboardState +import chat.simplex.app.ui.theme.HighOrLowlight +import chat.simplex.app.ui.theme.SimpleButton import chat.simplex.app.views.helpers.withApi -import com.google.accompanist.insets.ProvideWindowInsets +import chat.simplex.app.views.onboarding.OnboardingStage +import chat.simplex.app.views.onboarding.ReadableText import com.google.accompanist.insets.navigationBarsWithImePadding -import kotlinx.coroutines.launch - -@Composable -fun WelcomeView(chatModel: ChatModel) { - val scope = rememberCoroutineScope() - val scrollState = rememberScrollState() - val keyboardState by getKeyboardState() - var savedKeyboardState by remember { mutableStateOf(keyboardState) } - - ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { - Box( - modifier = Modifier - .fillMaxSize() - .background(color = MaterialTheme.colors.background) - ) { - Column( - verticalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .verticalScroll(scrollState) - .fillMaxSize() - .background(color = MaterialTheme.colors.background) - .padding(12.dp) - ) { - Image( - painter = painterResource(R.drawable.logo), - contentDescription = stringResource(R.string.image_descr_simplex_logo), - modifier = Modifier.padding(vertical = 15.dp) - ) - Text( - stringResource(R.string.you_control_your_chat), - style = MaterialTheme.typography.h4, - color = MaterialTheme.colors.onBackground - ) - Text( - stringResource(R.string.the_messaging_and_app_platform_protecting_your_privacy_and_security), - style = MaterialTheme.typography.body1, - color = MaterialTheme.colors.onBackground - ) - Spacer(Modifier.height(8.dp)) - Text( - stringResource(R.string.we_do_not_store_contacts_or_messages_on_servers), - style = MaterialTheme.typography.body1, - color = MaterialTheme.colors.onBackground - ) - Spacer(Modifier.height(24.dp)) - CreateProfilePanel(chatModel) - } - if (savedKeyboardState != keyboardState) { - LaunchedEffect(keyboardState) { - scope.launch { - savedKeyboardState = keyboardState - scrollState.animateScrollTo(scrollState.maxValue) - } - } - } - } - } -} - fun isValidDisplayName(name: String) : Boolean { return (name.firstOrNull { it.isWhitespace() }) == null @@ -88,91 +38,106 @@ fun isValidDisplayName(name: String) : Boolean { @Composable fun CreateProfilePanel(chatModel: ChatModel) { - var displayName by remember { mutableStateOf("") } - var fullName by remember { mutableStateOf("") } + val displayName = remember { mutableStateOf("") } + val fullName = remember { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } - Column( - modifier=Modifier.fillMaxSize() - ) { - Text( - stringResource(R.string.create_profile), - style = MaterialTheme.typography.h4, - color = MaterialTheme.colors.onBackground, - modifier = Modifier.padding(vertical = 5.dp) - ) - Text( - stringResource(R.string.your_profile_is_stored_on_your_decide_and_shared_only_with_your_contacts), - style = MaterialTheme.typography.body1, - color = MaterialTheme.colors.onBackground - ) - Spacer(Modifier.height(10.dp)) - Text( - stringResource(R.string.display_name), - style = MaterialTheme.typography.h6, - color = MaterialTheme.colors.onBackground, - modifier = Modifier.padding(bottom = 3.dp) - ) - BasicTextField( - value = displayName, - onValueChange = { displayName = it }, - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colors.secondary) - .height(40.dp) - .clip(RoundedCornerShape(5.dp)) - .padding(8.dp) - .navigationBarsWithImePadding(), - textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground), - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrect = false - ), - singleLine = true - ) - val errorText = if(!isValidDisplayName(displayName)) stringResource(R.string.display_name_cannot_contain_whitespace) else "" + Surface(Modifier.background(MaterialTheme.colors.onBackground)) { + Column( + modifier = Modifier.fillMaxSize() + ) { + Text( + stringResource(R.string.create_profile), + style = MaterialTheme.typography.h4, + modifier = Modifier.padding(vertical = 5.dp) + ) + ReadableText(R.string.your_profile_is_stored_on_your_device) + ReadableText(R.string.profile_is_only_shared_with_your_contacts) + Spacer(Modifier.height(10.dp)) + Text( + stringResource(R.string.display_name), + style = MaterialTheme.typography.h6, + modifier = Modifier.padding(bottom = 3.dp) + ) + ProfileNameField(displayName, focusRequester) + val errorText = if (!isValidDisplayName(displayName.value)) stringResource(R.string.display_name_cannot_contain_whitespace) else "" + Text( + errorText, + fontSize = 15.sp, + color = MaterialTheme.colors.error + ) + Spacer(Modifier.height(3.dp)) + Text( + stringResource(R.string.full_name_optional__prompt), + style = MaterialTheme.typography.h6, + modifier = Modifier.padding(bottom = 5.dp) + ) + ProfileNameField(fullName) + Spacer(Modifier.fillMaxHeight().weight(1f)) + Row { + SimpleButton( + text = stringResource(R.string.about_simplex), + icon = Icons.Outlined.ArrowBackIosNew + ) { chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo } - Text( - errorText, - fontSize = 15.sp, - color = MaterialTheme.colors.error - ) + Spacer(Modifier.fillMaxWidth().weight(1f)) - Spacer(Modifier.height(3.dp)) - Text( - stringResource(R.string.full_name_optional__prompt), - style = MaterialTheme.typography.h6, - color = MaterialTheme.colors.onBackground, - modifier = Modifier.padding(bottom = 5.dp) - ) - BasicTextField( - value = fullName, - onValueChange = { fullName = it }, - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colors.secondary) - .height(40.dp) - .clip(RoundedCornerShape(3.dp)) - .padding(8.dp) - .navigationBarsWithImePadding(), - textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground), - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrect = false - ), - singleLine = true - ) - Spacer(Modifier.height(20.dp)) - Button(onClick = { - withApi { - val user = chatModel.controller.apiCreateActiveUser( - Profile(displayName, fullName, null) - ) - chatModel.controller.startChat(user) - SimplexService.start(chatModel.controller.appContext) - chatModel.controller.showBackgroundServiceNotice() + val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value) + val createModifier: Modifier + val createColor: Color + if (enabled) { + createModifier = Modifier.padding(8.dp).clickable { createProfile(chatModel, displayName.value, fullName.value) } + createColor = MaterialTheme.colors.primary + } else { + createModifier = Modifier.padding(8.dp) + createColor = HighOrLowlight + } + Surface(shape = RoundedCornerShape(20.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = createModifier) { + Text(stringResource(R.string.create_profile_button), style = MaterialTheme.typography.caption, color = createColor) + Icon(Icons.Outlined.ArrowForwardIos, stringResource(R.string.create_profile_button), tint = createColor) + } + } } - }, - enabled = (displayName.isNotEmpty() && isValidDisplayName(displayName)) - ) { Text(stringResource(R.string.create_profile_button)) } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + } } } + +fun createProfile(chatModel: ChatModel, displayName: String, fullName: String) { + withApi { + val user = chatModel.controller.apiCreateActiveUser( + Profile(displayName, fullName, null) + ) + chatModel.controller.startChat(user) + SimplexService.start(chatModel.controller.appContext) + // TODO show it later? + chatModel.controller.showBackgroundServiceNotice() + chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete + } +} + +@Composable +fun ProfileNameField(name: MutableState, focusRequester: FocusRequester? = null) { + val modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colors.secondary) + .height(40.dp) + .clip(RoundedCornerShape(5.dp)) + .padding(8.dp) + .navigationBarsWithImePadding() + BasicTextField( + value = name.value, + onValueChange = { name.value = it }, + modifier = if (focusRequester == null) modifier else modifier.focusRequester(focusRequester), + textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrect = false + ), + singleLine = true + ) +} \ No newline at end of file diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt index cdf9ebf45c..6484c1d42a 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt @@ -1,7 +1,7 @@ package chat.simplex.app.views.call import chat.simplex.app.R -import chat.simplex.app.model.* +import chat.simplex.app.model.Contact import chat.simplex.app.views.helpers.generalGetString import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeFileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeFileView.kt index 32eeba9e32..48917ec05e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeFileView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeFileView.kt @@ -1,10 +1,11 @@ -import androidx.compose.foundation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.InsertDriveFile import androidx.compose.material.icons.outlined.Close -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index 8402d4e7fa..d187cf0344 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -7,7 +7,8 @@ import android.net.Uri import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.* +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.Edit @@ -24,7 +25,8 @@ import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.views.chat.item.* import chat.simplex.app.views.helpers.* -import kotlinx.coroutines.* +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking sealed class ComposePreview { object NoPreview: ComposePreview() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt index 496c8323b7..2b969b1ee5 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt @@ -6,7 +6,7 @@ import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.outlined.Close -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index 08f2672a15..b4d5aa24f4 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -23,8 +23,9 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import chat.simplex.app.R -import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.* +import chat.simplex.app.model.ChatItem +import chat.simplex.app.ui.theme.HighOrLowlight +import chat.simplex.app.ui.theme.SimpleXTheme @Composable fun SendMsgView( diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt index e3f342deac..e98f6b2b90 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt @@ -1,12 +1,14 @@ import android.graphics.Bitmap -import androidx.compose.foundation.* +import androidx.compose.foundation.Image +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Icon import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.outlined.MoreHoriz -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -16,7 +18,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import chat.simplex.app.R -import chat.simplex.app.model.* +import chat.simplex.app.model.CIFile +import chat.simplex.app.model.CIFileStatus import chat.simplex.app.views.helpers.* @Composable diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt index e14e031538..bf59756968 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt @@ -18,7 +18,8 @@ import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.* -import androidx.compose.ui.unit.* +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt index 3f981e1de7..e19473d364 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt @@ -1,7 +1,8 @@ import android.graphics.Bitmap import androidx.activity.compose.BackHandler import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt index df2a1ee8ef..c207f3de46 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt @@ -21,6 +21,7 @@ import chat.simplex.app.ui.theme.ToolbarDark import chat.simplex.app.ui.theme.ToolbarLight import chat.simplex.app.views.helpers.ModalManager import chat.simplex.app.views.newchat.NewChatSheet +import chat.simplex.app.views.onboarding.MakeConnection import chat.simplex.app.views.usersettings.SettingsView import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -88,8 +89,7 @@ fun ChatListView(chatModel: ChatModel) { if (chatModel.chats.isNotEmpty()) { ChatList(chatModel) } else { - val user = chatModel.currentUser.value - Help(scaffoldCtrl, displayName = user?.profile?.displayName) + MakeConnection(chatModel) } } if (scaffoldCtrl.expanded.value) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactConnectionView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactConnectionView.kt index b5636a3542..8b709bd6af 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactConnectionView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactConnectionView.kt @@ -9,14 +9,12 @@ import androidx.compose.material.icons.outlined.Link import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import chat.simplex.app.R -import chat.simplex.app.model.* +import chat.simplex.app.model.PendingContactConnection +import chat.simplex.app.model.getTimestampText import chat.simplex.app.ui.theme.HighOrLowlight -import chat.simplex.app.views.helpers.ChatInfoImage import chat.simplex.app.views.helpers.ProfileImage @Composable diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt index 1140273b0a..e6757f8351 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt @@ -10,7 +10,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import chat.simplex.app.R -import chat.simplex.app.model.* +import chat.simplex.app.model.ChatInfo +import chat.simplex.app.model.getTimestampText import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.views.helpers.ChatInfoImage diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt index 93ca20c5dd..b19f17a2be 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt @@ -19,7 +19,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import chat.simplex.app.R -import chat.simplex.app.model.Chat import chat.simplex.app.model.ChatInfo import chat.simplex.app.ui.theme.SimpleXTheme diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt index b02f0c535f..1045605cd1 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt @@ -11,7 +11,8 @@ import androidx.compose.runtime.Composable import androidx.core.content.ContextCompat import chat.simplex.app.R import chat.simplex.app.model.CIFile -import java.io.* +import java.io.BufferedOutputStream +import java.io.File fun shareText(cxt: Context, text: String) { val sendIntent: Intent = Intent().apply { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt index 23906df768..a4bc591552 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt @@ -19,19 +19,24 @@ import androidx.compose.ui.unit.dp fun SimpleButton(text: String, icon: ImageVector, color: Color = MaterialTheme.colors.primary, click: () -> Unit) { + SimpleButtonFrame(click) { + Icon( + icon, text, tint = color, + modifier = Modifier.padding(end = 8.dp) + ) + Text(text, style = MaterialTheme.typography.caption, color = color) + } +} + +@Composable +fun SimpleButtonFrame(click: () -> Unit, content: @Composable () -> Unit) { Surface(shape = RoundedCornerShape(20.dp)) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .clickable { click() } .padding(8.dp) - ) { - Icon( - icon, text, tint = color, - modifier = Modifier.padding(end = 8.dp) - ) - Text(text, style = MaterialTheme.typography.caption, color = color) - } + ) { content() } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/HowItWorks.kt b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/HowItWorks.kt new file mode 100644 index 0000000000..07ee6d892b --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/HowItWorks.kt @@ -0,0 +1,70 @@ +package chat.simplex.app.views.onboarding + +import android.content.res.Configuration +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.R +import chat.simplex.app.model.User +import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.helpers.ModalManager +import chat.simplex.app.views.helpers.annotatedStringResource + +@Composable +fun HowItWorks(user: User?, onboardingStage: MutableState? = null) { + Column(Modifier.fillMaxHeight(), horizontalAlignment = Alignment.Start) { + Text(stringResource(R.string.how_simplex_works), style = MaterialTheme.typography.h1, modifier = Modifier.padding(bottom = 8.dp)) + ReadableText(R.string.many_people_asked_how_can_it_deliver) + ReadableText(R.string.to_protect_privacy_simplex_has_ids_for_queues) + ReadableText(R.string.you_control_servers_to_receive_your_contacts_to_send) + ReadableText(R.string.only_client_devices_store_contacts_groups_e2e_encrypted_messages) + if (onboardingStage == null) { + val uriHandler = LocalUriHandler.current + Text( + annotatedStringResource(R.string.read_more_in_github_with_link), + modifier = Modifier.padding(bottom = 12.dp).clickable { uriHandler.openUri("https://github.com/simplex-chat/simplex-chat#readme") }, + lineHeight = 22.sp + ) + } else { + ReadableText(R.string.read_more_in_github) + } + + Spacer(Modifier.fillMaxHeight().weight(1f)) + + if (onboardingStage != null) { + Box(Modifier.fillMaxWidth().padding(bottom = 16.dp), contentAlignment = Alignment.Center) { + OnboardingActionButton(user, onboardingStage, onclick = { ModalManager.shared.closeModal() }) + } + Spacer(Modifier.fillMaxHeight().weight(1f)) + } + } +} + +@Composable +fun ReadableText(@StringRes stringResId: Int) { + Text(annotatedStringResource(stringResId), modifier = Modifier.padding(bottom = 12.dp), lineHeight = 22.sp) +} + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "Dark Mode" +) +@Composable +fun PreviewHowItWorks() { + SimpleXTheme { + HowItWorks(user = null) + } +} \ No newline at end of file diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/MakeConnection.kt b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/MakeConnection.kt new file mode 100644 index 0000000000..0b6290eef4 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/MakeConnection.kt @@ -0,0 +1,172 @@ +package chat.simplex.app.views.onboarding + +import android.Manifest +import android.content.res.Configuration +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import chat.simplex.app.R +import chat.simplex.app.model.ChatModel +import chat.simplex.app.model.User +import chat.simplex.app.ui.theme.SimpleButton +import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.newchat.* +import chat.simplex.app.views.usersettings.simplexTeamUri +import com.google.accompanist.permissions.rememberPermissionState + +@Composable +fun MakeConnection(chatModel: ChatModel) { + val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) + MakeConnectionLayout( + chatModel.currentUser.value, + createLink = { + withApi { + // show spinner + chatModel.connReqInvitation = chatModel.controller.apiAddContact() + // hide spinner + if (chatModel.connReqInvitation != null) { + ModalManager.shared.showModal { AddContactView(chatModel) } + } + } + }, + pasteLink = { + ModalManager.shared.showCustomModal { close -> PasteToConnectView(chatModel, close) } + }, + scanCode = { + ModalManager.shared.showCustomModal { close -> ScanToConnectView(chatModel, close) } + cameraPermissionState.launchPermissionRequest() + }, + about = { + chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo + } + ) +} + +@Composable +fun MakeConnectionLayout( + user: User?, + createLink: () -> Unit, + pasteLink: () -> Unit, + scanCode: () -> Unit, + about: () -> Unit +) { + Surface { + Column( + Modifier + .fillMaxSize() + .background(color = MaterialTheme.colors.background) + .padding(20.dp) + ) { + Text( + if (user == null) stringResource(R.string.make_private_connection) + else String.format(stringResource(R.string.personal_welcome), user.profile.displayName), + style = MaterialTheme.typography.h1, + modifier = Modifier.padding(bottom = 8.dp) + ) + Text( + annotatedStringResource(R.string.to_make_your_first_private_connection_choose), + modifier = Modifier.padding(bottom = 16.dp) + ) + ActionRow( + Icons.Outlined.QrCode, + R.string.create_1_time_link_qr_code, + R.string.it_s_secure_to_share__only_one_contact_can_use_it, + createLink + ) + ActionRow( + Icons.Outlined.Link, + R.string.paste_the_link_you_received, + R.string.or_open_the_link_in_the_browser_and_tap_open_in_mobile, + pasteLink + ) + ActionRow( + Icons.Outlined.QrCodeScanner, + R.string.scan_contact_s_qr_code, + R.string.in_person_or_via_a_video_call__the_most_secure_way_to_connect, + scanCode + ) + Box(Modifier.fillMaxWidth().padding(bottom = 16.dp), contentAlignment = Alignment.Center) { + Text(stringResource(R.string.or)) + } + val uriHandler = LocalUriHandler.current + ActionRow( + Icons.Outlined.Tag, + R.string.connect_with_the_developers, + R.string.to_ask_any_questions_and_to_receive_simplex_chat_updates, + { uriHandler.openUri(simplexTeamUri) } + ) + Spacer(Modifier.fillMaxHeight().weight(1f)) + SimpleButton( + text = stringResource(R.string.about_simplex), + icon = Icons.Outlined.ArrowBackIosNew, + click = about + ) + } + } +} + +@Composable +private fun ActionRow(icon: ImageVector, @StringRes titleId: Int, @StringRes textId: Int, action: () -> Unit) { + Row( + Modifier + .clickable { action() } + .padding(bottom = 16.dp) + ) { + Icon(icon, stringResource(titleId), tint = MaterialTheme.colors.primary, + modifier = Modifier.padding(end = 10.dp).size(40.dp)) + Column { + Text(stringResource(titleId), color = MaterialTheme.colors.primary) + Text(annotatedStringResource(textId)) + } + } +// Button(action: action, label: { +// HStack(alignment: .top, spacing: 20) { +// Image(systemName: icon) +// .resizable() +// .scaledToFit() +// .frame(width: 30, height: 30) +// .padding(.leading, 4) +// .padding(.top, 6) +// VStack(alignment: .leading) { +// Group { +// Text(title).font(.headline) +// Text(text).foregroundColor(.primary) +// } +// .multilineTextAlignment(.leading) +// } +// } +// }) +// .padding(.bottom) +} + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "Dark Mode" +) +@Composable +fun PreviewMakeConnection() { + SimpleXTheme { + MakeConnectionLayout( + user = User.sampleData, + createLink = {}, + pasteLink = {}, + scanCode = {}, + about = {} + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/OnboardingView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/OnboardingView.kt new file mode 100644 index 0000000000..08435f75e7 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/OnboardingView.kt @@ -0,0 +1,47 @@ +package chat.simplex.app.views.onboarding + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import chat.simplex.app.model.ChatModel +import chat.simplex.app.views.CreateProfilePanel +import chat.simplex.app.views.helpers.getKeyboardState +import com.google.accompanist.insets.ProvideWindowInsets +import kotlinx.coroutines.launch + +enum class OnboardingStage { + Step1_SimpleXInfo, + Step2_CreateProfile, + OnboardingComplete +} + +@Composable +fun CreateProfile(chatModel: ChatModel) { + val scope = rememberCoroutineScope() + val scrollState = rememberScrollState() + val keyboardState by getKeyboardState() + var savedKeyboardState by remember { mutableStateOf(keyboardState) } + + ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { + Box( + modifier = Modifier + .fillMaxSize() + .background(color = MaterialTheme.colors.background) + .padding(20.dp) + ) { + CreateProfilePanel(chatModel) + if (savedKeyboardState != keyboardState) { + LaunchedEffect(keyboardState) { + scope.launch { + savedKeyboardState = keyboardState + scrollState.animateScrollTo(scrollState.maxValue) + } + } + } + } + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/SimpleXInfo.kt b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/SimpleXInfo.kt new file mode 100644 index 0000000000..3825954bbc --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/SimpleXInfo.kt @@ -0,0 +1,139 @@ +package chat.simplex.app.views.onboarding + +import android.content.res.Configuration +import androidx.annotation.StringRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ArrowForwardIos +import androidx.compose.material.icons.outlined.Info +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.R +import chat.simplex.app.model.ChatModel +import chat.simplex.app.model.User +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.helpers.ModalManager + +@Composable +fun SimpleXInfo(chatModel: ChatModel, onboarding: Boolean = true) { + SimpleXInfoLayout( + user = chatModel.currentUser.value, + onboardingStage = if (onboarding) chatModel.onboardingStage else null, + showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } }, + ) +} + +@Composable +fun SimpleXInfoLayout( + user: User?, + onboardingStage: MutableState?, + showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), +) { + Column(Modifier.fillMaxHeight(), horizontalAlignment = Alignment.Start) { + Image( + painter = painterResource(R.drawable.logo), + contentDescription = stringResource(R.string.image_descr_simplex_logo), + modifier = Modifier + .padding(vertical = 20.dp) + .fillMaxWidth(0.80f) + ) + + Text(stringResource(R.string.next_generation_of_private_messaging), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = 16.dp)) + + InfoRow("🎭", R.string.privacy_redefined, R.string.first_platform_without_user_ids) + InfoRow("📭", R.string.immune_to_spam_and_abuse, R.string.people_can_connect_only_via_links_you_share) + InfoRow("🤝", R.string.decentralized, R.string.opensource_protocol_and_code_anybody_can_run_servers) + + Spacer( + Modifier + .fillMaxHeight() + .weight(1f)) + + if (onboardingStage != null) { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + OnboardingActionButton(user, onboardingStage) + } + Spacer( + Modifier + .fillMaxHeight() + .weight(1f)) + } + + Box( + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), contentAlignment = Alignment.Center) { + SimpleButton(text = stringResource(R.string.how_it_works), icon = Icons.Outlined.Info, + click = showModal { HowItWorks(user, onboardingStage) }) + } + } +} + +@Composable +private fun InfoRow(emoji: String, @StringRes titleId: Int, @StringRes textId: Int) { + Row(Modifier.padding(bottom = 20.dp), verticalAlignment = Alignment.Top) { + Text(emoji, fontSize = 36.sp, modifier = Modifier + .width(60.dp) + .padding(end = 16.dp)) + Column(horizontalAlignment = Alignment.Start) { + Text(stringResource(titleId), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.h3, lineHeight = 24.sp) + Text(stringResource(textId), lineHeight = 24.sp, style = MaterialTheme.typography.caption) + } + } +} + + +@Composable +fun OnboardingActionButton(user: User?, onboardingStage: MutableState, onclick: (() -> Unit)? = null) { + if (user == null) { + ActionButton(R.string.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, onboardingStage, onclick) + } else { + ActionButton(R.string.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, onboardingStage, onclick) + } +} + +@Composable +private fun ActionButton( + @StringRes labelId: Int, + onboarding: OnboardingStage?, + onboardingStage: MutableState, + onclick: (() -> Unit)? +) { + SimpleButtonFrame(click = { + onclick?.invoke() + onboardingStage.value = onboarding + }) { + Text(stringResource(labelId), style = MaterialTheme.typography.h2, color = MaterialTheme.colors.primary) + Icon( + Icons.Outlined.ArrowForwardIos, "next stage", tint = MaterialTheme.colors.primary, + modifier = Modifier.padding(end = 8.dp) + ) + } +} + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "Dark Mode" +) +@Composable +fun PreviewSimpleXInfo() { + SimpleXTheme { + SimpleXInfoLayout( + user = null, + onboardingStage = null, + showModal = {{}} + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt index 1c4aac68f6..64f7610bc9 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt @@ -26,6 +26,7 @@ import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.TerminalView import chat.simplex.app.views.call.VideoCallView import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.onboarding.SimpleXInfo @Composable fun SettingsView(chatModel: ChatModel) { @@ -111,6 +112,15 @@ fun SettingsLayout( Text(stringResource(R.string.how_to_use_simplex_chat)) } Divider(Modifier.padding(horizontal = 8.dp)) + SettingsSectionView(showModal { SimpleXInfo(it, onboarding = false) }) { + Icon( + Icons.Outlined.Info, + contentDescription = stringResource(R.string.icon_descr_help), + ) + Spacer(Modifier.padding(horizontal = 4.dp)) + Text(stringResource(R.string.about_simplex_chat)) + } + Divider(Modifier.padding(horizontal = 8.dp)) SettingsSectionView(showModal { MarkdownHelpView() }) { Icon( Icons.Outlined.TextFormat, diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index 7ab856193a..9496535853 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -214,7 +214,8 @@ Настройки Ваш SimpleX адрес - Как использовать SimpleX Chat + Информация о SimpleX Chat + Как использовать Форматирование сообщений Форматирование сообщений Соединиться с разработчиками @@ -256,11 +257,13 @@ Платформа для сообщений и приложений, которая защищает вашу личную информацию и безопасность. Мы не храним ваши контакты и сообщения (после доставки) на серверах. Создать профиль - Ваш профиль хранится на вашем устройстве и отправляется только вашим контактам. + Ваш профиль, контакты и доставленные сообщения хранятся на вашем устройстве. + Профиль отправляется только вашим контактам. Имя профиля не может содержать пробелы. Имя профиля Полное имя (не обязательно) Создать + О SimpleX Как форматировать @@ -293,4 +296,37 @@ получен ответ… соединяется… соединено + + + Новое поколение приватных сообщений + Более конфиденциальный + Первая в мире платформа без идентификаторов пользователей. + Защищен от спама + С вами можно соединиться только через созданные вами ссылки. + Децентрализованный + Открытый протокол и код - кто угодно может запустить сервер. + Создать профиль + Добавьте контакт + Как это работает + + + Как SimpleX работает + Много пользователей спросили: как SimpleX доставляет сообщения без идентификаторов пользователей? + Чтобы защитить вашу конфиденциальность, вместо ID пользователей, которые есть в других платформах, SimpleX использует ID для очередей сообщений, разные для каждого контакта. + Вы определяете через какие серверы вы получаете сообщения, ваши контакты - серверы, которые вы используете для отправки. + Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются с двухуровневым end-to-end шифрованием. + Узнайте больше из нашего GitHub репозитория. + Узнайте больше из нашего GitHub репозитория. + + + Чтобы добавить ваш первый контакт, выберите одно из: + Создать ссылку / QR код + Ей безопасно поделиться - только один контакт может использовать её. + Вставьте полученную ссылку + Или откройте ссылку в браузере и нажмите Open in mobile. + Сосканировать QR код контакта + При встрече или в видеозвонке – самый безопасный способ установить соединение + или + Соединиться с разработчиками + Чтобы задать вопросы и получать уведомления о SimpleX Chat. diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 6e66cb3bb8..5dce4ad6ea 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -219,7 +219,8 @@ Your settings Your SimpleX contact address - How to use SimpleX Chat + About SimpleX Chat + How to use it Markdown help Markdown in messages Connect to the developers @@ -261,11 +262,13 @@ The messaging and application platform protecting your privacy and security. We don\'t store any of your contacts or messages (once delivered) on the servers. Create profile - Your profile is stored on your device and shared only with your contacts. + Your profile, contacts and delivered messages are stored on your device. + The profile is only shared with your contacts. Display name cannot contain whitespace. Display Name - Full Name (Optional) + Full Name (optional) Create + About SimpleX How to use markdown @@ -294,4 +297,37 @@ received answer… connecting… connected + + + The next generation of private messaging + Privacy redefined + The 1st platform without any user identifiers – private by design. + Immune to spam and abuse + People can connect to you only via the links you share. + Decentralized + Open-source protocol and code – anybody can run the servers. + Create your profile + Make a private connection + How it works + + + How SimpleX works + Many people asked: if SimpleX has no user identifiers, how can it deliver messages? + To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts. + You control through which server(s) to receive the messages, your contacts – the servers you use to message them. + Only client devices store user profiles, contacts, groups, and messages sent with 2-layer end-to-end encryption. + Read more in our GitHub repository. + Read more in our GitHub repository. + + + To make your first private connection, choose one of the following: + Create 1-time link / QR code + It\'s secure to share - only one contact can use it. + Paste the link you received + Or open the link in the browser and tap Open in mobile. + Scan contact\'s QR code + In person or via a video call – the most secure way to connect. + or + Connect with the developers + To ask any questions and to receive SimpleX Chat updates. diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index d924c6dbea..9fd55a97c1 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -782,7 +782,7 @@ The 1st platform without any user identifiers – private by design. - Первая в мире платформа без идентификации пользователей. + Первая в мире платформа без идентификаторов пользователей. No comment provided by engineer. diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 40439a9b77..0f86f73a9e 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -552,7 +552,7 @@ "Thank you for installing SimpleX Chat!" = "Спасибо, что установили SimpleX Chat!"; /* No comment provided by engineer. */ -"The 1st platform without any user identifiers – private by design." = "Первая в мире платформа без идентификации пользователей."; +"The 1st platform without any user identifiers – private by design." = "Первая в мире платформа без идентификаторов пользователей."; /* No comment provided by engineer. */ "The app can notify you when you receive messages or contact requests - please open settings to enable." = "Приложение может посылать вам уведомления о сообщениях и запросах на соединение - уведомления можно включить в Настройках."; From c0528baba79af1863085b4e2ac77d224e0d4f627 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 10 May 2022 08:04:18 +0100 Subject: [PATCH 84/95] ios: show spinners when connecting contacts (#628) * ios: show spinners when connecting contacts * move status to the corner --- apps/ios/Shared/Model/ChatModel.swift | 3 +- .../Views/ChatList/ChatPreviewView.swift | 59 +++++++++++-------- 2 files changed, 35 insertions(+), 27 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 63b9f04ff8..578552a992 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -276,10 +276,11 @@ final class Chat: ObservableObject, Identifiable { self.chatStats = cData.chatStats } - init(chatInfo: ChatInfo, chatItems: [ChatItem] = [], chatStats: ChatStats = ChatStats()) { + init(chatInfo: ChatInfo, chatItems: [ChatItem] = [], chatStats: ChatStats = ChatStats(), serverInfo: ServerInfo = ServerInfo(networkStatus: .unknown)) { self.chatInfo = chatInfo self.chatItems = chatItems self.chatStats = chatStats + self.serverInfo = serverInfo } var id: ChatId { get { chatInfo.id } } diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index d039f6eeb3..fc61520166 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -17,15 +17,9 @@ struct ChatPreviewView: View { let cItem = chat.chatItems.last let unread = chat.chatStats.unreadCount return HStack(spacing: 8) { - ZStack(alignment: .bottomLeading) { - ChatInfoImage(chat: chat) - .frame(width: 63, height: 63) - if case .direct = chat.chatInfo { - chatStatusImage() - .padding([.bottom, .leading], 1) - } - } - .padding(.leading, 4) + ChatInfoImage(chat: chat) + .frame(width: 63, height: 63) + .padding(.leading, 4) VStack(spacing: 0) { HStack(alignment: .top) { @@ -45,10 +39,10 @@ struct ChatPreviewView: View { .padding(.top, 4) .padding(.horizontal, 8) - if let cItem = cItem { - ZStack(alignment: .topTrailing) { + ZStack(alignment: .topTrailing) { + if let cItem = cItem { (itemStatusMark(cItem) + messageText(cItem.text, cItem.formattedText, cItem.memberDisplayName, preview: true)) - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) + .frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) .padding(.leading, 8) .padding(.trailing, 36) .padding(.bottom, 4) @@ -61,15 +55,21 @@ struct ChatPreviewView: View { .background(Color.accentColor) .cornerRadius(10) } + } else if case let .direct(contact) = chat.chatInfo, !contact.ready { + Text("Connecting...") + .frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) + .padding([.leading, .trailing], 8) + .padding(.bottom, 4) + } + if case .direct = chat.chatInfo { + chatStatusImage() + .padding(.top, 24) + .padding(.bottom, 4) + .frame(maxWidth: .infinity, alignment: .trailing) } - .padding(.trailing, 8) - } - else if case let .direct(contact) = chat.chatInfo, !contact.ready { - Text("Connecting...") - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) - .padding([.leading, .trailing], 8) - .padding(.bottom, 4) } + + .padding(.trailing, 8) } } } @@ -92,17 +92,13 @@ struct ChatPreviewView: View { switch chat.serverInfo.networkStatus { case .connected: EmptyView() case .error: - Image(systemName: "circle.fill") + Image(systemName: "exclamationmark.circle") .resizable() .scaledToFit() - .frame(maxHeight: 5) + .frame(width: 17, height: 17) .foregroundColor(.secondary) default: - Image(systemName: "ellipsis") - .resizable() - .scaledToFit() - .frame(maxWidth: 10) - .foregroundColor(.secondary) + ProgressView() } } } @@ -118,6 +114,17 @@ struct ChatPreviewView_Previews: PreviewProvider { chatInfo: ChatInfo.sampleData.direct, chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)] )) + ChatPreviewView(chat: Chat( + chatInfo: ChatInfo.sampleData.direct, + chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)], + chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0) + )) + ChatPreviewView(chat: Chat( + chatInfo: ChatInfo.sampleData.direct, + chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)], + chatStats: ChatStats(unreadCount: 3, minUnreadItemId: 0), + serverInfo: Chat.ServerInfo(networkStatus: .error("status")) + )) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.group, chatItems: [ChatItem.getSample(1, .directSnd, .now, "Lorem ipsum dolor sit amet, d. consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")], From 05283d58b0c1eb853658f92edf8ab064bcb8f7b8 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 10 May 2022 08:21:09 +0100 Subject: [PATCH 85/95] ios: version 2.0 (44) --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 2618f76c7a..64383f1599 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -913,7 +913,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 43; + CURRENT_PROJECT_VERSION = 44; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -955,7 +955,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 43; + CURRENT_PROJECT_VERSION = 44; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1036,7 +1036,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 43; + CURRENT_PROJECT_VERSION = 44; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1076,7 +1076,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 43; + CURRENT_PROJECT_VERSION = 44; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; From 3cd8f8f7dd325bda319f2d6719b6595be409615b Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 10 May 2022 08:33:53 +0100 Subject: [PATCH 86/95] android: version 2.0 (27) --- apps/android/app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index 21395df3e2..35029b11b4 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "chat.simplex.app" minSdk 29 targetSdk 32 - versionCode 26 - versionName "1.6" + versionCode 27 + versionName "2.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" ndk { From 01467769bfcb46bab3a53cfe5d7201eaf724b324 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Tue, 10 May 2022 12:15:46 +0400 Subject: [PATCH 87/95] mobile: remove icons on received files (#629) --- .../main/java/chat/simplex/app/views/chat/item/CIFileView.kt | 2 +- apps/ios/Shared/Views/Helpers/CIFileView.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt index 6553a2d7ff..718d48f1f4 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt @@ -132,7 +132,7 @@ fun CIFileView( fileIcon(innerIcon = Icons.Outlined.PriorityHigh, color = WarningOrange) CIFileStatus.RcvAccepted -> fileIcon(innerIcon = Icons.Outlined.MoreHoriz) CIFileStatus.RcvTransfer -> progressIndicator() - CIFileStatus.RcvComplete -> fileIcon(innerIcon = Icons.Outlined.ArrowDownward) + CIFileStatus.RcvComplete -> fileIcon() CIFileStatus.RcvCancelled -> fileIcon(innerIcon = Icons.Outlined.Close) } } else { diff --git a/apps/ios/Shared/Views/Helpers/CIFileView.swift b/apps/ios/Shared/Views/Helpers/CIFileView.swift index f6ced00a1d..c192b0644f 100644 --- a/apps/ios/Shared/Views/Helpers/CIFileView.swift +++ b/apps/ios/Shared/Views/Helpers/CIFileView.swift @@ -102,7 +102,7 @@ struct CIFileView: View { } case .rcvAccepted: fileIcon("doc.fill", innerIcon: "ellipsis", innerIconSize: 12) case .rcvTransfer: ProgressView().frame(width: 30, height: 30) - case .rcvComplete: fileIcon("arrow.down.doc.fill", innerIconSize: 10) + case .rcvComplete: fileIcon("doc.fill") case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) } } else { From 1027bf923fb581f892cd76470ce541d2628fc9d1 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 10 May 2022 09:57:37 +0100 Subject: [PATCH 88/95] mobile: update connect action icons --- .../app/src/main/java/chat/simplex/app/views/WelcomeView.kt | 4 +++- .../java/chat/simplex/app/views/newchat/NewChatSheet.kt | 4 ++-- .../chat/simplex/app/views/onboarding/MakeConnection.kt | 6 +++--- apps/ios/Shared/Views/NewChat/PasteToConnectView.swift | 2 +- apps/ios/Shared/Views/Onboarding/MakeConnection.swift | 4 ++-- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt index 9bbb445e2e..d26f142f35 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp @@ -138,6 +139,7 @@ fun ProfileNameField(name: MutableState, focusRequester: FocusRequester? capitalization = KeyboardCapitalization.None, autoCorrect = false ), - singleLine = true + singleLine = true, + cursorBrush = SolidColor(HighOrLowlight) ) } \ No newline at end of file diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt index 514658944e..d7687a3a57 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt @@ -75,7 +75,7 @@ fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit, pasteLink: ActionButton( stringResource(R.string.create_one_time_link), stringResource(R.string.to_share_with_your_contact), - Icons.Outlined.PersonAdd, + Icons.Outlined.AddLink, click = addContact ) } @@ -86,7 +86,7 @@ fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit, pasteLink: ActionButton( stringResource(R.string.paste_received_link), stringResource(R.string.paste_received_link_from_clipboard), - Icons.Outlined.Link, + Icons.Outlined.Article, click = pasteLink ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/MakeConnection.kt b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/MakeConnection.kt index 0b6290eef4..d440775cad 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/MakeConnection.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/MakeConnection.kt @@ -81,19 +81,19 @@ fun MakeConnectionLayout( modifier = Modifier.padding(bottom = 16.dp) ) ActionRow( - Icons.Outlined.QrCode, + Icons.Outlined.AddLink, R.string.create_1_time_link_qr_code, R.string.it_s_secure_to_share__only_one_contact_can_use_it, createLink ) ActionRow( - Icons.Outlined.Link, + Icons.Outlined.Article, R.string.paste_the_link_you_received, R.string.or_open_the_link_in_the_browser_and_tap_open_in_mobile, pasteLink ) ActionRow( - Icons.Outlined.QrCodeScanner, + Icons.Outlined.QrCode, R.string.scan_contact_s_qr_code, R.string.in_person_or_via_a_video_call__the_most_secure_way_to_connect, scanCode diff --git a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift index 2d167671bf..eadf249b45 100644 --- a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift +++ b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift @@ -36,7 +36,7 @@ struct PasteToConnectView: View { Button { connectionLink = UIPasteboard.general.string ?? "" } label: { - Label("Paste", systemImage: "doc.on.clipboard") + Label("Paste", systemImage: "doc.plaintext") } } else { Button { diff --git a/apps/ios/Shared/Views/Onboarding/MakeConnection.swift b/apps/ios/Shared/Views/Onboarding/MakeConnection.swift index 4b077a1729..3251b16535 100644 --- a/apps/ios/Shared/Views/Onboarding/MakeConnection.swift +++ b/apps/ios/Shared/Views/Onboarding/MakeConnection.swift @@ -34,13 +34,13 @@ struct MakeConnection: View { .padding(.bottom) actionRow( - icon: "qrcode", + icon: "link.badge.plus", title: "Create 1-time link / QR code", text: "It's secure to share - only one contact can use it." ) { addContactAction() } actionRow( - icon: "link", + icon: "doc.plaintext", title: "Paste the link you received", text: "Or open the link in the browser and tap **Open in mobile**." ) { actionSheet = .pasteLink } From 058c0f58952d3fb26b391dd1a4d261508adfa0cd Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 10 May 2022 11:07:00 +0100 Subject: [PATCH 89/95] mobile: ios v2.0 (45), android (28) --- apps/android/app/build.gradle | 2 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index 35029b11b4..d9ae011896 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -11,7 +11,7 @@ android { applicationId "chat.simplex.app" minSdk 29 targetSdk 32 - versionCode 27 + versionCode 28 versionName "2.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 64383f1599..bccca3627b 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -913,7 +913,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -955,7 +955,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1036,7 +1036,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1076,7 +1076,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; From 3d8ccdaa9fea91c414f1245ebd814769b5bb27bf Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 10 May 2022 15:00:59 +0100 Subject: [PATCH 90/95] android: fix bug in chat list (#630) --- .../java/chat/simplex/app/model/ChatModel.kt | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 1e928074d0..8dfab64c3a 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -128,8 +128,8 @@ class ChatModel(val controller: ChatController) { val res: Boolean if (i >= 0) { chat = chats[i] - val pItem = chat.chatItems.last() - if (pItem.id == cItem.id) { + val pItem = chat.chatItems.lastOrNull() + if (pItem?.id == cItem.id) { chats[i] = chat.copy(chatItems = arrayListOf(cItem)) } res = false @@ -158,8 +158,8 @@ class ChatModel(val controller: ChatController) { val chat: Chat if (i >= 0) { chat = chats[i] - val pItem = chat.chatItems.last() - if (pItem.id == cItem.id) { + val pItem = chat.chatItems.lastOrNull() + if (pItem?.id == cItem.id) { chats[i] = chat.copy(chatItems = arrayListOf(cItem)) } } @@ -180,17 +180,17 @@ class ChatModel(val controller: ChatController) { while (i < chatItems.count()) { val item = chatItems[i] if (item.meta.itemStatus is CIStatus.RcvNew) { - chatItems[i] = item.copy(meta=item.meta.copy(itemStatus = CIStatus.RcvRead())) + chatItems[i] = item.withStatus(CIStatus.RcvRead()) } i += 1 } val chat = chats[chatIdx] + val pItem = chat.chatItems.lastOrNull() chats[chatIdx] = chat.copy( - chatItems = chatItems, + chatItems = if (pItem == null) arrayListOf() else arrayListOf(pItem.withStatus(CIStatus.RcvRead())), chatStats = chat.chatStats.copy(unreadCount = 0, minUnreadItemId = chat.chatItems.last().id + 1) ) } - } // func popChat(_ id: String) { @@ -670,6 +670,8 @@ data class ChatItem ( else -> false } + fun withStatus(status: CIStatus): ChatItem = this.copy(meta = meta.copy(itemStatus = status)) + companion object { fun getSampleData( id: Long = 1, From 89ea57e4b6dcdebaaed82e68c6721ef81de415a7 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 10 May 2022 16:15:11 +0100 Subject: [PATCH 91/95] android: fix picking file from gallery (#631) --- apps/android/app/build.gradle | 2 +- .../simplex/app/views/helpers/GetImageView.kt | 20 +++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index d9ae011896..eb53bce58d 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -11,7 +11,7 @@ android { applicationId "chat.simplex.app" minSdk 29 targetSdk 32 - versionCode 28 + versionCode 30 versionName "2.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt index 14b7adbb80..476300fa1b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt @@ -128,16 +128,16 @@ class CustomTakePicturePreview: ActivityResultContract() { } } -class GetGalleryContent: ActivityResultContracts.GetContent() { - override fun createIntent(context: Context, input: String): Intent { - super.createIntent(context, input) - return Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI) - } -} +//class GetGalleryContent: ActivityResultContracts.GetContent() { +// override fun createIntent(context: Context, input: String): Intent { +// super.createIntent(context, input) +// return Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI) +// } +//} -@Composable -fun rememberGalleryLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher = - rememberLauncherForActivityResult(contract = GetGalleryContent(), cb) +//@Composable +//fun rememberGalleryLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher = +// rememberLauncherForActivityResult(contract = GetGalleryContent(), cb) @Composable fun rememberCameraLauncher(cb: (Bitmap?) -> Unit): ManagedActivityResultLauncher = @@ -160,7 +160,7 @@ fun GetImageBottomSheet( hideBottomSheet: () -> Unit ) { val context = LocalContext.current - val galleryLauncher = rememberGalleryLauncher { uri: Uri? -> + val galleryLauncher = rememberGetContentLauncher { uri: Uri? -> if (uri != null) { val source = ImageDecoder.createSource(context.contentResolver, uri) val bitmap = ImageDecoder.decodeBitmap(source) From 0262ab53bfc4ed5017b2bf998d8ea030e7a093c3 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Wed, 11 May 2022 16:18:28 +0400 Subject: [PATCH 92/95] core: improve file cancel (#627) Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- src/Simplex/Chat.hs | 225 ++++++++++++++++----------------- src/Simplex/Chat/Controller.hs | 1 + src/Simplex/Chat/Messages.hs | 12 ++ src/Simplex/Chat/Protocol.hs | 11 +- src/Simplex/Chat/Store.hs | 155 ++++++++++------------- src/Simplex/Chat/Types.hs | 2 +- src/Simplex/Chat/View.hs | 4 +- tests/ChatTests.hs | 70 +++++++++- tests/ProtocolTests.hs | 3 + 9 files changed, 273 insertions(+), 210 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 234d17298a..cc6cab47b2 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -254,7 +254,7 @@ processChatCommand = \case (fileSize, chSize) <- checkSndFile file let fileName = takeFileName file fileInvitation = FileInvitation {fileName, fileSize, fileConnReq = Nothing} - fileId <- withStore $ \st -> createSndGroupFileTransferV2 st userId gInfo file fileInvitation chSize + fileId <- withStore $ \st -> createSndGroupFileTransfer st userId gInfo file fileInvitation chSize let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just file, fileStatus = CIFSSndStored} pure $ Just (fileInvitation, ciFile) prepareMsg :: Maybe FileInvitation -> GroupMember -> m (MsgContainer, Maybe (CIQuote 'CTGroup)) @@ -331,12 +331,12 @@ processChatCommand = \case (ct@Contact {localDisplayName = c}, CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}, file}) <- withStore $ \st -> (,) <$> getContact st userId chatId <*> getDirectChatItem st userId chatId itemId case (mode, msgDir, itemSharedMsgId) of (CIDMInternal, _, _) -> do - deleteFile user file + deleteCIFile user file toCi <- withStore $ \st -> deleteDirectChatItemInternal st userId ct itemId pure $ CRChatItemDeleted (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) toCi (CIDMBroadcast, SMDSnd, Just itemSharedMId) -> do SndMessage {msgId} <- sendDirectContactMessage ct (XMsgDel itemSharedMId) - deleteFile user file + deleteCIFile user file toCi <- withStore $ \st -> deleteDirectChatItemSndBroadcast st userId ct itemId msgId setActive $ ActiveC c pure $ CRChatItemDeleted (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) toCi @@ -347,12 +347,12 @@ processChatCommand = \case CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}, file} <- withStore $ \st -> getGroupChatItem st user chatId itemId case (mode, msgDir, itemSharedMsgId) of (CIDMInternal, _, _) -> do - deleteFile user file + deleteCIFile user file toCi <- withStore $ \st -> deleteGroupChatItemInternal st user gInfo itemId pure $ CRChatItemDeleted (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) toCi (CIDMBroadcast, SMDSnd, Just itemSharedMId) -> do SndMessage {msgId} <- sendGroupMessage gInfo ms (XMsgDel itemSharedMId) - deleteFile user file + deleteCIFile user file toCi <- withStore $ \st -> deleteGroupChatItemSndBroadcast st user gInfo itemId msgId setActive $ ActiveG gName pure $ CRChatItemDeleted (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) toCi @@ -360,8 +360,8 @@ processChatCommand = \case CTContactRequest -> pure $ chatCmdError "not supported" CTContactConnection -> pure $ chatCmdError "not supported" where - deleteFile :: MsgDirectionI d => User -> Maybe (CIFile d) -> m () - deleteFile user file = + deleteCIFile :: MsgDirectionI d => User -> Maybe (CIFile d) -> m () + deleteCIFile user file = forM_ file $ \CIFile {fileId, filePath, fileStatus} -> do cancelFiles user [(fileId, AFS msgDirection fileStatus)] withFilesFolder $ \filesFolder -> @@ -641,9 +641,9 @@ processChatCommand = \case SendFile chatName f -> withUser $ \user -> do chatRef <- getChatRef user chatName processChatCommand . APISendMessage chatRef $ ComposedMessage (Just f) Nothing (MCFile "") - ReceiveFile fileId filePath_ -> withUser $ \user@User {userId} -> + ReceiveFile fileId filePath_ -> withUser $ \user -> withChatLock . procCmd $ do - ft <- withStore $ \st -> getRcvFileTransfer st userId fileId + ft <- withStore $ \st -> getRcvFileTransfer st user fileId (CRRcvFileAccepted <$> acceptFileReceive user ft filePath_) `catchError` processError ft where processError ft = \case @@ -651,11 +651,29 @@ processChatCommand = \case ChatErrorAgent (SMP SMP.AUTH) -> pure $ CRRcvFileAcceptedSndCancelled ft ChatErrorAgent (CONN DUPLICATE) -> pure $ CRRcvFileAcceptedSndCancelled ft e -> throwError e - CancelFile fileId -> withUser $ \user@User {userId} -> do - ft <- withStore (\st -> getFileTransfer st userId fileId) - withChatLock . procCmd $ cancelFile user fileId ft + CancelFile fileId -> withUser $ \user@User {userId} -> + withChatLock . procCmd $ + withStore (\st -> getFileTransfer st user fileId) >>= \case + FTSnd ftm@FileTransferMeta {cancelled} fts -> do + unless cancelled $ do + cancelSndFile user ftm fts + sharedMsgId <- withStore $ \st -> getSharedMsgIdByFileId st userId fileId + void $ + withStore (\st -> getChatRefByFileId st user fileId) >>= \case + ChatRef CTDirect contactId -> do + contact <- withStore $ \st -> getContact st userId contactId + sendDirectContactMessage contact $ XFileCancel sharedMsgId + ChatRef CTGroup groupId -> do + Group gInfo ms <- withStore $ \st -> getGroup st user groupId + sendGroupMessage gInfo ms $ XFileCancel sharedMsgId + _ -> throwChatError $ CEFileInternal "invalid chat ref for file transfer" + ci <- withStore $ \st -> getChatItemByFileId st user fileId + pure $ CRSndGroupFileCancelled ci ftm fts + FTRcv ftr@RcvFileTransfer {cancelled} -> do + unless cancelled $ cancelRcvFileTransfer user ftr + pure $ CRRcvFileCancelled ftr FileStatus fileId -> - CRFileTransferStatus <$> withUser (\User {userId} -> withStore $ \st -> getFileTransferProgress st userId fileId) + CRFileTransferStatus <$> withUser (\user -> withStore $ \st -> getFileTransferProgress st user fileId) ShowProfile -> withUser $ \User {profile} -> pure $ CRUserProfile profile UpdateProfile displayName fullName -> withUser $ \user@User {profile} -> do let p = (profile :: Profile) {displayName = displayName, fullName = fullName} @@ -741,42 +759,15 @@ processChatCommand = \case removeFile fsFilePath `E.catch` \(_ :: E.SomeException) -> removePathForcibly fsFilePath `E.catch` \(_ :: E.SomeException) -> pure () cancelFiles :: User -> [(Int64, ACIFileStatus)] -> m () - cancelFiles user@User {userId} files = mapM_ maybeCancelFile files - where - maybeCancelFile :: (Int64, ACIFileStatus) -> m () - maybeCancelFile (fileId, status) = case status of - AFS _ CIFSSndStored -> cancelById fileId - AFS _ CIFSSndTransfer -> cancelById fileId - AFS _ CIFSSndCancelled -> pure () - AFS _ CIFSSndComplete -> pure () - AFS _ CIFSRcvInvitation -> cancelById fileId - AFS _ CIFSRcvAccepted -> cancelById fileId - AFS _ CIFSRcvTransfer -> cancelById fileId - AFS _ CIFSRcvCancelled -> pure () - AFS _ CIFSRcvComplete -> pure () - cancelById :: Int64 -> m () - cancelById fileId = do - ft <- withStore (\st -> getFileTransfer st userId fileId) - void $ cancelFile user fileId ft - cancelFile :: User -> Int64 -> FileTransfer -> m ChatResponse - cancelFile user@User {userId} fileId ft = - case ft of - FTSnd ftm fts -> do - cancelFileTransfer CIFSSndCancelled - forM_ fts $ \ft' -> cancelSndFileTransfer ft' - ci <- withStore $ \st -> getChatItemByFileId st user fileId - pure $ CRSndGroupFileCancelled ci ftm fts - FTRcv ftr -> do - cancelFileTransfer CIFSRcvCancelled - cancelRcvFileTransfer ftr - pure $ CRRcvFileCancelled ftr - where - cancelFileTransfer :: MsgDirectionI d => CIFileStatus d -> m () - cancelFileTransfer ciFileStatus = - unless (fileTransferCancelled ft) $ - withStore $ \st -> do - updateFileCancelled st userId fileId - updateCIFileStatus st userId fileId ciFileStatus + cancelFiles user files = forM_ files $ \ (fileId, AFS dir status) -> + unless (ciFileEnded status) $ + case dir of + SMDSnd -> do + (ftm@FileTransferMeta {cancelled}, fts) <- withStore (\st -> getSndFileTransfer st user fileId) + unless cancelled $ cancelSndFile user ftm fts + SMDRcv -> do + ft@RcvFileTransfer {cancelled} <- withStore (\st -> getRcvFileTransfer st user fileId) + unless cancelled $ cancelRcvFileTransfer user ft withCurrentCall :: ContactId -> (UserId -> Contact -> Call -> m (Maybe Call)) -> m ChatResponse withCurrentCall ctId action = withUser $ \User {userId} -> do ct <- withStore $ \st -> getContact st userId ctId @@ -838,37 +829,33 @@ toFSFilePath f = maybe f (<> "/" <> f) <$> (readTVarIO =<< asks filesFolder) acceptFileReceive :: forall m. ChatMonad m => User -> RcvFileTransfer -> Maybe FilePath -> m AChatItem -acceptFileReceive user@User {userId} RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName = fName, fileConnReq}, fileStatus, senderDisplayName, grpMemberId} filePath_ = do - unless (fileStatus == RFSNew) . throwChatError $ CEFileAlreadyReceiving fName +acceptFileReceive user@User {userId} RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName = fName, fileConnReq}, fileStatus, grpMemberId} filePath_ = do + unless (fileStatus == RFSNew) $ case fileStatus of + RFSCancelled _ -> throwChatError $ CEFileCancelled fName + _ -> throwChatError $ CEFileAlreadyReceiving fName case fileConnReq of - -- old file protocol + -- direct file protocol Just connReq -> tryError (withAgent $ \a -> joinConnection a connReq . directMessage $ XFileAcpt fName) >>= \case Right agentConnId -> do filePath <- getRcvFilePath filePath_ fName withStore $ \st -> acceptRcvFileTransfer st user fileId agentConnId filePath Left e -> throwError e - -- new file protocol + -- group file protocol Nothing -> case grpMemberId of - Nothing -> do - ct <- withStore $ \st -> getContactByName st userId senderDisplayName - acceptFileV2 $ \sharedMsgId fileInvConnReq -> sendDirectContactMessage ct $ XFileAcptInv sharedMsgId fileInvConnReq fName + Nothing -> throwChatError $ CEFileInternal "group member not found for file transfer" Just memId -> do (GroupInfo {groupId}, GroupMember {activeConn}) <- withStore $ \st -> getGroupAndMember st user memId case activeConn of - Just conn -> - acceptFileV2 $ \sharedMsgId fileInvConnReq -> sendDirectMessage conn (XFileAcptInv sharedMsgId fileInvConnReq fName) (GroupId groupId) - _ -> throwChatError $ CEFileInternal "member connection not active" -- should not happen - where - acceptFileV2 :: (SharedMsgId -> ConnReqInvitation -> m SndMessage) -> m AChatItem - acceptFileV2 sendXFileAcptInv = do - sharedMsgId <- withStore $ \st -> getSharedMsgIdByFileId st userId fileId - (agentConnId, fileInvConnReq) <- withAgent (`createConnection` SCMInvitation) - filePath <- getRcvFilePath filePath_ fName - ci <- withStore $ \st -> acceptRcvFileTransfer st user fileId agentConnId filePath - void $ sendXFileAcptInv sharedMsgId fileInvConnReq - pure ci + Just conn -> do + sharedMsgId <- withStore $ \st -> getSharedMsgIdByFileId st userId fileId + (agentConnId, fileInvConnReq) <- withAgent (`createConnection` SCMInvitation) + filePath <- getRcvFilePath filePath_ fName + ci <- withStore $ \st -> acceptRcvFileTransfer st user fileId agentConnId filePath + void $ sendDirectMessage conn (XFileAcptInv sharedMsgId fileInvConnReq fName) (GroupId groupId) + pure ci + _ -> throwChatError $ CEFileInternal "member connection not active" where getRcvFilePath :: Maybe FilePath -> String -> m FilePath getRcvFilePath fPath_ fn = case fPath_ of @@ -1077,7 +1064,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage XMsgDel sharedMsgId -> messageDelete ct sharedMsgId msg msgMeta -- TODO discontinue XFile XFile fInv -> processFileInvitation' ct fInv msg msgMeta - XFileAcptInv sharedMsgId fileConnReq fName -> xFileAcptInv ct sharedMsgId fileConnReq fName msgMeta + XFileCancel sharedMsgId -> xFileCancel ct sharedMsgId msgMeta XInfo p -> xInfo ct p XGrpInv gInv -> processGroupInvitation ct gInv XInfoProbe probe -> xInfoProbe ct probe @@ -1217,6 +1204,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage XMsgDel sharedMsgId -> groupMessageDelete gInfo m sharedMsgId msg -- TODO discontinue XFile XFile fInv -> processGroupFileInvitation' gInfo m fInv msg msgMeta + XFileCancel sharedMsgId -> xFileCancelGroup gInfo m sharedMsgId msgMeta XFileAcptInv sharedMsgId fileConnReq fName -> xFileAcptInvGroup gInfo m sharedMsgId fileConnReq fName msgMeta XGrpMemNew memInfo -> xGrpMemNew gInfo m memInfo XGrpMemIntro memInfo -> xGrpMemIntro conn gInfo m memInfo @@ -1238,7 +1226,8 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage processSndFileConn :: ACommand 'Agent -> Connection -> SndFileTransfer -> m () processSndFileConn agentMsg conn ft@SndFileTransfer {fileId, fileName, fileStatus} = case agentMsg of - -- old file protocol + -- SMP CONF for SndFileConnection happens for direct file protocol + -- when recipient of the file "joins" connection created by the sender CONF confId connInfo -> do ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage connInfo case chatMsgEvent of @@ -1273,9 +1262,11 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage _ -> pure () processRcvFileConn :: ACommand 'Agent -> Connection -> RcvFileTransfer -> m () - processRcvFileConn agentMsg conn ft@RcvFileTransfer {fileId, chunkSize} = + processRcvFileConn agentMsg conn ft@RcvFileTransfer {fileId, chunkSize, cancelled} = case agentMsg of - -- new file protocol + -- SMP CONF for RcvFileConnection happens for group file protocol + -- when sender of the file "joins" connection created by the recipient + -- (sender doesn't create connections for all group members) CONF confId connInfo -> do ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage connInfo case chatMsgEvent of @@ -1284,14 +1275,15 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage CON -> do ci <- withStore $ \st -> do updateRcvFileStatus st ft FSConnected - updateCIFileStatus st userId fileId CIFSRcvTransfer + updateCIFileStatus st user fileId CIFSRcvTransfer getChatItemByFileId st user fileId toView $ CRRcvFileStart ci MSG meta@MsgMeta {recipient = (msgId, _), integrity} msgBody -> withAckMessage agentConnId meta $ do parseFileChunk msgBody >>= \case - FileChunkCancel -> do - cancelRcvFileTransfer ft - toView $ CRRcvFileSndCancelled ft + FileChunkCancel -> + unless cancelled $ do + cancelRcvFileTransfer user ft + toView (CRRcvFileSndCancelled ft) FileChunk {chunkNo, chunkBytes = chunk} -> do case integrity of MsgOk -> pure () @@ -1310,7 +1302,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage appendFileChunk ft chunkNo chunk ci <- withStore $ \st -> do updateRcvFileStatus st ft FSComplete - updateCIFileStatus st userId fileId CIFSRcvComplete + updateCIFileStatus st user fileId CIFSRcvComplete deleteRcvFileChunks st ft getChatItemByFileId st user fileId toView $ CRRcvFileComplete ci @@ -1368,12 +1360,10 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage agentErrToItemStatus err = CISSndError err badRcvFileChunk :: RcvFileTransfer -> String -> m () - badRcvFileChunk ft@RcvFileTransfer {fileStatus} err = - case fileStatus of - RFSCancelled _ -> pure () - _ -> do - cancelRcvFileTransfer ft - throwChatError $ CEFileRcvChunk err + badRcvFileChunk ft@RcvFileTransfer {cancelled} err = + unless cancelled $ do + cancelRcvFileTransfer user ft + throwChatError $ CEFileRcvChunk err notifyMemberConnected :: GroupInfo -> GroupMember -> m () notifyMemberConnected gInfo m@GroupMember {localDisplayName = c} = do @@ -1501,41 +1491,44 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage showToast ("#" <> g <> " " <> c <> "> ") "wants to send a file" setActive $ ActiveG g - xFileAcptInv :: Contact -> SharedMsgId -> ConnReqInvitation -> String -> MsgMeta -> m () - xFileAcptInv Contact {contactId} sharedMsgId fileConnReq fName msgMeta = do + xFileCancel :: Contact -> SharedMsgId -> MsgMeta -> m () + xFileCancel Contact {contactId} sharedMsgId msgMeta = do checkIntegrity msgMeta $ toView . CRMsgIntegrityError fileId <- withStore $ \st -> getFileIdBySharedMsgId st userId contactId sharedMsgId - withStore (\st -> getFileTransfer st userId fileId) >>= \case - FTSnd FileTransferMeta {fileName, cancelled} _ -> - if not cancelled - then - if fName == fileName - then - tryError (withAgent $ \a -> joinConnection a fileConnReq . directMessage $ XOk) >>= \case - Right acId -> - withStore $ \st -> createSndFileTransferV2Connection st userId fileId acId - Left e -> throwError e - else messageError "x.file.acpt.inv: fileName is different from expected" - else pure () -- TODO send "file cancelled" message - _ -> messageError "x.file.acpt.inv: bad file direction" + ft@RcvFileTransfer {cancelled} <- withStore (\st -> getRcvFileTransfer st user fileId) + unless cancelled $ do + cancelRcvFileTransfer user ft + toView $ CRRcvFileSndCancelled ft + + xFileCancelGroup :: GroupInfo -> GroupMember -> SharedMsgId -> MsgMeta -> m () + xFileCancelGroup GroupInfo {groupId} GroupMember {memberId} sharedMsgId msgMeta = do + checkIntegrity msgMeta $ toView . CRMsgIntegrityError + fileId <- withStore $ \st -> getGroupFileIdBySharedMsgId st userId groupId sharedMsgId + CChatItem msgDir ChatItem {chatDir} <- withStore $ \st -> getGroupChatItemBySharedMsgId st user groupId sharedMsgId + case (msgDir, chatDir) of + (SMDRcv, CIGroupRcv m) -> do + if sameMemberId memberId m + then do + ft@RcvFileTransfer {cancelled} <- withStore (\st -> getRcvFileTransfer st user fileId) + unless cancelled $ do + cancelRcvFileTransfer user ft + toView $ CRRcvFileSndCancelled ft + else messageError "x.file.cancel: group member attempted to cancel file of another member" + (SMDSnd, _) -> messageError "x.file.cancel: group member attempted invalid file cancel" xFileAcptInvGroup :: GroupInfo -> GroupMember -> SharedMsgId -> ConnReqInvitation -> String -> MsgMeta -> m () xFileAcptInvGroup GroupInfo {groupId} m sharedMsgId fileConnReq fName msgMeta = do checkIntegrity msgMeta $ toView . CRMsgIntegrityError fileId <- withStore $ \st -> getGroupFileIdBySharedMsgId st userId groupId sharedMsgId - withStore (\st -> getFileTransfer st userId fileId) >>= \case - FTSnd FileTransferMeta {fileName, cancelled} _ -> - if not cancelled - then - if fName == fileName - then - tryError (withAgent $ \a -> joinConnection a fileConnReq . directMessage $ XOk) >>= \case - Right acId -> - withStore $ \st -> createSndGroupFileTransferV2Connection st userId fileId acId m - Left e -> throwError e - else messageError "x.file.acpt.inv: fileName is different from expected" - else pure () -- TODO send "file cancelled" message - _ -> messageError "x.file.acpt.inv: bad file direction" + (FileTransferMeta {fileName, cancelled}, _) <- withStore (\st -> getSndFileTransfer st user fileId) + unless cancelled $ + if fName == fileName + then + tryError (withAgent $ \a -> joinConnection a fileConnReq . directMessage $ XOk) >>= \case + Right acId -> + withStore $ \st -> createSndGroupFileTransferConnection st userId fileId acId m + Left e -> throwError e + else messageError "x.file.acpt.inv: fileName is different from expected" groupMsgToView :: GroupInfo -> ChatItem 'CTGroup 'MDRcv -> MsgMeta -> m () groupMsgToView gInfo ci msgMeta = do @@ -1869,10 +1862,11 @@ isFileActive fileId files = do fs <- asks files isJust . M.lookup fileId <$> readTVarIO fs -cancelRcvFileTransfer :: ChatMonad m => RcvFileTransfer -> m () -cancelRcvFileTransfer ft@RcvFileTransfer {fileId, fileStatus} = do +cancelRcvFileTransfer :: ChatMonad m => User -> RcvFileTransfer -> m () +cancelRcvFileTransfer user ft@RcvFileTransfer {fileId, fileStatus} = do closeFileHandle fileId rcvFiles withStore $ \st -> do + updateFileCancelled st user fileId CIFSRcvCancelled updateRcvFileStatus st ft FSCancelled deleteRcvFileChunks st ft case fileStatus of @@ -1882,6 +1876,11 @@ cancelRcvFileTransfer ft@RcvFileTransfer {fileId, fileStatus} = do withAgent (`deleteConnection` acId) _ -> pure () +cancelSndFile :: ChatMonad m => User -> FileTransferMeta -> [SndFileTransfer] -> m () +cancelSndFile user FileTransferMeta {fileId} fts = do + withStore $ \st -> updateFileCancelled st user fileId CIFSSndCancelled + forM_ fts $ \ft' -> cancelSndFileTransfer ft' + cancelSndFileTransfer :: ChatMonad m => SndFileTransfer -> m () cancelSndFileTransfer ft@SndFileTransfer {agentConnId = AgentConnId acId, fileStatus} = unless (fileStatus == FSCancelled || fileStatus == FSComplete) $ do diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 0f0a48e5af..f43b42b449 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -335,6 +335,7 @@ data ChatErrorType | CEGroupInternal {message :: String} | CEFileNotFound {message :: String} | CEFileAlreadyReceiving {message :: String} + | CEFileCancelled {message :: String} | CEFileAlreadyExists {filePath :: FilePath} | CEFileRead {filePath :: FilePath, message :: String} | CEFileWrite {filePath :: FilePath, message :: String} diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 525d283067..47e0aa8218 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -311,6 +311,18 @@ data CIFileStatus (d :: MsgDirection) where deriving instance Show (CIFileStatus d) +ciFileEnded :: CIFileStatus d -> Bool +ciFileEnded = \case + CIFSSndStored -> False + CIFSSndTransfer -> False + CIFSSndCancelled -> True + CIFSSndComplete -> True + CIFSRcvInvitation -> False + CIFSRcvAccepted -> False + CIFSRcvTransfer -> False + CIFSRcvCancelled -> True + CIFSRcvComplete -> True + instance MsgDirectionI d => ToJSON (CIFileStatus d) where toJSON = strToJSON toEncoding = strToJEncoding diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 6a61f96691..83b0d8c204 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -114,8 +114,9 @@ data ChatMsgEvent | XMsgDel SharedMsgId | XMsgDeleted | XFile FileInvitation -- TODO discontinue - | XFileAcpt String -- old file protocol - | XFileAcptInv SharedMsgId ConnReqInvitation String -- new file protocol + | XFileAcpt String -- direct file protocol + | XFileAcptInv SharedMsgId ConnReqInvitation String -- group file protocol + | XFileCancel SharedMsgId | XInfo Profile | XContact Profile (Maybe XContactId) | XGrpInv GroupInvitation @@ -295,6 +296,7 @@ data CMEventTag | XFile_ | XFileAcpt_ | XFileAcptInv_ + | XFileCancel_ | XInfo_ | XContact_ | XGrpInv_ @@ -330,6 +332,7 @@ instance StrEncoding CMEventTag where XFile_ -> "x.file" XFileAcpt_ -> "x.file.acpt" XFileAcptInv_ -> "x.file.acpt.inv" + XFileCancel_ -> "x.file.cancel" XInfo_ -> "x.info" XContact_ -> "x.contact" XGrpInv_ -> "x.grp.inv" @@ -362,6 +365,7 @@ instance StrEncoding CMEventTag where "x.file" -> Right XFile_ "x.file.acpt" -> Right XFileAcpt_ "x.file.acpt.inv" -> Right XFileAcptInv_ + "x.file.cancel" -> Right XFileCancel_ "x.info" -> Right XInfo_ "x.contact" -> Right XContact_ "x.grp.inv" -> Right XGrpInv_ @@ -397,6 +401,7 @@ toCMEventTag = \case XFile _ -> XFile_ XFileAcpt _ -> XFileAcpt_ XFileAcptInv {} -> XFileAcptInv_ + XFileCancel _ -> XFileCancel_ XInfo _ -> XInfo_ XContact _ _ -> XContact_ XGrpInv _ -> XGrpInv_ @@ -450,6 +455,7 @@ appToChatMessage AppMessage {msgId, event, params} = do XFile_ -> XFile <$> p "file" XFileAcpt_ -> XFileAcpt <$> p "fileName" XFileAcptInv_ -> XFileAcptInv <$> p "msgId" <*> p "fileConnReq" <*> p "fileName" + XFileCancel_ -> XFileCancel <$> p "msgId" XInfo_ -> XInfo <$> p "profile" XContact_ -> XContact <$> p "profile" <*> opt "contactReqId" XGrpInv_ -> XGrpInv <$> p "groupInvitation" @@ -490,6 +496,7 @@ chatToAppMessage ChatMessage {msgId, chatMsgEvent} = AppMessage {msgId, event, p XFile fileInv -> o ["file" .= fileInvitationJSON fileInv] XFileAcpt fileName -> o ["fileName" .= fileName] XFileAcptInv sharedMsgId fileConnReq fileName -> o ["msgId" .= sharedMsgId, "fileConnReq" .= fileConnReq, "fileName" .= fileName] + XFileCancel sharedMsgId -> o ["msgId" .= sharedMsgId] XInfo profile -> o ["profile" .= profile] XContact profile xContactId -> o $ ("contactReqId" .=? xContactId) ["profile" .= profile] XGrpInv groupInv -> o ["groupInvitation" .= groupInv] diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index f9aafdbade..30e8f8a4d2 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -90,17 +90,15 @@ module Simplex.Chat.Store matchReceivedProbeHash, matchSentProbe, mergeContactRecords, - createSndFileTransfer, -- old file protocol - createSndFileTransferV2, - createSndFileTransferV2Connection, - createSndGroupFileTransfer, -- old file protocol - createSndGroupFileTransferV2, - createSndGroupFileTransferV2Connection, + createSndFileTransfer, + createSndGroupFileTransfer, + createSndGroupFileTransferConnection, updateFileCancelled, updateCIFileStatus, getSharedMsgIdByFileId, getFileIdBySharedMsgId, getGroupFileIdBySharedMsgId, + getChatRefByFileId, updateSndFileStatus, createSndFileChunk, updateSndFileChunkMsg, @@ -117,6 +115,7 @@ module Simplex.Chat.Store updateFileTransferChatItemId, getFileTransfer, getFileTransferProgress, + getSndFileTransfer, getContactFiles, createNewSndMessage, createSndMsgDelivery, @@ -211,7 +210,6 @@ import Simplex.Messaging.Encoding.String (StrEncoding (strEncode)) import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) import Simplex.Messaging.Protocol (ProtocolServer (..), SMPServer, pattern SMPServer) import Simplex.Messaging.Util (liftIOEither, (<$$>)) -import System.FilePath (takeFileName) import UnliftIO.STM schemaMigrations :: [(String, Query)] @@ -1849,46 +1847,8 @@ createSndFileTransfer st userId Contact {contactId} filePath FileInvitation {fil (fileId, fileStatus, connId, currentTs, currentTs) pure fileId -createSndFileTransferV2 :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> FilePath -> FileInvitation -> Integer -> m Int64 -createSndFileTransferV2 st userId Contact {contactId} filePath FileInvitation {fileName, fileSize} chunkSize = - liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - DB.execute - db - "INSERT INTO files (user_id, contact_id, file_name, file_path, file_size, chunk_size, ci_file_status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" - (userId, contactId, fileName, filePath, fileSize, chunkSize, CIFSSndStored, currentTs, currentTs) - insertedRowId db - -createSndFileTransferV2Connection :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> ConnId -> m () -createSndFileTransferV2Connection st userId fileId acId = - liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - Connection {connId} <- createSndFileConnection_ db userId fileId acId - DB.execute - db - "INSERT INTO snd_files (file_id, file_status, connection_id, created_at, updated_at) VALUES (?,?,?,?,?)" - (fileId, FSAccepted, connId, currentTs, currentTs) - -createSndGroupFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> GroupInfo -> [(GroupMember, ConnId, FileInvitation)] -> FilePath -> Integer -> Integer -> m Int64 -createSndGroupFileTransfer st userId GroupInfo {groupId} ms filePath fileSize chunkSize = - liftIO . withTransaction st $ \db -> do - let fileName = takeFileName filePath - currentTs <- getCurrentTime - DB.execute - db - "INSERT INTO files (user_id, group_id, file_name, file_path, file_size, chunk_size, ci_file_status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" - (userId, groupId, fileName, filePath, fileSize, chunkSize, CIFSSndStored, currentTs, currentTs) - fileId <- insertedRowId db - forM_ ms $ \(GroupMember {groupMemberId}, agentConnId, _) -> do - Connection {connId} <- createSndFileConnection_ db userId fileId agentConnId - DB.execute - db - "INSERT INTO snd_files (file_id, file_status, connection_id, group_member_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" - (fileId, FSNew, connId, groupMemberId, currentTs, currentTs) - pure fileId - -createSndGroupFileTransferV2 :: MonadUnliftIO m => SQLiteStore -> UserId -> GroupInfo -> FilePath -> FileInvitation -> Integer -> m Int64 -createSndGroupFileTransferV2 st userId GroupInfo {groupId} filePath FileInvitation {fileName, fileSize} chunkSize = +createSndGroupFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> GroupInfo -> FilePath -> FileInvitation -> Integer -> m Int64 +createSndGroupFileTransfer st userId GroupInfo {groupId} filePath FileInvitation {fileName, fileSize} chunkSize = liftIO . withTransaction st $ \db -> do currentTs <- getCurrentTime DB.execute @@ -1897,8 +1857,8 @@ createSndGroupFileTransferV2 st userId GroupInfo {groupId} filePath FileInvitati (userId, groupId, fileName, filePath, fileSize, chunkSize, CIFSSndStored, currentTs, currentTs) insertedRowId db -createSndGroupFileTransferV2Connection :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> ConnId -> GroupMember -> m () -createSndGroupFileTransferV2Connection st userId fileId acId GroupMember {groupMemberId} = +createSndGroupFileTransferConnection :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> ConnId -> GroupMember -> m () +createSndGroupFileTransferConnection st userId fileId acId GroupMember {groupMemberId} = liftIO . withTransaction st $ \db -> do currentTs <- getCurrentTime Connection {connId} <- createSndFileConnection_ db userId fileId acId @@ -1907,18 +1867,18 @@ createSndGroupFileTransferV2Connection st userId fileId acId GroupMember {groupM "INSERT INTO snd_files (file_id, file_status, connection_id, group_member_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" (fileId, FSAccepted, connId, groupMemberId, currentTs, currentTs) -updateFileCancelled :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> m () -updateFileCancelled st userId fileId = +updateFileCancelled :: MsgDirectionI d => MonadUnliftIO m => SQLiteStore -> User -> Int64 -> CIFileStatus d -> m () +updateFileCancelled st User {userId} fileId ciFileStatus = liftIO . withTransaction st $ \db -> do currentTs <- getCurrentTime - DB.execute db "UPDATE files SET cancelled = 1, updated_at = ? WHERE user_id = ? AND file_id = ?" (currentTs, userId, fileId) + DB.execute db "UPDATE files SET cancelled = 1, ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ?" (ciFileStatus, currentTs, userId, fileId) -updateCIFileStatus :: (MsgDirectionI d, MonadUnliftIO m) => SQLiteStore -> UserId -> Int64 -> CIFileStatus d -> m () -updateCIFileStatus st userId fileId ciFileStatus = - liftIO . withTransaction st $ \db -> updateCIFileStatus_ db userId fileId ciFileStatus +updateCIFileStatus :: (MsgDirectionI d, MonadUnliftIO m) => SQLiteStore -> User -> Int64 -> CIFileStatus d -> m () +updateCIFileStatus st user fileId ciFileStatus = + liftIO . withTransaction st $ \db -> updateCIFileStatus_ db user fileId ciFileStatus -updateCIFileStatus_ :: MsgDirectionI d => DB.Connection -> UserId -> Int64 -> CIFileStatus d -> IO () -updateCIFileStatus_ db userId fileId ciFileStatus = do +updateCIFileStatus_ :: MsgDirectionI d => DB.Connection -> User -> Int64 -> CIFileStatus d -> IO () +updateCIFileStatus_ db User {userId} fileId ciFileStatus = do currentTs <- getCurrentTime DB.execute db "UPDATE files SET ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ?" (ciFileStatus, currentTs, userId, fileId) @@ -1936,7 +1896,7 @@ getSharedMsgIdByFileId st userId fileId = |] (userId, fileId) -getFileIdBySharedMsgId :: StoreMonad m => SQLiteStore -> Int64 -> UserId -> SharedMsgId -> m Int64 +getFileIdBySharedMsgId :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> SharedMsgId -> m Int64 getFileIdBySharedMsgId st userId contactId sharedMsgId = liftIOEither . withTransaction st $ \db -> firstRow fromOnly (SEFileIdNotFoundBySharedMsgId sharedMsgId) $ @@ -1950,7 +1910,7 @@ getFileIdBySharedMsgId st userId contactId sharedMsgId = |] (userId, contactId, sharedMsgId) -getGroupFileIdBySharedMsgId :: StoreMonad m => SQLiteStore -> Int64 -> UserId -> SharedMsgId -> m Int64 +getGroupFileIdBySharedMsgId :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> SharedMsgId -> m Int64 getGroupFileIdBySharedMsgId st userId groupId sharedMsgId = liftIOEither . withTransaction st $ \db -> firstRow fromOnly (SEFileIdNotFoundBySharedMsgId sharedMsgId) $ @@ -1964,6 +1924,23 @@ getGroupFileIdBySharedMsgId st userId groupId sharedMsgId = |] (userId, groupId, sharedMsgId) +getChatRefByFileId :: StoreMonad m => SQLiteStore -> User -> Int64 -> m ChatRef +getChatRefByFileId st User {userId} fileId = do + r <- liftIO . withTransaction st $ \db -> do + DB.query + db + [sql| + SELECT contact_id, group_id + FROM files + WHERE user_id = ? AND file_id = ? + LIMIT 1 + |] + (userId, fileId) + case r of + [(Just contactId, Nothing)] -> pure $ ChatRef CTDirect contactId + [(Nothing, Just groupId)] -> pure $ ChatRef CTGroup groupId + _ -> throwError $ SEInternalError "could not retrieve chat ref by file id" + createSndFileConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> IO Connection createSndFileConnection_ db userId fileId agentConnId = do currentTs <- getCurrentTime @@ -2057,8 +2034,8 @@ createRcvGroupFileTransfer st userId GroupMember {groupId, groupMemberId, localD (fileId, FSNew, fileConnReq, groupMemberId, currentTs, currentTs) pure RcvFileTransfer {fileId, fileInvitation = f, fileStatus = RFSNew, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Just groupMemberId} -getRcvFileTransfer :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m RcvFileTransfer -getRcvFileTransfer st userId fileId = +getRcvFileTransfer :: StoreMonad m => SQLiteStore -> User -> Int64 -> m RcvFileTransfer +getRcvFileTransfer st User {userId} fileId = liftIOEither . withTransaction st $ \db -> getRcvFileTransfer_ db userId fileId @@ -2090,17 +2067,18 @@ getRcvFileTransfer_ db userId fileId = Nothing -> Left $ SERcvFileInvalid fileId Just name -> case fileStatus' of - FSNew -> Right RcvFileTransfer {fileId, fileInvitation = fileInv, fileStatus = RFSNew, senderDisplayName = name, chunkSize, cancelled, grpMemberId} - FSAccepted -> ft name fileInv RFSAccepted fileInfo - FSConnected -> ft name fileInv RFSConnected fileInfo - FSComplete -> ft name fileInv RFSComplete fileInfo - FSCancelled -> ft name fileInv RFSCancelled fileInfo + FSNew -> ft name fileInv RFSNew + FSAccepted -> ft name fileInv . RFSAccepted =<< rfi fileInfo + FSConnected -> ft name fileInv . RFSConnected =<< rfi fileInfo + FSComplete -> ft name fileInv . RFSComplete =<< rfi fileInfo + FSCancelled -> ft name fileInv . RFSCancelled $ rfi_ fileInfo where - ft senderDisplayName fileInvitation rfs = \case - (Just filePath, Just connId, Just agentConnId) -> - let fileStatus = rfs RcvFileInfo {filePath, connId, agentConnId} - in Right RcvFileTransfer {..} - _ -> Left $ SERcvFileInvalid fileId + ft senderDisplayName fileInvitation fileStatus = + Right RcvFileTransfer {fileId, fileInvitation, fileStatus, senderDisplayName, chunkSize, cancelled, grpMemberId} + rfi fileInfo = maybe (Left $ SERcvFileInvalid fileId) Right $ rfi_ fileInfo + rfi_ = \case + (Just filePath, Just connId, Just agentConnId) -> Just RcvFileInfo {filePath, connId, agentConnId} + _ -> Nothing cancelled = fromMaybe False cancelled_ rcvFileTransfer _ = Left $ SERcvFileNotFound fileId @@ -2185,13 +2163,13 @@ updateFileTransferChatItemId st fileId ciId = currentTs <- getCurrentTime DB.execute db "UPDATE files SET chat_item_id = ?, updated_at = ? WHERE file_id = ?" (ciId, currentTs, fileId) -getFileTransfer :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m FileTransfer -getFileTransfer st userId fileId = +getFileTransfer :: StoreMonad m => SQLiteStore -> User -> Int64 -> m FileTransfer +getFileTransfer st User {userId} fileId = liftIOEither . withTransaction st $ \db -> getFileTransfer_ db userId fileId -getFileTransferProgress :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m (FileTransfer, [Integer]) -getFileTransferProgress st userId fileId = +getFileTransferProgress :: StoreMonad m => SQLiteStore -> User -> Int64 -> m (FileTransfer, [Integer]) +getFileTransferProgress st User {userId} fileId = liftIOEither . withTransaction st $ \db -> runExceptT $ do ft <- ExceptT $ getFileTransfer_ db userId fileId liftIO $ @@ -2215,15 +2193,20 @@ getFileTransfer_ db userId fileId = (userId, fileId) where fileTransfer :: [(Maybe Int64, Maybe Int64)] -> IO (Either StoreError FileTransfer) - fileTransfer [(Nothing, Nothing)] = runExceptT $ do - fileTransferMeta <- ExceptT $ getFileTransferMeta_ db userId fileId - pure FTSnd {fileTransferMeta, sndFileTransfers = []} - fileTransfer ((Just _, Nothing) : _) = runExceptT $ do - fileTransferMeta <- ExceptT $ getFileTransferMeta_ db userId fileId - sndFileTransfers <- ExceptT $ getSndFileTransfers_ db userId fileId - pure FTSnd {fileTransferMeta, sndFileTransfers} fileTransfer [(Nothing, Just _)] = FTRcv <$$> getRcvFileTransfer_ db userId fileId - fileTransfer _ = pure . Left $ SEFileNotFound fileId + fileTransfer _ = runExceptT $ do + (ftm, fts) <- ExceptT $ getSndFileTransfer_ db userId fileId + pure $ FTSnd {fileTransferMeta = ftm, sndFileTransfers = fts} + +getSndFileTransfer :: StoreMonad m => SQLiteStore -> User -> Int64 -> m (FileTransferMeta, [SndFileTransfer]) +getSndFileTransfer st User {userId} fileId = + liftIOEither . withTransaction st $ \db -> getSndFileTransfer_ db userId fileId + +getSndFileTransfer_ :: DB.Connection -> UserId -> Int64 -> IO (Either StoreError (FileTransferMeta, [SndFileTransfer])) +getSndFileTransfer_ db userId fileId = runExceptT $ do + fileTransferMeta <- ExceptT $ getFileTransferMeta_ db userId fileId + sndFileTransfers <- ExceptT $ getSndFileTransfers_ db userId fileId + pure (fileTransferMeta, sndFileTransfers) getSndFileTransfers_ :: DB.Connection -> UserId -> Int64 -> IO (Either StoreError [SndFileTransfer]) getSndFileTransfers_ db userId fileId = @@ -2243,7 +2226,7 @@ getSndFileTransfers_ db userId fileId = (userId, fileId) where sndFileTransfers :: [(FileStatus, String, Integer, Integer, FilePath, Int64, AgentConnId, Maybe ContactName, Maybe ContactName)] -> Either StoreError [SndFileTransfer] - sndFileTransfers [] = Left $ SESndFileNotFound fileId + sndFileTransfers [] = Right [] sndFileTransfers fts = mapM sndFileTransfer fts sndFileTransfer (fileStatus, fileName, fileSize, chunkSize, filePath, connId, agentConnId, contactName_, memberName_) = case contactName_ <|> memberName_ of @@ -3564,12 +3547,12 @@ getChatItemIdByFileId_ db userId fileId = (userId, fileId) updateDirectCIFileStatus :: forall d m. (MsgDirectionI d, StoreMonad m) => SQLiteStore -> User -> Int64 -> CIFileStatus d -> m AChatItem -updateDirectCIFileStatus st user@User {userId} fileId fileStatus = +updateDirectCIFileStatus st user fileId fileStatus = liftIOEither . withTransaction st $ \db -> runExceptT $ do aci@(AChatItem cType d cInfo ci) <- ExceptT $ getChatItemByFileId_ db user fileId case (cType, testEquality d $ msgDirection @d) of (SCTDirect, Just Refl) -> do - liftIO $ updateCIFileStatus_ db userId fileId fileStatus + liftIO $ updateCIFileStatus_ db user fileId fileStatus pure $ AChatItem SCTDirect d cInfo $ updateFileStatus ci fileStatus _ -> pure aci diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 8c200b067e..5f4aafc789 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -549,7 +549,7 @@ data RcvFileStatus | RFSAccepted RcvFileInfo | RFSConnected RcvFileInfo | RFSComplete RcvFileInfo - | RFSCancelled RcvFileInfo + | RFSCancelled (Maybe RcvFileInfo) deriving (Eq, Show, Generic) instance ToJSON RcvFileStatus where diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 5b79a664a1..72d374a161 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -620,7 +620,8 @@ viewFileTransferStatus (FTRcv ft@RcvFileTransfer {fileId, fileInvitation = FileI RFSAccepted _ -> "just started" RFSConnected _ -> "progress " <> fileProgress chunksNum chunkSize fileSize RFSComplete RcvFileInfo {filePath} -> "complete, path: " <> plain filePath - RFSCancelled RcvFileInfo {filePath} -> "cancelled, received part path: " <> plain filePath + RFSCancelled (Just RcvFileInfo {filePath}) -> "cancelled, received part path: " <> plain filePath + RFSCancelled Nothing -> "cancelled" listRecipients :: [SndFileTransfer] -> StyledString listRecipients = mconcat . intersperse ", " . map (ttyContact . recipientDisplayName) @@ -652,6 +653,7 @@ viewChatError = \case CEGroupInternal s -> ["chat group bug: " <> plain s] CEFileNotFound f -> ["file not found: " <> plain f] CEFileAlreadyReceiving f -> ["file is already accepted: " <> plain f] + CEFileCancelled f -> ["file cancelled: " <> plain f] CEFileAlreadyExists f -> ["file already exists: " <> plain f] CEFileRead f e -> ["cannot read file " <> plain f, sShow e] CEFileWrite f e -> ["cannot write file " <> plain f, sShow e] diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 77c5827a0e..9a6f0b7be8 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -58,9 +58,11 @@ chatTests = do describe "sending and receiving files" $ do it "send and receive file" testFileTransfer it "send and receive a small file" testSmallFileTransfer - it "sender cancelled file transfer" testFileSndCancel + it "sender cancelled file transfer before transfer" testFileSndCancelBeforeTransfer + it "sender cancelled file transfer during transfer" testFileSndCancelDuringTransfer it "recipient cancelled file transfer" testFileRcvCancel it "send and receive file to group" testGroupFileTransfer + it "sender cancelled group file transfer before transfer" testGroupFileSndCancelBeforeTransfer describe "messages with files" $ do it "send and receive message with file" testMessageWithFile it "send and receive image" testSendImage @@ -1028,8 +1030,30 @@ testSmallFileTransfer = dest <- B.readFile "./tests/tmp/test.txt" dest `shouldBe` src -testFileSndCancel :: IO () -testFileSndCancel = +testFileSndCancelBeforeTransfer :: IO () +testFileSndCancelBeforeTransfer = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice #> "/f @bob ./tests/fixtures/test.txt" + alice <## "use /fc 1 to cancel sending" + bob <# "alice> sends file test.txt (11 bytes / 11 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + alice ##> "/fc 1" + concurrentlyN_ + [ alice <## "cancelled sending file 1 (test.txt) to bob", + bob <## "alice cancelled sending file 1 (test.txt)" + ] + alice ##> "/fs 1" + alice <## "sending file 1 (test.txt) cancelled: bob" + alice <## "file transfer cancelled" + bob ##> "/fs 1" + bob <## "receiving file 1 (test.txt) cancelled" + bob ##> "/fr 1 ./tests/tmp" + bob <## "file cancelled: test.txt" + +testFileSndCancelDuringTransfer :: IO () +testFileSndCancelDuringTransfer = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob @@ -1112,6 +1136,34 @@ testGroupFileTransfer = cath <## "completed receiving file 1 (test.jpg) from alice" ] +testGroupFileSndCancelBeforeTransfer :: IO () +testGroupFileSndCancelBeforeTransfer = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + alice #> "/f #team ./tests/fixtures/test.txt" + alice <## "use /fc 1 to cancel sending" + concurrentlyN_ + [ do + bob <# "#team alice> sends file test.txt (11 bytes / 11 bytes)" + bob <## "use /fr 1 [/ | ] to receive it", + do + cath <# "#team alice> sends file test.txt (11 bytes / 11 bytes)" + cath <## "use /fr 1 [/ | ] to receive it" + ] + alice ##> "/fc 1" + concurrentlyN_ + [ alice <## "cancelled sending file 1 (test.txt)", + bob <## "alice cancelled sending file 1 (test.txt)", + cath <## "alice cancelled sending file 1 (test.txt)" + ] + alice ##> "/fs 1" + alice <## "sending file 1 (test.txt): no file transfers, file transfer cancelled" + bob ##> "/fs 1" + bob <## "receiving file 1 (test.txt) cancelled" + bob ##> "/fr 1 ./tests/tmp" + bob <## "file cancelled: test.txt" + testMessageWithFile :: IO () testMessageWithFile = testChat2 aliceProfile bobProfile $ @@ -1845,15 +1897,19 @@ testNegotiateCall = alice #$> ("/_get chat @2 count=100", chat, [(1, "outgoing call: accepted")]) -- alice confirms call by sending WebRTC answer alice ##> ("/_call answer @2 " <> serialize testWebRTCSession) - alice <## "ok" - alice <## "message updated" + alice + <### [ "ok", + "message updated" + ] alice #$> ("/_get chat @2 count=100", chat, [(1, "outgoing call: connecting...")]) bob <## "call answer from alice" bob #$> ("/_get chat @2 count=100", chat, [(0, "incoming call: connecting...")]) -- participants can update calls as connected alice ##> "/_call status @2 connected" - alice <## "ok" - alice <## "message updated" + alice + <### [ "ok", + "message updated" + ] alice #$> ("/_get chat @2 count=100", chat, [(1, "outgoing call: in progress (00:00)")]) bob ##> "/_call status @2 connected" bob <## "ok" diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 29d76d9914..3b615ddffd 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -169,6 +169,9 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do it "x.file.acpt.inv" $ "{\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" #==# XFileAcptInv (SharedMsgId "\1\2\3\4") testConnReq "photo.jpg" + it "x.file.cancel" $ + "{\"event\":\"x.file.cancel\",\"params\":{\"msgId\":\"AQIDBA==\"}}" + #==# XFileCancel (SharedMsgId "\1\2\3\4") it "x.info" $ "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" #==# XInfo testProfile From 885a4ea97271c7614c7666399b1348fa30e7d1bb Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 11 May 2022 16:52:08 +0100 Subject: [PATCH 93/95] terminal: change default servers (#633) --- apps/simplex-bot-advanced/Main.hs | 4 ++-- apps/simplex-bot/Main.hs | 4 ++-- apps/simplex-chat/Main.hs | 5 ++--- src/Simplex/Chat.hs | 31 +++++++++++++++++-------------- src/Simplex/Chat/Controller.hs | 3 ++- src/Simplex/Chat/Terminal.hs | 19 +++++++++++++++++++ 6 files changed, 44 insertions(+), 22 deletions(-) diff --git a/apps/simplex-bot-advanced/Main.hs b/apps/simplex-bot-advanced/Main.hs index bb81911913..6b31a62dce 100644 --- a/apps/simplex-bot-advanced/Main.hs +++ b/apps/simplex-bot-advanced/Main.hs @@ -11,12 +11,12 @@ import Control.Concurrent.Async import Control.Concurrent.STM import Control.Monad.Reader import qualified Data.Text as T -import Simplex.Chat import Simplex.Chat.Bot import Simplex.Chat.Controller import Simplex.Chat.Core import Simplex.Chat.Messages import Simplex.Chat.Options +import Simplex.Chat.Terminal (terminalChatConfig) import Simplex.Chat.Types import System.Directory (getAppUserDataDirectory) import Text.Read @@ -24,7 +24,7 @@ import Text.Read main :: IO () main = do opts <- welcomeGetOpts - simplexChatCore defaultChatConfig opts Nothing mySquaringBot + simplexChatCore terminalChatConfig opts Nothing mySquaringBot welcomeGetOpts :: IO ChatOpts welcomeGetOpts = do diff --git a/apps/simplex-bot/Main.hs b/apps/simplex-bot/Main.hs index 1c322dbc18..2228070039 100644 --- a/apps/simplex-bot/Main.hs +++ b/apps/simplex-bot/Main.hs @@ -2,18 +2,18 @@ module Main where -import Simplex.Chat import Simplex.Chat.Bot import Simplex.Chat.Controller (versionNumber) import Simplex.Chat.Core import Simplex.Chat.Options +import Simplex.Chat.Terminal (terminalChatConfig) import System.Directory (getAppUserDataDirectory) import Text.Read main :: IO () main = do opts <- welcomeGetOpts - simplexChatCore defaultChatConfig opts Nothing $ + simplexChatCore terminalChatConfig opts Nothing $ chatBotRepl "Hello! I am a simple squaring bot - if you send me a number, I will calculate its square" $ \msg -> case readMaybe msg :: Maybe Integer of Just n -> msg <> " * " <> msg <> " = " <> show (n * n) diff --git a/apps/simplex-chat/Main.hs b/apps/simplex-chat/Main.hs index 0a94c77935..821bc2a270 100644 --- a/apps/simplex-chat/Main.hs +++ b/apps/simplex-chat/Main.hs @@ -3,7 +3,6 @@ module Main where import Control.Concurrent (threadDelay) -import Simplex.Chat import Simplex.Chat.Controller (versionNumber) import Simplex.Chat.Core import Simplex.Chat.Options @@ -20,8 +19,8 @@ main = do then do welcome opts t <- withTerminal pure - simplexChatTerminal defaultChatConfig opts t - else simplexChatCore defaultChatConfig opts Nothing $ \_ cc -> do + simplexChatTerminal terminalChatConfig opts t + else simplexChatCore terminalChatConfig opts Nothing $ \_ cc -> do r <- sendChatCmd cc chatCmd putStrLn $ serializeChatResponse r threadDelay $ chatCmdDelay opts * 1000000 diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index cc6cab47b2..2bd6bdfd00 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -86,6 +86,7 @@ defaultChatConfig = }, dbPoolSize = 1, yesToMigrations = False, + defaultServers = InitialAgentServers {smp = _defaultSMPServers, ntf = _defaultNtfServers}, tbqSize = 64, fileChunkSize = 15780, subscriptionConcurrency = 16, @@ -93,30 +94,29 @@ defaultChatConfig = testView = False } -defaultSMPServers :: NonEmpty SMPServer -defaultSMPServers = +_defaultSMPServers :: NonEmpty SMPServer +_defaultSMPServers = L.fromList [ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im", "smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im", "smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im" ] -defaultNtfServers :: [NtfServer] -defaultNtfServers = ["smp://ZH1Dkt2_EQRbxUUyjLlcUjg1KAhBrqfvE0xfn7Ki0Zg=@ntf1.simplex.im"] +_defaultNtfServers :: [NtfServer] +_defaultNtfServers = ["smp://ZH1Dkt2_EQRbxUUyjLlcUjg1KAhBrqfvE0xfn7Ki0Zg=@ntf1.simplex.im"] logCfg :: LogConfig logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} newChatController :: SQLiteStore -> Maybe User -> ChatConfig -> ChatOpts -> Maybe (Notification -> IO ()) -> IO ChatController -newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize} ChatOpts {dbFilePrefix, smpServers, logConnections} sendToast = do +newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize, defaultServers} ChatOpts {dbFilePrefix, smpServers, logConnections} sendToast = do let f = chatStoreFile dbFilePrefix config = cfg {subscriptionEvents = logConnections} sendNotification = fromMaybe (const $ pure ()) sendToast activeTo <- newTVarIO ActiveNone firstTime <- not <$> doesFileExist f currentUser <- newTVarIO user - initialSMPServers <- resolveServers - let servers = InitialAgentServers {smp = initialSMPServers, ntf = defaultNtfServers} + servers <- resolveServers defaultServers smpAgent <- getSMPAgentClient aCfg {dbFile = dbFilePrefix <> "_agent.db"} servers agentAsync <- newTVarIO Nothing idsDrg <- newTVarIO =<< drgNew @@ -130,12 +130,14 @@ newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize} Ch filesFolder <- newTVarIO Nothing pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, filesFolder} where - resolveServers :: IO (NonEmpty SMPServer) - resolveServers = case user of - Nothing -> pure $ if null smpServers then defaultSMPServers else L.fromList smpServers - Just usr -> do - userSmpServers <- getSMPServers chatStore usr - pure . fromMaybe defaultSMPServers . nonEmpty $ if null smpServers then userSmpServers else smpServers + resolveServers :: InitialAgentServers -> IO InitialAgentServers + resolveServers ss@InitialAgentServers {smp = defaultSMPServers} = case nonEmpty smpServers of + Just smpServers' -> pure ss {smp = smpServers'} + _ -> case user of + Just usr -> do + userSmpServers <- getSMPServers chatStore usr + pure ss {smp = fromMaybe defaultSMPServers $ nonEmpty userSmpServers} + _ -> pure ss runChatController :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m () runChatController = race_ notificationSubscriber . agentSubscriber @@ -482,6 +484,7 @@ processChatCommand = \case GetUserSMPServers -> CRUserSMPServers <$> withUser (\user -> withStore (`getSMPServers` user)) SetUserSMPServers smpServers -> withUser $ \user -> withChatLock $ do withStore $ \st -> overwriteSMPServers st user smpServers + ChatConfig {defaultServers = InitialAgentServers {smp = defaultSMPServers}} <- asks config withAgent $ \a -> setSMPServers a (fromMaybe defaultSMPServers (nonEmpty smpServers)) pure CRCmdOk ChatHelp section -> pure $ CRChatHelp section @@ -759,7 +762,7 @@ processChatCommand = \case removeFile fsFilePath `E.catch` \(_ :: E.SomeException) -> removePathForcibly fsFilePath `E.catch` \(_ :: E.SomeException) -> pure () cancelFiles :: User -> [(Int64, ACIFileStatus)] -> m () - cancelFiles user files = forM_ files $ \ (fileId, AFS dir status) -> + cancelFiles user files = forM_ files $ \(fileId, AFS dir status) -> unless (ciFileEnded status) $ case dir of SMDSnd -> do diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index f43b42b449..9eca5c606a 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -33,7 +33,7 @@ import Simplex.Chat.Protocol import Simplex.Chat.Store (StoreError) import Simplex.Chat.Types import Simplex.Messaging.Agent (AgentClient) -import Simplex.Messaging.Agent.Env.SQLite (AgentConfig) +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, InitialAgentServers) import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore) import qualified Simplex.Messaging.Crypto as C @@ -57,6 +57,7 @@ data ChatConfig = ChatConfig { agentConfig :: AgentConfig, dbPoolSize :: Int, yesToMigrations :: Bool, + defaultServers :: InitialAgentServers, tbqSize :: Natural, fileChunkSize :: Integer, subscriptionConcurrency :: Int, diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index 4b1a6b8a4a..c1bfcc8bbd 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -1,9 +1,12 @@ {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} module Simplex.Chat.Terminal where import Control.Monad.Except +import qualified Data.List.NonEmpty as L +import Simplex.Chat (defaultChatConfig) import Simplex.Chat.Controller import Simplex.Chat.Core import Simplex.Chat.Help (chatWelcome) @@ -11,8 +14,24 @@ import Simplex.Chat.Options import Simplex.Chat.Terminal.Input import Simplex.Chat.Terminal.Notification import Simplex.Chat.Terminal.Output +import Simplex.Messaging.Agent.Env.SQLite (InitialAgentServers (..)) import Simplex.Messaging.Util (raceAny_) +terminalChatConfig :: ChatConfig +terminalChatConfig = + defaultChatConfig + { defaultServers = + InitialAgentServers + { smp = + L.fromList + [ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im", + "smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im", + "smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im" + ], + ntf = ["smp://ZH1Dkt2_EQRbxUUyjLlcUjg1KAhBrqfvE0xfn7Ki0Zg=@ntf1.simplex.im"] + } + } + simplexChatTerminal :: WithTerminal t => ChatConfig -> ChatOpts -> t -> IO () simplexChatTerminal cfg opts t = do sendToast <- initializeNotifications From 81ebf1b696ab112c139d1351fe09328c7365d171 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Wed, 11 May 2022 20:15:09 +0400 Subject: [PATCH 94/95] terminal: version 2.0.0 (#634) Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- README.md | 8 +++++--- package.yaml | 2 +- simplex-chat.cabal | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 234815ae48..06627e145a 100644 --- a/README.md +++ b/README.md @@ -111,12 +111,14 @@ See [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/master/p - ✅ End-to-end encryption using double-ratchet protocol with additional encryption layer. - ✅ Mobile apps v1 for Android and iOS. - ✅ Private instant notifications for Android using background service. -- ✅ Haskell chat bot templates +- ✅ Haskell chat bot templates. +- ✅ v2.0 - supporting images and files in mobile apps. +- 🏗 End-to-end encrypted audio and video calls via the mobile apps. +- 🏗 Automatic chat history deletion. - 🏗 Privacy preserving instant notifications for iOS using Apple Push Notification service (in progress). -- 🏗 Mobile app v2 - supporting files, images and groups etc. (in progress). - 🏗 Chat server and TypeScript client SDK to develop chat interfaces, integrations and chat bots (in progress). +- Groups support for mobile apps. - Chat database portability and encryption. -- End-to-end encrypted audio and video calls via the mobile apps. - Web widgets for custom interactivity in the chats. - SMP protocol improvements: - SMP queue redundancy and rotation. diff --git a/package.yaml b/package.yaml index 72ef61505d..6877733594 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 1.6.0 +version: 2.0.0 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 87d37efeff..abba53b214 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 1.6.0 +version: 2.0.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From 50f06a3c55f1baa6736511592a899eff61bbfa52 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 11 May 2022 17:20:22 +0100 Subject: [PATCH 95/95] blog: v2.0 images and files (#632) * blog: v2.0 images and files * update post * update Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> * update Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> --- blog/20220511-simplex-chat-v2-images-files.md | 35 +++++++++++++++++++ blog/README.md | 2 ++ 2 files changed, 37 insertions(+) create mode 100644 blog/20220511-simplex-chat-v2-images-files.md diff --git a/blog/20220511-simplex-chat-v2-images-files.md b/blog/20220511-simplex-chat-v2-images-files.md new file mode 100644 index 0000000000..0a0b9bfd6e --- /dev/null +++ b/blog/20220511-simplex-chat-v2-images-files.md @@ -0,0 +1,35 @@ +# SimpleX Chat v2.0 - sending images and files in mobile apps + +**Published:** May 11, 2022 + +## New in version 2.0 - sending images and files privately + +To send image and files SimpleX Chat uses privacy-preserving system components, both in iOS and Android apps. We do not ask for permission to access multiple or selected files, as, for example, Signal and Telegram do - it compromises either privacy or convenience. + +How does it work? The gallery and files are accessed from a system provided dialogue that runs in a separate process, and provides a temporary URI to access only one file selected by the user, only until the app is restarted. + +To make file and images work for mobile apps we made a breaking change in SimpleX Chat core. The current version can exchange files with the previous version 1.6 of the terminal app, but not with the version before that. + +In the mobile app, to send and receive files both devices must have version 2.0 installed - so please check it with your contacts. Receiving images works in the previous version, so even if your contacts did not yet upgrade the app, they should be able to receive the images. + +## The first messaging platform without user identifiers. + +To protect identities of users and their connections, SimpleX Chat has no user identifiers visible to the network – unlike any other messaging platform. + +Many people asked: _if SimpleX has no user identifiers, how can it deliver messages?_ + +To deliver mesages, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts. In the current version of the protocol each queue is used until the contact is deleted. Later this year we plan to add queue rotation to the client protocol, so that even conversations don't have long term identifiers visible to the network. This design prevents leaking any users metadata on the application level. + +You define which server(s) to use **to receive** the messages, your contacts – the servers you use **to send** the messages to them. It means that every conversation is likely to use two different servers - one for each message direction. + +Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**. + +Read more in [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md). + +## How to connect with your contacts in SimpleX Chat + +Once you install the app, you can connect to anybody: + +1. Create your local chat profile - it is not shared with SimpleX servers. It is local to your devices, and it will be shared with your contacts only when you connect. +2. To make a private connection, you need to create a one-time connection link or a QR code via the app. You can show the QR code to your contact in person or via a video call - this is the most secure way to create a connection - or you can share the link via any other channel. Only one user can connect via this link. +3. Once another user scans the QR code or opens the app via the link the connection will be created and you can send end-to-end encrypted messages privately, without anybody knowing you are connected. diff --git a/blog/README.md b/blog/README.md index 293dd5cf96..204ced415a 100644 --- a/blog/README.md +++ b/blog/README.md @@ -1,5 +1,7 @@ # Blog +May 11, 2022 [Sending images and files in mobile apps](https://github.com/simplex-chat/simplex-chat/blob/stable/blog/20220511-simplex-chat-v2-images-files) + Apr 04, 2022 [Instant notifications for SimpleX Chat mobile apps](https://github.com/simplex-chat/simplex-chat/blob/stable/blog/20220404-simplex-chat-instant-notifications.md) Mar 08, 2022 [Mobile apps for iOS and Android released!](https://github.com/simplex-chat/simplex-chat/blob/stable/blog/20220308-simplex-chat-mobile-apps.md)

qPS);D0pm zlRhu+pk~XKs^LZUU5jK1Rq)2Aa0qn36yUADr1viGwZJ8;jB9p zK-w(c7j@>NxqIuw)eqO0)B!5g7y_R<05z(BGG4agb2{xEFIk^Tw&_7AojheqIEl=d zgzn_rhh02vs|p>|s^*agDDY0BB$j3L6-q7H1m2o6Dl4Ou%%ch^w&P(61j$OCp=#e` zVGw6n=?r9O<~#`v0-hwL09q(qI-dm(mg)1vBcb=p%5+e~<#9X_+(x%`cuFvguS@YG zuQ;54CjUoctHdR7Ax4z3;q=ap%0xdn`&wE;lh36eiVY+?^H8w=97`Exv1?4Oy z>B&Td1UH{}HMJC;2KuJ|!ng}DRwten?3}B1!u06uu%u}=_l&$n#ja^+CIyC1*zZ1B z*oGw>=fF#MNPX}v-=DpPqp-5QebD~FTP((RPunxncpM_W2lz&5!{0q5E#GfnmG_}= zRbu6FU<(NWJc^U4-5ZF9ia`$u)Z-GOqcL&TWXgrqw1S_SOM}2zHgSaP%Vi&xargz zlrOLHShe%cM@Peaz4>-xM+<6yMKvB6AnuM`;mjNKZIy1#Z|~GOw&DU7Sl#RovpJ9O z2Cs*Le+jwgR@r z;^O0k0hG)iWC|5^k^DbxTttnQtaC#Y&kW4!TBSkfcxdaQTVo!DVmhR3cwUPitGqY& z+3~Fa!{7e5@g9$O-2IbMw?Vpj=SiOUZ;E|4C!202Pw!ZC4LI#UZAEALq1JC?(+NRl zmMG=U!TMqMYKe~tv&GWP=5aEN0zY4SnN8NMDwz;d^93|yUi2(v=nQ2Pv9$IDO}Q&} zb#{^bh@$G7Qjsgscq63kPJt9GKUA1>b`LZV@lZX0+;YR!h-l$Mn|`D@wedwwU4mBK zszpVUL5sGY?xk1DH-4)ZUfvvNj;}Lw!tnxIo-WlHUS3S<0vo{0xs?duf>le+)xEQO z;%(JP7zE|tpqDWqBj}NQ=V=LRyc63)Ue<8Os&L_mbW=}IngbT&&fMH$)|y43?z^qK zG;$HDpJ-g8nV-;HRzKz8XFErG*}Mq)m?;Yg#YA@x_9+%M~ zKT_X8uXZp-=CJ~-B6@+fPdKj(X*on%jak3MtQnf^Bauqw@J+2>N2td~*8ayjHfUq? z&;ch-s~uHtY+oPk*rl)(>YW(06cV%|d!a>iS0wof5l_o2#iNUSE0#=W6?D9Ypx$@) zJN@>Tyn#B?q^xNt(}eLcSEaks7s~%^T8Ds_=vJ}hak|yqxa@>HkjrG4I79}?9L9E$ ze9%kO+3&}X5oA*+x8oy5OOKsD?+5(?f6oz((3@1uvQhO~j)nWo-x% zE3kDaB7-36#*=L7C3}FZ3a~~!A~7nOrfrjV940gM09n)6NmR(vjim2WXX0%_d4SME zE~`VGfKB{RR8iy$bE9f#=X$|>T9+1DjJO0ec0fvs3*peTr+z@f1Tu$`iEry-#B|mH z6q+7;RxX^1#i?6~_}+=GsR&wT*-iwTrn@+}G(4a_x#+%A1j=0b6?Fn$43*9eEXlik zx)TCN!?D%}B;e0V!Mgz+Nm*y5F1fg4D1ZJ(?sTmZW=huiam~_J&@6faviHxj13Op0 zvJE&I{o`%Z3Z}5?TUXGav_9I@Kkja#rr!~wBt7x6V%X@%<(qDqtKmfKw^BD;K}kI@NLGo#?CzK{s6!FHAd z%-ir=bs#yF*ZK6d7BRe^B}MHIb}&k1rM`J(Hh{0S>lDlk3E(?95&*O@4hNzCsUuRL zo!9!tRo+(=CDT2uOtychU%cdV;or6?n^-!GO|TJhVm2e!mkAm9w|Qwb?Vy6`cyNTT z`Ne~asFy+Jat`Z*t4_|bOKCDW&WF5RHSV#l^);RPmf#iwCtAX=8ZTuz8st^ulJL-R zlJQqw5&QQsz6TXs9{vW*w09UjR5TX<-tRP~(i;uYS6k$kHk3xTy57BCgd>Xto%Gg= z42vCGb+VKtU4|FG7}~ExV$#`e<8Tp2C*g<@cwO?~sY<K4Ca#q2mOrT1=h zZx=FzR?M6U?24gq5m4U(mP|kFSk|A3`3o`@CCS+!jDf~Pd~E&mo)R;y2}sgG-|QLI zbof6St@BBAR346S6hfM?YRGX&$rLSKH$fP))Q;{~j@CTBQ8n*dom6{rT)P;GtZ}I# zU{B&ge=NI9OS!f?RF#=I+soh65D|#rh%m#$E0wtl>Vi&}sFIu^^IvG#-*cJ8bAF3W zp?-*O%$<{lueDzgEo(g2e<${~2cV7XY6{4qwA(!-z0ylN>LRo21h?&-*px%uN3{Ml zzi|+aNKXDWuJ%`gN#5Oc7M<^27Zj4$x`T|Nv~Bh&>#iywAA_N92`9^=3<6aE;Gl^f3<@4X|B;{;!V4n=Jab^^BYE72Q$iDp@i!*E1e zd=YJayaiex(bM~jQcNN5U1n3h;xB>SCuiIDzd8L~a+Oy$Wq*^&*wHPs<5os&axsO}H-2*br&w9EdA za$^&l-|^$zOnF?+zZKjOiXz#TIYu6JdH$7*$>6-Y4`sA9X`APWQU%Ub>y~t#xdds3 zrpI@*8E>f!ZfW1(yYobcw$gP9a}Xr2r^gAC9xhN4OfU6fx;O5oA%K$pxpeFE^Jxpn zGGD3mP~sxBkrvy*PT+1k*Ktc$Jzcw%@P$@C&EP>jYr3hU)bk=9&S4Mjb{!WsYH?Kf zq!bE>^bjv9%af6;w< zA3m>Z)Hs&KpVhqHI+kOm7`aEG3V4ptiAyJj)_7+#BEs$o!UM&(GQ>?W^&TT{|?Uj|ODxEh2-&U~Ob=UqB4|196Aq=;t*-4A7l1Er zXyM$)RY!l|1r%pG_4`NLVXb=?1vqb=sZpr@;mVSY&cyk#O9_=!kAOmJM3X8IgRt@# zto!slMN$dUm#FNub2t$ScJYlOCQVq_WSx9<+GQ0cANFj`6uj*Sa!KOsE;+z%iz9kb z4#{hE$1g?$|ApCFP7U{2B6`~|U#&tHiTQ*L+};fCf8;q2ybrl6fioo;)a2_GcHv|% zxKc$&Al_rpFGB%^Wf93Z`RN!ak#OGKknhSnLk1x)**X17lk&jetsT+aO`y{P)bA`Hyy z*!q)7|As5{1GU+11kgtv42&6_GLz$;qis9iEDdIB8bI zS?eO!k(KKlEAM8;MlB;^N~O$ynY>^jdCOTWZefQLjxc8EE^%&m36-DIS&4CqgkL=? zfw)vh56o1GU;i!E8*6tSsPvD0lT%l{dTUU!JYO1!cJB|!Z`rMeFDs<#S`d(xe1t4h zLBYuzl@h){UFAlp(ZrQ9z?p(=K6WplvcO{vbKS9AUH_p)9lfxx+Yc6%eq*N~!-V9d zmV}tpgsSo6gw#x*wezL<4>=!xYrlG_?dbO$X*IuH!y!<58XW7|X<6mi;n5&5d^1~(!=h*r#COMD7xd)QdrVe6+Vd1U}70o*_>JlArxfJHocV zwSvJ?-#dEc`-M1z!kr(ezcvP5iAB(falUn6QEM*MH!HOe<9u)I;P)d1O*cI%ma^R| z&fdJcUn+N}FIa-T|Bdv!L7ECCruR0y_x>$zspcL2VZ+NiE|eZ)smWJJA3yl{!#7^u z>iO6qb|Xt`ewvnv%qPA2$*fv@#!sEzYk#(Bcue=sJtdW?L;Flh)B5kkU+b;$N}0H~ z39H319pK9etNNY?!cLlsNBPfM0s{5Yer}s`FmF5bwGz-zQft8$Hq-dyWOKVm86OEg zC)W(exUN|tBZ0p`lHe`6O4N+cWKmo)`S zA>Q$yMhR7sBi5Zj`0k*SAN<1ChCN8QHFv`WJ%-pb;OeAO^ZwH3ZH=g?W==MqS)4gz zxNAMuIwD9eSyNS4pCvvKp!0}jtxf!H071ODA6HS6wUdYl93f|y93nC*`vyNJ!57D! zrJ9+x%wc_`Hu!Xr$M86*Ps3TM{n+D&fFqySgRu%V6<66;n=H3z zxn&=eZZ#D6Eh0r5725ZNNpR3`pdRbZhY>*9@`OWygxQTE%cj1a_{t*1hO<%I5^*Q% zyvE+hPf!2S=Le}jpSsF@n_5NlgN);XM2JaA!q!fX1O#8(5iGY>oaP1}_xF!9c96$I zxz_2J5=rZB5)O0e8*$SLzr`am!`cs@c1!addDZrAOKm|(ReH#X?Wfx#FZZb;Zw}1_ zwCbPXQ`;G&+G!V9LZE3x1pFV(sH7-o!sHn5CQQ1hSnfNXdo*qW@G&uKL-WWxn4&Qi zh+6(E=VzEo^4^-20LRE>nEa{GJXG=cSX9HA4FJJdIZF}d;jDB^L!V+c-BGNd zyH>YFLCvof=1=Tu%#Xs76&8}^*vRZe%-QMh6iFM+r3*>>WKp;@?!N#IZlU)z5)&XnLfJZk$Z zqiAeWCd40`?_SYV(6@;f+OUh8LcYzcN_sw8sMsZ+Iix#U1jGfKlWhyZO=RIeBP3hp zHA;plPc42Dat~Ar3LX#uk^8&1Fhg`xdEKcMnS$u?i%#r)>lNj~`6N1=t$g66`@~Ve z<`!OFv!&typ6*YdSinA?SqPKlpdZy7%`GB^61FZ0xc_LrOQd6#+YZD1LOE^aN%Mx~ z2A(`W!`sr!8b@L(3@zVOInR$sZtp5#u*xpW&{#L5`2mb}M-fwcJpMa$)b}foh-_(K z>eGDfFDXNXKe(K@SvyV#9h zszM0ajqXT5%)1Xk%)hU{qiUOeKiDfIk5&v)NPR>u-NWTYq(vL&rA^f$%Yw6jypzrY zv1B$0pKM2?p3l6r5;2ctHLuBZ+Je85akhY!?u*LqQ@?$d%8*bojK|XkDZ%)i=fDTg zP1K@q^i8&RO$Nfs#Qc0(DFB&ik9~>FKEp_vtNLc~;bkoE{|dgf9N}BTZRrxf?lyG| zUs{|-VV6^%R>eaBu^8$GKdtWBCj;iJ-O{a{%QgxfVQIe%D_Vin#}CE4u!5M4x6*00 z{(M*d>cbsXb;9!b{pBXq9IzlYi?DIhrEt@w4;@4I-I$GA_=Xn!mk(DMEc5w}&ChHG zM+>~^_F+B;w`aBv1yP#Z0leWZ%yGBhsRZ4!OO8vmsUxANla{u3q;fCJ5$xi8-7_&% zpN%KiIw<0Op7BeXTYpb6o}{o*WsLyrR(qN8fO}Fe*k|G1+CIRTSQYJU`3ifn*nI>-rGgMUoy&?kaQ~p|%uB)iNmMsgWuFaKg z0%X72iRzJSTc?In7jBn=))cIowv8D#vk97C5+Y5tV0X{LSFw$wY4oPZZ(d^`-ABS_ z8*BZfJJBxYTx#G262A?XLYH?XcDdz-PCTscWTl>rBYAN;-xE8GVnh2;cjC9J*HzPe zvq?82LQ!7>u&-}piu26LHuw0V%I*;*@>YnJ_D!tmvrSsAtk?#l?Rv4>tvgT5)~?V0 z&Jpx~$LeQiR^KmV(#V_hbN5-+W5-_oLEk9FFGY@aF9ypP1NuXNj1^Vit~;i<8LBd; zMaQQ_uvEmR>6_~mhigv$!y;Jrdd$~a?r^C|W-=Jp+GZULt46y?R6eg7X_VTklrYa# zKfrE*yqZaSGLxVeo{bC7{bz#I`AkI;^?`4A4cOz|5ZuljsWs+sCI!KST7+@sCb>hb zxQXudc+;d$&a$-)b(@4S!1uh>~Wd}%cH>I(A!y=y-IOxUvv@5#gkDn#l5`R*$i=RrcIt#7*dI>%Vv9iKwYa1z6T#GdW>4kA#?B2d3 zCew02fT<1MsV z4Um^@O)7>1UgkYCm!Ly_q|&d&SV^w-r>vje@5y}2kNUwBft(1CN6p_gpB(uYAh3fJ z#HU-6MzhE(CZ76DliPl^>6;RF)uiD42AZP!X4p!; z1M2r)Q7*2PW@dy9fMMqS%L~kJ@mUK7879W|klX(*kF0e74YE@cw9m#reP?9!jqmoKc;>=q@yRB#gC@Od-&7ty z>k*F)&&{CEGoTre;njY^So)ORiQd%JXo2p5gMK*g^E5w5P#paEtYw=0x)~yHVi3LM zVaMFP+1I8Oq4*0ay8RywfQ&NUT(^H_G}p963Mu}T_0$#V z;Dz20RgqyU$|eMZ-;Qs^{loLT{CRkxmBBc}Q-fRG-}-y?uQQOsEdHl!(q#*Kd{je^ zANniqd)NZ1t3;--EpdG>$S1;TC^vO&Uxlpre<`piZVrx^mYM^}GrosV}( zxp2U+MO8Jo^|zc%_{lNcofRfa;!?cMe){YWshui`GCMPw_gaDdJ~v!wS0~L;l`qTG zs56y<4q<}a0UeX%m6s&!kMPX<+UkXJvMu~o7cxYb35cxSxyw;-Z(xFl=Ta~H)V~$t zJ#b-H;QwXsL=Kujsl+GcG>kTnvO^vignLupU-TwC4`Q3K1@!bgX{yDvDWI>$=Rs3B z+nk0<`741*KM4gy4BgozI>tHGE! zU8u6i_^BnXGZ?L$-*IhJW)8n{f*xB@!qVp*6-jZ_pgXxQLMIcrBq3r%9bl9yD!|%| znG#@b#dk2d3=W+=1o`|q*!xI1QxfZKKDt6r<`N~}^*;?|6lE*`yx%zWa)F=Hia#e& z>M8~*<$DqX{VneX(vS2ekX@>M>zSwh*g?lss(JAM_w|q;4SUKL74~CO9&<0gqQTdF zutuTGF2ZU$%TxqZD5EM}?;Ekr`I@lMH(@3>D~mfc6_`0xAk-$rmD;f}1w;QEjL{+TVs!pA53sP-qrQ;Dx7w|`c-*`yM6QU`9srpgb1;X^UW~rlT;G4UCAH2bkt6q(76A`CneKmll|s*Z`f1_ zFw0tTh1J0cqq|=*f6#p0=~P*@XR{4>re{n^ZO279z$ZyYZXqg0=yC2e{dZ&>O69bZTH{)M`Y485ooT0j z>-=;Gd@p={zsk)W@8Lx^(gtiz|OvU4>vbhm(I_YAk4S7S`Q+H`K$H01?6 z6RT`3u8@cf!$T=oJH}leais z2xm$MC`ws1R*qaDVRfp~#w~)`^o9XqRz!`kt%(j+P~q*9QMC2Tx@ys83Dot$w$iOR1Xzfqpx{TKk&Nb$8q~_G^UD>zi2OJ_Cc&DFDTXFdx|Ge9 zk6Wv0wMNBx`o@#kR#C>4LP@VqQR|{LAm6eLRPm)}(u>WO-Au$yF8Kr(7F`MG-ns(R z9&?}Tsp9E0o%UAN!kt-AQs!wv9ce~HR|XLFoS4rqH3!wt zX6wphOu=%yh3*Pb)j(0W6@>dU>?;a3(4J)7?+x$I1N|GGSGyCk)R3!dx_SS~%mWO{ zA+-jPEBi#J*pc=wjo2NAD_^Oji}usvl!^>KY<;PT2X9yQi3efMrQ zfJCbu*y(4jprJnVsE|SZ%O;S{GJ@3feV_Al2n99-GXOVDc(@LfaU86svd=#ni%pL% zw|K!@C)8hhvP~Vjt`T;8i?Fs8NHfAvY-?LtVeDR_m!fF=uii~(G~0IrD_+`7t=8kP z8^;ibIUuwwg9@Cw0!x;ONA_KU9okOGcYc~b8qyYtH?V#Lmr)T~c9qG1ln1h~5kq8R zLS14ArStKzt(pQuWo&&Bdorx;(avM_+z**jCLuvJwGb{M|70$44eWVj+B>thj!vF= zziu8`&jfN%*};yZki`J~%|ILuTZH16Fn*ejw+774a{|$$OWS33l41#3DX4m%a*BjtoV|h!nP; zmi(!d&f8;Dmo!+-keu==)yI7$hLzp)o5Z??~$?^(Ar(rOWs_PJ4vZ$ zKZ->CqWPDZ8aXuF-*v7Rj(_?tcITlJLzb?^uyJ>DKb$;_-xm9kSv#L1t zoAFjW{j+QK2AYxIG}YJ=V*9n3b*`!Cm{k2#R zQNa3}F4s?4V|QuB{vj=ic<{3a^ikU`B4_)h@$lbK>}HJ+hnpkT%{o`AQtgNRXr;dv zn?1e3+q9I}w>(d(e+7h@oCA;eTD%lB7QlKnTy!Jd3q%s3DEpn7cZ|wFyaUU1`z{fo zDE2I^{AJ*A2+YWC!#?m3?4Fbwr*z#rVOsMfjj6d$+=01nAl#wrS>|J4*?if=@>g8J z*APnIkg`5CQoQi}5ro7V^IyGW=JCNVYgIq7@iryB$j2IXcs0zw9H>{iHdn%#-LO;38$a$4zQkNBNg*cEfs4`L? zox$#}`PaO{iW%1>|Hr&`q$|=>t~oa$Xa<04jZXlcGj?|jb$MGanFk)@;t@~N`d*kI z*`7O7>1mAuS`|>`esAcG`y?QT>K_yuV#9>h17s@d52Teg6#X(Nt5s8@5B)6&t96kw zHDwM~%gE{kc97HA-lxz7&bDFF;u7{iBlP#_4f4B`4ye_eEM{X|2BBh3L&nr*LTw9~s9)PIkK@I%wrQH>XO0*Nc$uepMdCzDVBk)9pS6=E zJJecc#pc}qNR&-)WaaJoMuGLr0i+X54HcV2+52*HRhAEPPCP!i3(imHyW!^@h=eK< z=i_=TdMnfRYq*7-*b{-PqUUgq>xB(geO$lPWyrrOE!4!~OJ@n&c4 z<+nr7%S(+@%=!tg_40ljc|W$D1guP`Z>fY7$2XH)jAi0XJjH6tlfo4D&X-}O zFNtEZdZ}`pm`^J@hy6)V$xL3jw5C8^gi~@OkMGe3l$7?B?>BCJq+xJeX)s zU!gpg5904V;yz^2!#7JgfLUyr(@8qHH<`{k8zvpfGV zs>ByLOW#20wQp~aPvqYa;dE*VRtMu*)GCbDCmqt*qE)7yjIn}!;#bRMXjS!J#yh6a zrv+xI_*A^~KWK^z!$h)GxIM}x5HTgD^4~+nwnOr9TrV90PVK)ZAEPglSHAg zYs0V-#4zlqH!o57SIcsll`9dZ@tZKTx`6ylghLbQgLd7cGQHTv7I~tq+Y&i~Z>`xa zx`^h7PG8RS>hc-0tsXYzQ_t|suVf=@aSN&Pzt_>053!Feu7XmAZz zoCHKD3%7+!{LD^Vp+~_Z*ePS0Ek6TxZETdh4sGR6n_x$w!xP>wo4VHSaFw-#g4XVp zyOfEXewK3$7;nn4e$D=O%Q&xs;GG$>d6wkuQk~GSY-4(eTH<7B!(@=eP`Z#kCyQHq zSauSKDQ^)JEc)VJea{+7We4GY%6MP;poikWxNdG-@b``)--4zp`;)9)*KgLoJ(xXXwPj2CMZZ?oyv<8B>V|T? z)<{TK>x%}j9^2-VV4`z-_m#lR$Gstp#}kS-lwWI^Qx26?1)x;3ZwoZA|J3qVY9^Ua zdyQH-SG~Dz3=u{_ft2hEsb^Gs@2CH9R>LN2(H5|5kbGf*N~;G{&JG=xO>=n7r9S3k zDR@V`zm&s?SSurs=buMIzMwJaqen3Z14r+{A1_Ce55keCUwAblGZpYtZK_ZIP4so} zb?d(Kp5I(j+u=izHq{XSg8t9Q6hRT`Qs8;P-obQXL&$uS{-(Jpdc@(O>!-D=H)yiD zi|IeIKGhCSE=}i4k#saJFFoXPBXXXfU39*fB2G`h+uhDVHN39RpA{GBKKOIz!8p^) z8)|N8P7mYx>A2~HrYgDk@?Bs(ON2K6nS+a)QWG8sDiEq2yofAvNjNRi8=9iFYccWK zSE4^y>P+C!j$$)8US(DX2Si_)*y5#RKPC}7%es;`g%dlp)>S1|539cQimR2*Xm(?L zl8p>=w*LVCgdx!7aj*xO190>?|{mFy=3sK8Qd;Dcas1$M7 z*ZOOYw*LR5h#CG>Jd!Vq3iudCoM}hzusFvhITgS9sGssY(WBC)@r6{SqezQKGCcH( z6zT)B`D4a+;pK0&-4H`kL-r;}g_r2+=npTxO6@68H>=6FrG(;B(7NXS;fV3C!ef8U ze|&L?ygK{n3-N8~0Hdl(#f~;6o7aIwQ#&war$=0^$NbAG%NGY*DIs58>d)xnor`vU z-X(GEuJaAXVc~A`7@wW~TiOz4*|%o5e=NWFXS>h*fu?ox&y-u`*Q*K*mJjlp(6{Eq zXAG>~+}oUf^R~Hy$&qj39ZpdhUUc>bIyoPy`X8h-m!SwHRD-DB4?}E#eA8x`V z6HCF_qGo>jHm&T@`K~7V`hKxPa`P+fRZ{ZPgHwSx4*dna2t#Z9-xwXl3 zd0bL7>8{-ps6S^gS+gp!C`1Jus@&kUtgbM7} zSxD1*jb6*?YboHz_8b4vaJ6mQ^=WXlX@$Js`O$-D0KW=u;S8$mjJkoDP-4T0Tw(}C zk-IpFp|-tPknVgUQ(n+s(eLt50(f8PHjt>qVB;%ElB0Af(Ul%RlF3c3M6O9McP^ps zrhI;W+z#J(7oO>L10mi#qc#Om z-dTSjN)!*%sY)V)t$t>Di<0)?M28{Uh(#!W-MR1b79_l`9ua+#X?JLi{)rkAXeCDj z<&~T{-*eWsf6tuWIpiy-6Xo^YS`z(AW`$YpLW58FC#Ov_5O`6C6Wpe$vg#d=_Tzq8MGo(Y%&b330F;o{R`arBX@?1? zmBd!k5CO(s#osaJW2w1inm70hmZ`G*$*GjZDJ~|+F#dDW2EY9D3 zTu;s=Y&v8#5N314B>|Dh%SmWL9ekhiQ713zcl~w$mGrQ9m;digpXNBB2H1U`rkbSe z?aPHju6i5j5j{4<7KS*vm$QW#c328bKNNF;XR;rio(34`PoutRIJP|O01n7gx$5X>0XQWQ zX_f4~P$)-qw0QYYQ(7k_TIi{|w(0F|xQ^V@=eKcB8$rBN|RFd5)eG7mJ# zbiGsUoaY(N+t=zN?vfM~NWByC=My!bUuAtjlk-55)zQwLn@!(D+C%CkIkczTc)-cy zV8_ymSBU=FGn(g<)M;JyRnD8+Lfp4|`&9 zAc=gNAGb+uqa=SDFkX2{SKey1Im(Pr+CZjnC;`vQJ-9^msNSZLj|x7MCL&7Ill@N~ zevAC{@#?ea@VwW*BG(w+3jYtUEWj$<5L`96>$_a_7{qe(sN(6H;wRU~UebKD+_`ax zD(tWwxUjU6<4Qy#Dc6dT)O9ByT76dQpC|F(xIg{Y)K}m_Kwq9ISY?jD-ky&ypw>qX zFxx!|mC`9wChPRVYF3}sKhB3XeL3^0+ne9saj})@mM$lf?Ny&v&8JP(8md`dpJ!va zI|FlanakbbB$Va$Lj6vKaKT#TL%k?X3AQ1G*>4&Ox0{F7s_Kf}rP@GDO(eilV$n?UAew2JIPA8($>IsQ-l_9-k7kc=4I) zKFyakr0yjqS48;pY)y>zqSSRl94h~9NR7Wcn#wF0zhX(1NUw+%AJmKS0bWgbHCS%$ zA7we;HJ^3}DSZ>kvV#;E@k^G>wabo4b!iRFj0#P_#vMFfuw{@^&jsDh)!G4DFpR?h^aP+4|}U#0Htno_+Yh@><}YU|W%t+6(hKzi{!o z7edLBpKiPkM{JU~^p3DZu@T2L#*tNtZnZ0aE}dHO_?Q&+nXv&&u!c#H7YK&8WP3;=nEN$OwD&m__sV zm2bJPzJ&kbqt}V4b^XGuF7fUkUgN)kW)5@eMZ;Zr)=oh2atWzlQw}uOf2+QtNqt-N ze9pqHU)^Ua!erV>(psIEl#m)#ML5|-y=f_3a1Az;$ zPg4;!sn}N|nk5*K*J1Q5{H5xxyPA?$ukhWyb-kO0R_zrHof6w9L>#wOrCp_qv`r1U z?EHo*eY9XB_0RJm2J_M@n>G-89nHyGdy=_rfEcp(b8>>u+NJX8e>9lOR2eRjMC;9n zxe57@2;px{|hbrKe zrYW4hoq$!5NY}#uGkQa(J*6Pv&ifuxloRsWbgHgVx$tax;G@BO^z*E1e4qYlnhKDI z+3u1;M=0%vxXvF?P=`dFzb~RaWWgrzs(H*OP-&6*zYZS0%_-6JX`r&Hl@}LU=t$B2 z|7fhvlU)?CFT5Grb9`I5gHSKnL6(k_VP5o%q_MRUcApttL` z`YK(k-wLG6tQU&5VH(67q;ev=T&hus9ghoW7c}e~?EkjHTr|_mm2Z!07haNGVIDlKokB5$c8$tK1@o~muy%iA^7WW9*2qQBWeM6Ox z;vHfAN}*+<#bOdKi6~6F@VQ9tK=(-RvM(OVjDHs}lJS-LB)Ho%e>~HCRlHT&v9jNO zO_1N8lhmEg7&H%G9()dMoT(d}a3|WqFaDt^9bvFV^7ZFT5?@$=ezq z1&%!wTKr+D8^~+4s@@~~bl^#UtS=p_+BmPm@)fEB;5@!+OX8%=fBs{G?nVLvB~i?2@g0$1Hy=V1W0^og9DgdS3swCaBNwNcFyS+e`j$ z4N_-1z8l1FET?|g z`=MkxsAOAqPXe=73w^aXs~>1}HpzC|m9!^0KR^K6RuMWlZS#wg^>Wb+?@d|M2vF3? z(DssF)CiZ>`NBd}`ii=#vWc)KTxhTmgI8uIqjFm*RDb-}%`k=>+*Bs#fnj*d zwJNk1;aW*uX8JpP&QCIav|!@F!z*~2iLI$@^*e-Kn{EFv&3(9?ss<7XEk+PJcE5&A zj;z4|ko&}*?ZX0y> zD9Q@THAj$cCd*e_j0$_1H!%2J@DfcYsE+x-RU;Qp>#+yKmx!bW>742&J(*bW8A%ys z8oDjqE>d3sj}mFP;3k$$#w z=gqdxluon|G9<%qc`yXMHuBe<|MAtdw`k9RG~N$+u@wWc_Sk<}J?^+VuP6Ip-*nHr z{l4*lkDhu_;D4VhvR_dX9eONZC-hoe2lK77?t zu(3_-;LsJH=i#yF>B9I}l(nv2U~sfefgZQneV6a$_1`p%R~C#Xa4P){rJ~uOuxu)e zD{0WnthinO&-hP)@|<9KH(Q`1CREWAxuAOsD+$AOM046)_Rj zlNkVKy7uVqSgT7Jc^HuXqj`Jct$5uRM39FsLM*40 zNtFpzhO{Q##eCWA(4sTLp1V7I9%^vJB=l-4DQtD7BP$5F9tsb^J9T?pCtoVD#Kc2E zSEvxtdeEsx942i3N0X-%*0<|hB!5Eo$>W`e{6lOSXTxpJzu3sMg0j!MOJ|VKrilY__Xs262=m8gEOYFqX5 zoo!aEf};PFl;Rh7SL*6=)*QQA>mJ+&y{(*h*}Wo2l@Ek%uq7$Z5`yxjqHNyCEz8$A zpJnC*aDH7t5lf&;YgVBqRf#f>#D$N%naGz}r-t_>=oFDky~BU-lP>DIGSEkVXID&{fjN6W zvM63viylFR?Xh(gc7fm>>i-I+kREHFiB!5*=0Tr%6eLsw>GuYcd)?@B28?B^+<%Sq zrC|ia`#)hHq%9|#cnQ5dJ~e_5eA2W)&G*ZFh={E;<>ADOOR&G|Hcw8YiFnLvTU^)@{-1t+sxicIZF$4p+Ksa_|9461foB^!AXH-jd9S>}OaO zotNC7X$0fHgj4IqgR38}uU&mRrBdO?Q%+kiScqe8S=AmC$ZKrPGka5&XZHKS7(atp znwPtQiTVq3>!9g!V;RG8UFxw8L|jyMTzOLF8w%Qufk@J(dGPznwHw@DkhxB?|2r(8 zkqz?Bp$1cWIx4dQ1=;uhe&)A+nlJ5K7o>~ML+DHZa!?tNzCNW&m<46I3a#imbk}=d zxeyxK`+t1BWmH?;7d{v$rO-mL7AwWw-JRlA9E!VpaVb#T-61$dgS!gO=h1 zd*}A|pEYYf%$nQ~azkz|37d2F*^lhHVYgFUIVlTt8C`}!a1jDtWKX68NWs1WMg$|* zU8!e+r|}+2Y@y9~_Fw9y?UvK3%-@|g39a70Sri5@@(hFnTCUzCU*!^~c)uMf+BT>T zb7fpTqs|*8M>S10Xh!V=*A7{u*RU8bFj`w1i5g}A6$2GvPA2-e>p?2J=)!-JcibQP znw?lS=gyApx7|)Ky`{9qXq!pcayc6yLw=!{049jP;jN_m~(Fj4a~`M!V8I=;#wt2i zB6_ZkMwU<^2mve6BI8)NM+C-L-7ti%)deg4N8~*X*6Y`%7BQ(9#79KIBg{ zNgkP6ryNRhqW}PCo}&%u?^@&XFGEsM!;c6xjIrz?Hm)vTcM4W! zPV+o-DKR_(;;!iP+VK;zguwnK=5K%{0wP8CvZk!cv3nazK0lzRmhsV2#{R8TZ*>YYbajX zdy!>q-F~eb)*rCy-hT%X{+EhW*430yt4M_6&wE;r z91qX)^->~5a*C)vPy{guL=O^2v!SAv>16yQ)2(Pvq1`cW!C#>t>tLR!q`pjt>uR@( z8YEv5YpE6T9!TL5YsXHCswy^YhQD1MumZwM3t>xq)Q|7dk%Y3!k^-C|Q18z{aYERM`iy`B*Q<|28!7r&RklfEp zA2eO~RY5CS?;a~3=y1K**3SL);xT9R+CTTquZ6`t11Hj=!gW0BlUq_4Nr&ao#^L4M zL9fn*s$Q2jI4Q8_?mtk;h7PBvTC-k!Lvc>D(S)^ItnQu`tBBrv%bBOVbLUkdI9Ya2 zGGd^djBToK*?I`qP4)Q23KH`X>Mu4&{{LTnRY zWi3S4Zm084uII4jl_{}_USO0VsVA_PmT@Y-5pvA!DDr0XX4omedqe1pT`S?`7e9B7 zwp+l>!3%MDAWHCNySM3?mGc(J1m#b*G|I!m%)c!y0q z?DO`58j%c1)hUBCViu2(-Gp5{Q(7J;cH3O$(4JqgHidLV4>D$utIIiY#5^`O!WdWd z`O~Zqw}sX69MBsY`wbZ_&-aLy{tcU6%8`MzD_PkUE;4t@|}a3P4%1O&=PYvK<1 zC-kM$ZO(aBedxTx@?MW?awy&sJs`8)ZA6tPNKS&zAYC35Ol459XcbDm0Q}EXS611t zSK$HVe+P#yLj|_7^-kVW_z^iVQdCqyVkR1mC7)tl=vI8@BUCMT4m?2EtBGJf-hWj% zWTdP#Rtp4rC}-5hM|IO9bd)~faU{(=4? zTG_GC>LKIvd9{*{RpXP}!3S&Q@6d(tc38NpmdH=mrm^vmR+Q5vt=u>XBA^Q;`732ve_qat%o;NOqMof;j}H_vRwj0?69_E2DDQs)I7|_P?LBeO zi)*{`dYV4HKh5Koct$N4i%_d24Wna00dR41{xOHuTPHRZG6LJ4yuVdz;*F$&rdeT` z5Js7I?yL53F+BE;79Au#koBFOv6gO^%Wo-x`+qSv8CykjJ)n6I^|ZvI$XzayZo>H! zOP&fy$w?8c?5eYzL^Vq^Z~NYQR|Kq40JEcUSHPF5_DG__9&KBKigMIId>V3g155Y2 zd(3o+?ry<@fM?H-E=5BDg09rX;Ioo!bmycZztL>8D>V4d%xYJewfpI%NM6Pwe1DTR z0Ji1lgDSJgRomgS!>?!(i*~Y*J=AJn0tpcC$@J__?OIy=BC_F{@ca3?XhW28{oa}W zxG?{34e>@)LBglG+<^C0W3m;gt_!z90uZj-E{IK5FBTuE^*$T*gW|v~U~y}2qo1qKlIM|r51=cMB+U){^|^bV36r-~1@eFg(q z758zjar|3I>*$h6huSjw0~=erCc=BoBcWp(`Zxc9(w&Wkd!2kXqu89SBYIsuy>0)r zDE@~0(%X8~t?T{fHb)gC8dQh)d;iwTaq1{}z<+&b1i zP8%Evl8l{Bq(*PZ(q-{1(fvJ&^C^4m=Q7Bobup<4VORRQlml8E zH(^CyE?KIH;iBLaX-e~wS%c{mvO1q`D*>w%|$7=YGRjwDT1Ki29X+$rx4;+3?Rj;Jg@H#Bi6at4< z>9&~Gj*W7elV^J@r(luUwC2gTVCt_@Y2Sn#<4>n(J4)#|xiqf|A4oAkr&v9#X7p-# zC6AR8v*)Z>>m5-Ra>NHlv+`chRu(fadbqA;2sYXfFEwD=t;AIGnpWv+W)kTLl&rgd zFtyGEK|XiiB$+QR5B?YRj*3YkCA-ZgzL-)ww2moOMkFii?8P zDMX5?e;2Y6UJ@lQQQ2Ip@gA#u_^$fG6LIb}9KtX|va93gp*^;00(h8EW0s>>;2@V! zzO&E*a$hqCbE^pX^_gnK6tqe|V@^Fn(fB*vVQkt+Tom@mK4lc^cX4iMbky`;igCLRj!b{O zW}^M$Zg2Hd`@C#NISVhekfFby1t)f=0&V4sj#l%bis-+^KpfzDCf#iKF}CRONjEqM zgl00>_pju-Ea?ITCbaBLG42adZPsqQeRDP?cQgP?E3Ot`Kd#;>X0uG`XFS~*5NIJysg4DakU zONj3mslSWj`NMrzXf%gKP_3S^slIdD^ZypKpC=L?$n^RKzBO_ya3|s1 zAv>Vy7qvB*f1I%G*0|Blv|GE;)5>wd5w;s3Ho|ALf<;jN<(6QJBV+UY@grSNK5FUh z<2Q8HfaSmCM#uGtOMkGLQ_r_sM9M3npQY)Lp~4!!Fi|SJb-FD{zd?VBwLkU)=mCph zTCFoEfRTt zxN_2ybWv_z{S=R2ZOs)P@4icde1=T8UI8$mps|Ut4{29dxZ-i*g;jpNYKB5Lo&Bzt z&ir%61=d322WoecGq4+}(lx~R=Y@@(@2_VbJm7^*Pv#LIzn7v)FkRu&{LLd{3NK&S zQn<(?4_5Az*lzd6)4A*VDeD$_PZ*n^>%n^$iggVrRTCJG$Zx3s(-qoZ<}(6}lRwt| z3Pwj8$YXyhw$0gG?sW4`9-n0c#6)Yr62kXGXO?}BS3h9O`+n?81 zFME*)B4Pz%~QgD~O{J1_5PaZh%-0dXcKv@j$f$ zspA><+R6V3C|CT?X;?`si?#tra*z!wQqpn0^s#n;fOK1)a4-2q9-DwrKfV-LZrCPr z8idJijt^JuXgq{TUp_w=@Uta70|GY1%CTWMfZ1^l0&Nm7F<5G?{eYYlDrR=ILzX3) ziAd1B@EP{+elO{gMjJYa$qsVlaz7(|b(SaK=h-W!RV#+8keeTvt8l@X{$qv6M1hhJ zJ!p>fOtx$O;RE2_X%E}|#u31G$yFvzl-d4^y>6;PT@r-!olpf=1TaWW?nGOS-H9`^=U0Wi(ol-UI7#FH4l#uPaVc3W&g;Kuom=P%Fv5l$$Nq9 zG4cyg=J72F?ad*+mHSAMv7CB*IPvAVL(G>^_|YHLJ%P)Fb)>GpjVFcICWt1cE;~2n zoWf56!g~dvqdkWAUi*B8O%7k0i*i@!C&_s*p1@mH{|kCqd|i9(A;krGQP`1rmf|!%_}3i&&sNeU#dO#wY#7c ze!vby76=vDgsG~*Fq97CYw?r_*_7Y~g4d-pqq3AAJ2th*CXym5(u5WbI~>g<#Y_oL zd~guWwlD|-^K9uq(1)kAT7Bd9H!$4O0r;AIYea4KX{~MrC7n-3HR9){i+Um8^892! z+HC|soKWf#?JsyuFr^`2dsONX_9_xS1 z_dlBrAMCGOMNEF&M>uss4%m2GVKEfr)fSm8zLUuT5(m%rhx%J1{^mCc!;YutjrD)( znL4`cP+CV@Z}RBK)*@I&HUaactn5?bZ zljU>5@M*sI+rCyov}mgM6vkY=uIV#AaHD(k+8gMIsM)>W{wu>%N4tKStR^TEsa%Vs zx)$M-v!!uOoVJwozVu5Nf0HX^Q3I}0=nrLFwbXz)f;S@;VdUX67)Tg+ z>cD_RuDPze>H>^A0RL;YlyZcj=(Sx{*g-I_8WWQuomTRM3Th^Z76hUNNS#3NN@>!D zliOB0V)q4eCI**;ha}rnr_QpPolqTe6gbEm-x3D9?QN3@8r8VloI`Vqw^7?8My7Y-THZ7T;@yI{p&?(pY@-1AM z2X*2rh%-QE>`4WFxDHTa|DT{;^59?BjPiwPAs&8pxXu2)_nPpXlrr!uqxi%9&?%aK zCB}CZ;BvW{9N2~+^D<3vGeXg-oNLkX3MIE($$Z8Q}qLCP1NvwRu7XJfL26!!n^Znle zF5unneHNoF&7o6;(CIz<=rsy_(QB|aHt#}Rbr-&Xzu5gIyIt{pX3 zGLBT`S2TqRmay0@*I4Y}B*twnf@z-2dqi~7d?D!u{#A4CBEn}8DxY}hV;5NhR9R^U zbOiS#LyOG1_frBY{&{LOb_pl!ZB~ft=kcD8hBrqWs;H@>WPw7Q-d2+@N;0P?%Vg_W z$^>BybGZ%Xe0g6t)@+8GL&3oHhj2*1V2e@`b$hGnH!4LC2pO#*@nD-xRY92zTIuW)cKMwES=cZpmB_VGtpTOa%i41{HceFJOjE$>@q} znG{=;6k80C@$EE}=Z`dEl47d2fA5(&^CS4%iI%));D;11fFZRHWRm@{=@)B1UN`{g zJ}2$}@_7V1(7|R4_TEDw^D8BSh7&%BHP>~t!$mhepaNi-WN6k19-DHH*0Lg3rm zh~wV)=Yb`WrKkth7{E^ckBZ1+fR>Jh9Lmm;%aexe!Q~?ytFR|#xLEHryo5tyqwG!aRjqzgojRvM#iCTs(aoInZK5 zxRHr;!w6_rR$9xY*S$GoOd9ZEEg|9DOJ}@Vrc_;`D@49$5|?6yf`~`=fR#J-i}PG>U-CViYlGVVKpMwWG1-T$@C&xAz#K%{oNZi6 z{KETR7enCkvWW7nu~pX5Q3SC$xAwiuZCbr!pY?>1SVq%Z!I=UM&U zgLl8%)RY1wnj%Z(qD%+Q2~WqmmrgrF-SNh610M<+ zIF~-8N^(WZ@b>FUj*Pe6Lw8}-9d1Ye96*1<_TJ`X9?|(SbvyI_9SQf4?9tY*25cjb zV?B7FrCOUW!yjb38&8yLs~l@Z6#-fLCbLTCs+-+vm;+6u0+YL>N0M2T!zy~4LWsJ; z`d~;~OVnfuQ(JVIyuEf&im$;TT}qdc!4;{XYC~a?65Qdig&H}IQ<(_!g>4Wh8>xU0 zy+E>zmN(Lo2ZV?FFT69+O#bnQ0Y(i9#|vzZ7vcc0tL9qH(N6>-$14m9M}LW9qpk!Z z1Z7fBr^pkI8uW9>LSLRgA4-JkFY;bTiaNM$=l}&7lryA!}7cFN3IYjs@D)PVO zBEX+l5MG-VUW16Gk)D&cU`?Vj(uUHA4W{I^l$F*bN8 zeF`>FPOjr>kddirKGyXTEYPTa~WJEnh2(_#h z&6pz<73))nuisUfvq%Rw+0Z#a99*K)iB|0v6Q1&FOAd9^0Gd< z(BBgBt?0l*wE0jV9vtXQ2lPSc&HCs$Z>3Qcqpiu|y5pOkX(0VF$}Vn(d@cUr7?Sc_ ze>gy}kM%>;v~jZWulp}Rn~kG5Qp6DUH@)<$S(-o1k4&ovo!AGN^D9(RBbuFao@+xt zOY?oT&2(-z)4T<38I>=~0EsC@G2z3+F_AuVJumkj+ssDcUQemF+kYVZfQoYsLt$qj zC54M3(=1ev_|B)ed|iY3*~m?j`mH1teX;65jr%J9`!mj~O>Xnu9%$V0FWc0q^+O0P z?%XfeUh!iR>x9!ft}dP|(g60YjD#5NGmRF}pX;_>Itgk1kZYZ>l(OgC><%jQ!C)G0 z>MaFw%rt=_yp(r2bqy7`vA0nTdf$i188#S`;Z13?N=XAoRjS4@&w}+^Xnxr5M7* zk?&Dj3NCet{L1_Qy9@-vX2c8QWKK~+MMI(u&6XSfTrcU;fSu=?Hs>gqd8fW`HF3^S z=O3UWDGd zKw&1A{#L0vB{CWRWo3bG^6Tl4IwtK;Q9L+KifY{AIZSRcbH|$rqJ5jJF+<%|LmjS& zV=g?gj4Hye#&@Guj8l~yr0N{zS$NnZp{@69x%XcJB;r!y<|fbgBLYhDj^mZAD%hkI zZp^Z9VzrW9mhts79=9u3T(y=U63Xo(&;cQ~F~ym_+%BGX6?LrczW7kQf#sl{8i4oE zK;3ga$SfYsZ#)qimtu2d+|J;1_;1H@2VE;B7xNq<_C+%Tn)HT&aJ!6`R!cm;p+g+t=!}an~uJ7YA z>QCY`$(`-|m|Z`)N!!KME@vH;*=l>kx^lLUqQTdy^w<8E`#tmzI@@W-aOMP6Vg9|P z>K!P*oIaZN>t(6@>*iCA%Wi+Jqv6ZvT!izHK>Xa~6V;Sep!6w!?a@_mikK?+t-zk? z?Ye8N@6ioRaL)WI58QwIm-&uwO&Y<)@paDw_<##G-t!s2dROL(Aq<{VoFlPq=Uwz{ zY7$?+Xl>ANHtW6jkCjcL%t%7cQc)=W${g&NZYIlNHFL1x8xPj1l+}-$82qIuD-gJ< z1dl!#Ei1RwNNQSC<|mZG1HD6u1;QEE#-tBq`vVz7kBlB-h7=7#FTnbX}r#i%G&sn}ROz7JAeNxw)|4n)R*em~=U4!e~oJE4@G6+z!gOu6v-Y@6_g^F-N zbGIk$&T_VJ*TBLHrQ6H24N3cOp`fj)W&X6)#^``7HhfRZT=V0Z(U1B9Tvhg?c@`S! zhH`G>l~ieh$?YEV5v(09`)_jvGG-gI1iM$yjUCQKBfOXPdopT{p2Gro3TWc zO|vQhzd8(_%vdgFcKcO}+L+a^U|Eiip|N6rQT5Bsnx4Q#1fHltr~SnC1YKsvd&|M0 zaEH=9h`F5|%VD?oneQQs%YF~bL;0Yu>g!JHi18VX5v_O&?m|Q4woUrMaDxm3$NS8x z;~uWYCC087n~0v=9_E!B;S4baSH=-EN2OXWwbvI!F-GG|d|Fp*99KN>l;FIWT4Q|s zz{J1h{%<(Ox@Rb7f0Ql^jmaq6RxvIw?0*&AB#mln=!Aa1d-wwbH~W`8Fyt{aX&b0( z?cpdzYAbB3*pt?j>m*&5Q!E*20*o~=hAw~mmLng3Us{PH1+CCujN_EKGWZGxzLz(+ zhfinu_xL+pSbW6ph!=79z3@|ydBgbFMtm(FB@GJgDCvSu}7U-aLQNz&6HKM4mZ~moRN=FB>E?o z26m^^uCLrkKKX^ar2L88QaRGT1L?)pIKb|h>`Pz*yo6{@XbV+yk;}UOfV1l5c z6tJxHET7w^<1vh^8PfY&mD^}LfQ$-Ml7UNj5TKRqQx*sA;-PThiNC=9`LFwCYMQj% z51Em)InNHbY(~^iPpG?Rae$sa2}!=+RJt%??H6y=*Jb75&war_Y96HYgQ4eP`A%~&XLpNI&pJmO|#1Xx%WMg<}N zpIThFphjGZO8xhk21pqXM7U9(C59(_f+u|HO;C%OJyi>)QcLW7j>*D@&kG_sJZ>wf zO)KHe4)gZ;gNC+^GQdvF>5CV%aK>aVg=$X|twtUmh}l1=!uB6p(Wu>0SUocCwq#<} zro?gH!lYs3pa$Wkv^N^$DVt<%29S=GI{ci7JkVIZds_Co;dUdk5cX0I_F_#zc54Hs zL5o6NLv{MsOhsZhltrBuzC4Z9q$)prd!td-nv+?(y+%wY6*cY>qS~t01I1R&c@8Z| zK@KlKFpL+%u`dJ`dwgmc+I*%<-&cL$U-W>O%o(-3H2HvR=u?O3yIgGG@M&lyB^#99 z-^X)o7W=Z!3ROlba)hg>^YtKfg~i&y!7%$acdw6Y013_o zK%7f3j?{JG0}`_it~Wf#CUz!76Mg!ipL)9{S*0dfyJnlF^!@7XX>l0+a7=3|%)M}` zaRQ79Jm3y>!Cvy=Y5RhE&2bz;u!#+LG%19x6jJE!T*mL?R)KoMR#g z%88>eLK)_5-q|0PSQ&CbF1;!}2q9q1$>Fr=u4uX-6bEtlfw0+;0KJi!2`vaY2nZ@* zxI_&HqU=2YSYtD)rP}2=4IK-s<~+3K+zbVzec;!px44`o%99?A{$vJ6uMQdw`j=Ut zy?=qi&5dk^vOwQ#LB)6yYT%%|G*>-i=;>r`zPn?vW$#J+OG!9Z(Y;sQ7I6b&M+zDQ zQIv%oIc1om|I;3wzCHhmpu>t0yrp~d9;i^$oW-N0Of&rsyL!`gsCVGLW*N6zJU**5IA9lM?M4rK@G`DHLhw{(8 zf+|Gz1JQJ*-`!D(+>@f7-VXhC%h*&!wmpOOq1=NIPb+81fAM?8d%-V(y+_d?Ff2~w znG~7yNerAF0O|sXhJaz}tq1b{e`TlZ{=VM3M->Ixi%Bf}W)XRMyeE@byKKz4r||z< zeBk|fK{uoI_<(7Bz<0T3TyTQ;EgGINH9OUlIEdIyIpBgryPjTH!<;sYu=4Vqr$7J2 zRrM2oe=Mfut25m5$G1~4M`y<$o_l80p0R!HEDB57#xZuS8r*PhksUHOn(g|c^q_by z)3a$E)_hAf5PGxr$ixe)^fOL*uAJ!M+&%YU<%4qaL5zfbdHpaq*hz=Ph*Nt8yMk9v z6rnT6(-+!3ONYITUc}J3Kb%_(DC-3nl$x%dn)} zV(~#-S4v5Av>=5NTFif>jGzMG9RKG+4x&Yu$8i8jNvM?_9kFncjf*JCFXhWvwffOZ z>dsdWcjb5}{LpfV)_zdP`q=_?mmz*wD zNSzuc0~>|%XCp6ug$q6~sRkFufz~C0&-{n0zT9o0d+sHt#$;Vhu9E{_mQ5-w>GW@?DPjL`Lm1C zJ!otz+G1(K*CtI*A0{sm)x96U+GuXkv?(M-lkjDrmhim?pw!(M;~kE}Q%OWzT|Y$@ z$pJtHuHlipU~lgk|=;{`Di25utl^biGRk^?XU;gE~JT@3~?P!40NCx_A3w{@V1Zy%ryND|B0vf~mb zBNc#9@W@A7k=-gkt<#xIzvZMm1uWbApfgu%F#`S`6IUdc&65u;jlYkV9Ousu{JbKq zm7?P0ly#GpmuPY-ZT3*D1L6RVkso~8N4C<320G#QQG4yKo!_+e9!Rhs!2@&VTdF z?)8nGJUdxaz*j!!{!)i$C1(IZn+3xWvgM{(38l|k&v zAY|$wac;~4M^5$cv|B>xGLko7{F}I)LlTE-9kMuqKMCOcsCz7=Q$qm{1z9Ij`943o zxW89})*|2EC)upLH+#j;z5En*4qq06_g<(PTnNGTPFnyP+b2^V3)J9fk|NTyBV$fW zkG+$-d{Kv%`+#1Sr?eEHVrGKd1SI%?eHI8rZua^SZ=;LM{b$RUn%F-&f+w-kX+77! zvGf2mkRm!=<*l6)XT#)hinH~~t@*BXwoQ1bJU5yY$_sI{LUW5ha>*f|qwJ#9aT&Fx z2Vx4EO)XYTiN=<3T`krPcymF6|HLjx;=s6~$sNRwB#y)WKT(XP*^WXEQ=@U&A;p|9 zSg~?&zB6F#R5>2}&B$(9EeWP#r{3QG`F|(_qnQf`4Ml;Q`p0*h;WX*teoVdBoQw_c z^kfPI1 zfq1H}eKyKI&Qe2G|0MtEQxM9Gv;36jDr_-LUq_X1@>%SJR17vf$JHc9DUTkBO^^J+ zQG2@^y5^6y`7faG0$R_Br+LQPN3gxwIkf^D_3`BUed1*x)R|shw84Pj|w>eJpF92)t;#p zJTiL{NToounE)zIj%QQ#GvKVB7jZcA)+qMJPK=MZMSrS+W)WQ6nAxqNb$$2<*49wZ zc@0Mq2ZkK+!6<4>@-o|l-)g2tpN^^+Z5@u?mJ5lLc|EJj=o&9KGMtLMZ~xA(e~IU$ zp&$F80zxYL7oETYffl_0C|4o_6CZ78iRQZk*$=FF>=Tjehr-1xU!S;&{~$=yTNvqB z4Eb?agzZZkTvIWzk;~?S+3`?bO4otjds6TbyVykA4WY~I^Q^p~hmHa^y$k#9AKeEX zFuGMjJE|u=4cxhEpXCwv$R>bx6@>CavZG4J@(mMwHbLZyP3#c1D|FD9QN~ffpwbu1 zEbV4d=RmB~TDC7}FFIH|c8>^j1iENkkoZ#1#3dz4M7sVfT=L$>b^Gyk-x2hj!61No zEg{OWK$i88n}%FIs07VKg^O>~5U|Z0MmYQHtaj;?ljW5X{k+gl;WH39w zXx7l!P?x9xyo1%wm(9Y#H94|kP+7n_FZcrsG#P~-L{dy;dtE-#Yv2r4`)AdfA;xri zE#&W2OJNdDABu!4iLUTc)v|hdX$8?nbQvi-M6jls*T4rZP|Z)e;<&swF3^D$1)x0p zGzSI?<9|QEgIC|(S`Oc2>1_2J?r1-Dwd%h$idVaGGqO1(cBj)p_c5=i-jB~mpX^B= z+q>~&tU8T74GiqsP6%J>V9Qk&jT8NvbtQ;6B$HyxHw(b_|3fAP$t?oQ@mlrg{Af6o zaV2)ciD59Yvt%*6&s=o~FjhKCAGc@GC$5B7A09VyIi|U%L-Cb|2JnIlb%ShV$p2+# zAk-k+AYl3r07!*@mXZg>Ron)xPK_+plgsR zE?`9!<7k-`mSw#vuzG@kh9Sfbdb9F)o6?%LBc&86ncYIwM>Prb;~1~b%AXk@#0(mQ z-Iu#mxbtf+*-CHVN4OW!x3EQ;-L(7>?>*}#vG-~D(~pLeegfMU87q(FIzOg*GAEa; zT@L#n!B<`r@frPEaCw|;w8W9HL~U#rtX{kbRS|8Oc?bJgK&+1w@MQMU3Lt$-x zIT+O>)p`v$NsMsjP>dKt>^@uvl)*t{l#(X-BB~IXDs~Uf9eUkj+%XR!e?vGHnRQh4 zh4%xa;8wq)U@vQItbOY%_yG?EU=y%vl1Wi$tbFmWQe8?Ka3Jb82OeqB`Nk&yC!gsG zYGll9{v-rl+nTm!O;SqLh(<|rR-W|81{-QQ1_*ERAqXHo_(CIBle2eYx7jHJ1hax; z7ti{KZ=3?HA)5!;dykF39%;2&798CnmbWm_?J0mj%ik;wdH~&i1@B^R9n;IFo}5dr zZoDA`r~09Y^9LRCJpaM&6e#U*>@PTAxc21QqrXWEl=%+_;BV|(6KY>I9=9gY8%x=1 z^ux+IP7tLJ-}KogWM|i}^9j7B(;%?sCxJ=XXfLCLDLe1lKvZagaUq~04)F5l-;94A zzJoio>5&HPkW(0r1e=ksQSRYjM7F=jNsM~`p7)al^$Jb{^@8*Wxh}O zdhKh2q3V3COjR+IhM3gmiXz4l3Zmv1hay1P-oWVaqXk7Xq+w)GD={=%4N%l77Z8%8 z^DC4eKZ_lAK?XO4ew0>7;sxnHhpRnZaR@IxKHH2T5fKdW)pJ9pxl z1Qcg|&+_)cniNy;U-C61f_|hR!g0Fu#;IkM&(fCKkMgSd1s_%Wx~LuC2KMrZ?v&lYf39?%AcVIZ#r>X9nMzvYO|Z29o79DW=w_sBUG6MqCWek#QOfg+zb?&1s2 zHjpyndoHtnlLD5+Qo-BPQ{BCmmwV9G<1Q=p2kTJFnLLS6xF5JCQ0>0ezXOZt#+c%8 zV1KQ0bm#b%Rb#3_(Eawu9Vrj2;n((H@287r@r;#@%^XfFFBoD!2ds9bnpMu$d*Gk) z(Prh?Gxy%_KM({^@<5y;$j&#HHz{qTYXg=`lG5DNe&RJmVPa5nQY8^lqoHo&Dq~Q} zv!e{>$W>Ws(E=LwB>C9T_2`AV$Yw?=ZW`X^DwH4XC?wHQp)#o&YN+tfvALRGB9lM8 zEBUPdzCt&8A(~wNH75nG0%q|wgznmL=ZIOU+8(+~stDQrcnS2zg+Fbo1c0UXa#-NM z_>EaTeIxXK>m?nyU#yVHAmBO9S=Uo|u}geceCTkIHy>qV&zf@mcArcydgo7WM1kgl z&aM|mm-1aO?{wCbO_sk^_deZI-E`?X^|C`de1HD_&6I8F#m;MMC-fl^3djQ2th9M= zPJ#g=Pr;Kyfl2e~@V33!-f7`&Lk>;ncpk;NXou7|pp)dgr}A2_J8+A4H*9L9O^Dg6 zQ2z~8nsg!HB>wPZekhg%$lA@CEnJ(jbyDJ7Z(2N2Js+Q?E_LR}xKvgY)TgS{6~E*N zv9f!re`&PSezg%N@;5e7=@UCjCg!_26mAeYx0ZBr<14M{EqlSIGR?iB@z`aim$j>v z)y=PfrA}}XF(nQSV~IZF9^dYY>>5+JwY%&$9G*G$E%)6oani07g!wByMUmPm5___`8TJt?pEx~6t) z#;6Pvd-O1Jd#$>veuF59St-`OJJ)i+1OyZx@IWX$^wIa6G|R)TfJYru>%v#~->&;V zRN!(3a|X8w8H>yxW4c@Z=X#0fR+i2_z0QjP&z)Pnd{aFf*T&;qP3Z!#T8a4a3D1m5 z;x$G!x6O1;aL~*?^U4 z_{1F2qFcX}T|dU5394-za_CsaC?&4wVBQ>83bYB%epTM@Dt~*DEQZnu=+ZyUd;$sV^^wl9CRe zZLetSPcz%SZxcq=jhpWjTM)Ku`d;Oj^G(t@2M0-AzH}eTOSPRQ84c%~ocP!j%}xUA z5eX;u<5aM2EJfG9z~F+X50*jL<~O$aeqPU32ih4$b#jMxW*+W3X}7kBmaa_l`w&cs z**-e^I$#RHlp7Ypr{^2(u92F*w11$hmT&M~(-AxUmML1jMC|XpayJf+zs>gEnQQs7 zyi)HwD)ZIl_Q7vy$ZR}s&mbetEenv$XH*r#AeALt4n(mtxhT-mMoM$ zHRf7Bia+Jm!=Kqsndeidhup+!Y^*!G>(*!!bqFU#f3 z)~T@K$?i9Chp{S9sb9EpmwRn`3NgjX9?9wxAt;5o<0O{!eH8qe=eZuK#UGe!6(*Ee z;>&#&PkC}kv#Jzl>`USFEB3cj)twITxtZ(?aFNXi@yr)W*tp_MmD#5|Tp3?D+$$G- zp0O59yS!&_WQ{nx^GMm)tnh}(8e_K4R=y8ibo*xn{oypwA_+0{`gp*kdrtV^W{hpL ztrL;|duKq^m1%&iJtADp4U6dNZNB>KA1+r9zvW|5uJYRjBebWz{MUZ7#xF%qR8ftt z*uJy*Vxh0!UzIL8IC!hY)P2eK>w7ry0wX0%zcvOLixrp6EI1HX#KSWiXF zNL&`*bC3y;$wW-a&J(w~X(LQ0|noxaHlB3g{)UQJ;gT$AyG zWf>7apYV02%4cd-bLp;3{BJvND#nX8l=tUk%=pFl?ooxHQ^yB|kUa1Vvj0g>G*+JF zm3XfZ_|q#Gk?13g^6FR4WT+$O&#kAoB3zeyWS?G5!4AUx|MWyM!6U9X$b`iT7yK{T z3Q_*5!m_yXfAp^m=v{2!9YlVGPC*V*dj(|=ej^6&1<3ftij)1fpX0FdID3MV z5gEmMD=?3x#A2O6UlBu}$EUa}&N+D46{l*x`4zwaIa%S%W&4$|Jv{U2ZR{e6FCP)g z_V8Q~5(7C%E!8G0@?FSlfS6Rdo80$aMvZaKsbW#9Eh9r;t9`zl0ED8!fQc?BU&-9C zRzcHMF%(L;Eg7q0RMtwxddyLpM&!cFZ6ueJgSMKS#iOM1E=4<2BO<3}LwioOLr-2< zw*rN6BrbDd-)w~>m!#85V(;r=mRT@!rCP&Vmqq)sM}1b^rJ0zF2rGPLjdfjtw&4to zUN|4j(u&nsG(VVrJbd^yHx>53^r*nqzG-y!MBr*9Hp--0Ue82Zm&Gzz&m=AV4RF)m z&}^c8@(Df6GNdm@VH~-kuR4c``St(q)R9-?zwWD<0QT4WQC9TWgW0R=nB38rkY7dy zn`kSMD<^7LT1G|zkCH0^TlK%E{eNuKA^)!}3t0m9|7=RZ09QsX87|J+(e2Hbrg1~f7cRpIqZJw5SCDVloA|j zG}C`4dk_5PQRKnEZ7>IA8M9_T@UFh-so7}ON=sK4v@z&}r|YI1!#F?Z)|APq0MoX7 zYB7zPN>fve9tA*xAK~sp=CSxx$rlxq@=8YmH?2gH#5OU=bd5!^tUMb7yAKrV_&Rr% zVpNS*dq0#9M_o%vy+I>(3s<3-KW%4EO7G|LwiP}<(R^r>w7Fhju_1gdX9^Z}^}udg zpYPPN-mT@JqJavNa7hrdbRC}0Zx|D_{=lZv8edF2a=A$T_rlYANrU*wbB(}zX3t_R z?zTqsgPF2l!zrl<>ZXjv}D${$gJmFWc=TixOyR!)~-J)0j$(A zmP!*thp(XK(g#!{lT+o?lEO+a2gc=_8Dv2HRBud`lW~)9sNXLnR#sH1-_0>LwET9g zmJdlzO zRCns$-`JRhMW4Goa^vRA8|a&tUq5{6+aJ%*8{d1kyCPjXajkUA>l=s8Efi{6U!EM? zwBgGBlK*aeciXy`DXp{w``#FuIZ;ACo0B9 z{P5w7w&IErltv?-QPj6b^P~_07ofDXWKG9nv}B&`f)ZnxgM?F4GysDmYk@XQ3gj}7 z0b>wyUdVOD5T9p412zDS@nWb64w((5fwK{Bf@EQcgdetKS^#5MA1~$>8^E^;E@9S; z4VoGTKLIB5|E~Z0K?A{4e4qoSG(eUpAs!(y!THF%)+|_v)FL4wB8h5->~suKIIap&n1d2!5N1YY*|^;SSRIT zz7R*~VG&6r#6UfawdKqK61EMQ!ndjDFk8hfk!CaQq}{$mr*_t(M>qIy9`66C|LdH& zT@MEWmkXaBzA$m*^L57x&sY6DBZvRfk@kxhzuS3g?YdOI=G#P2Hp>3}L@d!xMIz|! zGXz{%Dhuo805Ad~^QCB-fzcT)2Ac zkrADjGq-(t;^4+=hfg!806XHP9A@bQeV=Q~ik6=@PRhT^juC9_Ilv!p-hZz6@>yQs zbbVassF+X`e*VUAA*ZEmo>&3$mkv9|-Cg}b= z@nHNLTN%@*lQi)nH4A>h5!V=ep6E%Z8@h`SBZLs4Sk}tzx!&`7d6}lKb*RKPif99D zUqWI-g#``?XjAs3`$hBp`^!G<+Fh`r;9&RU<=fFYs{YvZ{Qkzew4w^rzfw2dJhbcn z!%N+1Kf0n0$WFaC-*Tp?C8BK`-qLQFu-W#qq)JxP zvKXjFmLD#rL1+}*;j-X`*N-Dj1{Be9osSz=(T-=)Z?wWUI zFrM&LvNsc*8f`bZL$9%j$8Gm*2T3NT5Cua5mW`;zwy~^Tw%PjDBCAHs^N|MhVyM1- z5QR8(0)HKA-+nSepT@|&xKUK2ipN^4-8d2>Aq-gdVXe8=`gCHXfc zKXyI)=Vxi_|M`4;_Ce>v%-F7vzO67W-ZS1-2>sWNArY#~D$23k?T8f4K$Qd%o|t7U9G^rin!Lp$`(hWH%s_sg(_;Z75ez zhS95!#BiM^FU3mKq8LNw-E0Pa4#!tU&;Ns*kACR- zpz_?&q0`T&Ssq&Nk2`)k!L8f%bVW%^`diq$1tk}THvgf#cim1kOogUd2?SGYEyvAi z5m|(3Eo>UhGq_bSFDHZxAb^^uSsM|j^g_N2OQZnitB?k0ze#FFBXKBkKLSWd(2h20 ziMTfdn8nW`VxqUM?7cFoQZ9+ISu#a$2BE>IXtsN-Qt>?w>pn}J8=Ws2+y_sIBV4{7 zl$A&EWlLbosDNsr$D}%8EsxUE4o^2?X<^)k1xbYfD#t3+qFNJGgSM)m7SV*WJZ@wD zfM)Y+C-42SWhQ^mpAd*I8GbPm%@_u!;EU4=Lbe$!kW@lkiVtDfIhAUe#J}%txQKas}_j%}96^0U|fkJ?&!9&9)sj3#?K@V%7I6^7p zz{H9};)r5!&c{Tvt5OgR$P8DU*zB&a=P1mEsUeAqcB0K%!$EFKf)GRkE<~vjCn8dB ztkUs@1mc8n2McXmP(&_^hK_o(?So8WY*crX($$O_N*Rn`-0ze%Te-cgV5MYRKQ@UR zxMxS-{X(f&oP|}uQ}-l}kM|!xB)ZC*7*qjm0;nV)RGf-X!kD7;sH5H{-2U;;3rB!I zuLaV;VA1m+{0tlho@p5B27gZ~fGnHa#GtmTw*g!ga%U9d2^Cg)(4u6rxN4z1#8}Ei zN>UOXq#n|P0SuxMnnHTPx?*!11bMlQd%EIQ(Rb`Ua5SmnURh-s@%ExKyplDAEHPI< zSOKctc)+hABE%8!Xu?sFUDc)P-~cUX^~nlseBmYllO|_qHmq6q%}aa6FJ0W;<4jwf zfL^E3M1AV$acm5t#%4Zj6(sqYGMM8(2%5NV^bEkaa@*>uEm%xoLJ9Ar*ill`+gUL+|qKP=8B|Dd>ilG(7 zDDF7+<*$iWWVO-|?Fv@57=?CoaFA$7#X~Drc|(SJPmNwjP*%`Xgo;QezBY^s^E8IV zP=r*<7-u!XZ4d_S>GGjl{#w0jU_AdZ|azzS_;`%e`tPdaw{ zS;vakUd`ULcOdj#^?Jwqj}?XQvcR1{s2b2g(Q#S{UY!tOiZjv!vW%{Ag#=DzMGByz z=)GK2dfBGl@(>HmCND;=!uwYv@-ZrQtkelB@eEnUxY#1r7IMhdU;Q`s^yA^` zN8qMF5P1R|1_($f#DG@J5B4iWs+e8_0oKc+%}T{AX9V4NT1IqE2GWGDVI0AV>D-Rm zI(mrCwJG~$FKh0t{9v01@)e5_H3iriHko< z!NHCmeMx?!-N=E2o|fjZkQ*9j2&j5uQH-!wjO;;hK^rwJQQ)$sa6nQ;N|+pCM?1Rt zr^V$1O32Wv3QjcU-`2hT(n9s7RZpOQ?-oa3@Q+|9YYYWWqQqhvau~cy40=}!9s<2S z4ohjk*pUb_jbYK!LlP;ubUB=;YL|}zj}OLo>{kX+~83=q-xx!IR%wZ~vs8@Cn-QkM21hy%cSHy4|(cvGdDQLnIOysohqy%`i;GA}RFF zM7)#)PmM7I_+SHztJmN|(>(~3M$5>ci)!3hmj;|i7!%qZu@i@jCpvR(sZoqsw_5Bk zV2#USC9r#B{pV*$3z-5>g2LBoD4)#_MJNwo6T)@dJ*`mA_87vyji&Uy_Bb3BSsZ>MKn2d5dHfM1S zKJ3Gn_Kf^;ts8Z39(cJ#z*7h@#6Taap;!=nVF*G3V-*q%Ss(-w>c?B~7MR>50f_|q zFgpGOr{7fyIW5--jMT)Soe;%1@vL;kC0V`zA?%VF?o_NyJiwAw5#hPy$`xnfDDLUdAQh4Vxmt6qLYJc01I0i-Ys?@QJzGsgPF z5oLWG((E#dZ^M8I7+HQ%Ta$(tO|}a|xNJm%`#Pa^P>tItYg8wW#d3W2L{9GUH1`50 zVZ<8UnAXjbcZnuZCMNyGu;z%co=E3TNc=M5$(AW(ltAb9<%=#BE4!NPCm2LTWWr19 zeu+eNtyPWv_>U=wFRZV3wC9s8V$g(&g#Zx(1)dZS(V#&DCL9r<i0<1+~0Qf;kS- zhm0OhFAM@@7@2fJ)IOmse5R3m>e9UM_eu>sqCyb}`kXrC_XjBR1tl7F#^%V;=slZ% z3qC(T|I4@UT^e}0)O+pf;Kw`u^*QhHXJre&FAFLjtUBCVFYBY8yYD`n9PwMjgTnKF zHa|Xm;kC&>?caU1IWN`Qi}g5!h1tu$d6i*IpqFwrAl860t2jM6ysfA-uW1mQ8+-Z5QT)m~}ZwNg9fp-{iaQ`J~6;IN&(9G+zDp?&?nyGeH4iNAHb} zP#S2VUNhd9r?&`UH*NZ)gazM_A+;z>lO38z^VPIljs&_o89`*gZs^4c2Mt5j9?@~m zQpC#&bF17Qw@ICt73vwVm<&F%mlGbWW3QmB`sUJiVpV!S6vkNChr11D`O0Kfcyh}P z0V{9ZVy{p;!gGkqizt1AtC$qWskb5;7N>wSki}n21+A7ogKL=wh`OqV3K=&PZ;P<2 zARH6!X{X$7r=PS1u^~2^z4@a@AAdBnE;w^<4zX42lHtfmmW2chd16qdLwF%=IuDEJ z#OFuSAWVi3+lI&FB*?@yDGWRTM?~^{N;}Qp86#&Ls*n_q66@@`Prul{H|1oF7--Pp z4N36kC7NrLWLwS42XrmG{Vygy$lIeO*Rx1Th9W3J5K zCs*GuNp3FH4_>lb&tI6maMD?Mv3h^9Z@NeBW%46woHewTR#b5e!8LhQn=3Uwh*rTk zhEXD^o|*%p`gtWpoDM%b3aBtyoWBs;}n#kAc0o_5;6dO0VAs}VX^m!d^aU1c& zXt`x#a1fM#_y|~qouolCbdPb7ZJ={6>H$W4V^iK+${OBm!0T#T9g~=C`CbnC>8)Iq z6v%>$lroiv_qP&i!-h-2a2RK2p9J*&l{rBCR|;fG9vMZ8AaNkfXOL zEch&p2_dWj_7xVUXj%(zR9fU}p$tiEluxqsVQ73@xjex4(y_xeW{i9DljyfEROp)d zNLV<9^&Gps{vozem@uxd&FT=%hySB4x9|4j-vn#+9oum2v!B0f{`|Y`>C^wSamOpd z16=zZxu&6Yjf z69^QgeVX5pfP{*%;B8u3q53I)K!9lPZo#2HPg?PsPCmj(SRZ}JG8+iruEGyf;{vkT zC~X3F@jkT}2yD?9e?u$+B^#ZJQ)p2diMvT!Armi)f!l^Dt@&!OTjEh-g(N;2Um_8x zLywqT2)PP>*GtVgDAuTT955mo{QT-yuU>usTAu0Nbqmr!Lpa(%vA8&lABLwwSc_!< z3A!xWAcSqxafFHuywg#Tj?Fbj;Ynxph3PqC zdNc!wOkX0_c4{{l?ed%??Vnd{_hQfzid>Z?fQt1J zePXyS(c3H>2kW@pZ>|_s#)>js}kagsT)6$Z9RX%-I-M62P1YIP<7EDUqgSZ z%pa@)QS?lp&_46*bA9h?fkCt2r&RtD_s|<1D>q;!w0!Ig0a_9(oe2IgO)qzJ;li)e z0Em{2N7-gbrSZ`bG;>L{S6D}JI6S_50NMIj46I%0EduBrlAW0%zQw6SGmVM^D3>LJ z1(f?_G{@St^8CVwMSaiDDj+Z54m#ad=;5oUck{!iLP0RDW*3eS3Pgjow(BKGU8;r!dc`~(x=vp^ zByO+840vK}`SDnsor2>ew3*1hLI~wlD8!g1rw$5DQ8(?Dr#+#22z_j#OvRANO(iZL zhs+V*CS^J#5@d$*xVd`Dwv=w(hDcCEu{vq~Jeszq+ydtl?T&F>tez2JrpQB08QkM* zH&9bUHL7eoza>N7N6I z;klo;byeS{BHR$0t$dj?p()-bMz&jmZb+@PRo1OZz*_i|V09ubz;^}A^uzH~_>neh zl9XJsKe3#iDd~T=Qj)729BE<@SbMpbD+N#%sDkj?B-kr%!^Bfvg^B#mzo&8Si?Xch zAteGgL^}(NkkB(kzQu_O9cyt|Q$C2n0?K3tLesqBVX84f)WWD{tbi7!Yw^qKz}!FrO)m@3$}K!spcXIOFi$ZRdm*aWjkU1UM9K5d6wePI z_*L=QD{U_hBddjD!Ows~(Bp^*FX!`VSTPpNO}wn7Fn8ZuCA8mL9c%}3OH3Y2 z87jV~A~dBTBEnORMTy(Mr&FjWwynt;Nnq*x-LCe1f9iJ?N|Pz$-w30H~!l6FkJI#u>9v~(~v6K%5?>X5gt4{%3Ds= z`@%9)+NNWjh)y=SiCDmS>2_rZ7uRHbW6x<9_NvA58fs*6 zqYaH<(cAmCU?Va~t~$C$+h)y1uFCYgf^o*9G;Mnqrefj>abILG9Pq&`uX``mYm6i^ z9>h}3`;{g?86Ceu66aNQ`=05%Isxe6!b>np40(cc zkVTLT*EA7WGz}bNO)V}mbTT1pjy0*obk#-My~i#Pqqw35Qp4!!{W%57fPZwAl{&rl z<<#$=)Voc2esLJpgwpXKFgC{LK}?pXlA+QUAuC!ubeSo)#MK5=@0{L5b+321vR2BP zslT2CT5-lOR1^UrWFajIAx2vWu`C%YKxE|1=A=Ie?^3M$!o!_Jbh@_uFO1apD)W`g zg_?8P%Z5lV^I^LRB@S2mqm^*ION^ofzHo+d&&JBy_#6kpEAuH1>UiaDa<5dW zw8$Ha_lVN!<%~t))NCMaN<}kcYnH`Cxa=p9tzsknOk4?()HU5a`K8e&m9m=3aZF9> zm*d~!6OpXDq3l|O(!4FJ6;LN_m4J0u;oyKHc$ZvxFjEKdf-~gy1Saj|DyPL2>atx| zoociRl?_EPflh1>f3T%&cLOuxxK(Ge$)o zJ#{6A&f>C*pnS!yu~;n!5COY_K#8nAB-gC&U%0TDr}0g#Exi2OzxaEv-umob{^ ze&W-Pd6fXB(o=9eD6UX7=E{U=+H7Bi%&x1=LPWuxv?tq0UpE!gDNc!;I9YdQ2#abhvO zmPzN%WFQAMcA7ynp`1szjqu=56)88SyGlR~4C~`m9`nw{1>s#~I6Fp-5hEsX#wH{& zW6WENuvZE2{NtU)SQhoKR?Q(Jq7=P1#x5VgAKP_`+@YzUng?3t%Lw*2({EoC@>xCw zfGkc0?UOa6=FTLHsNWB;5y+Owl<6~Qx~(kaGN2L(2E6Pj1BwxuY}|x`7mU>1$2;Lt zs-azr^oGTdr>2=%GcU@5zrC^WYudiGFGwMJmj%Jg2*VT?Q*2rQB=c$TNCdIM0y$)^9ULEr0q)Z9FiG43VTz(L5&7fhq0Ztijv`liP~#;-Jq{vcoMvw;d^|AVtoKq9 z55BO$4M!rPaj0cTgRhxI9^8JtzOcadMUlGH+=S#K@i(?N7*BGo%VL?>;bTtVa~rx8 z`p&$>`fB-GRM!loM>U*uBElDp3W=u%it2jC7uX?TWt*R${iL+xTtiB zS#2$%!Lb2j(EwJVJP=hWby83Zp>U@#WKylfsH1LR`NX&x6Svi-}~Pfgx*z7y~aE{O}^1trpm=4Etk6HC~qG z@62u1yWDyH+?c*{SD!rBy_UWF(gI10aCBo?oHo!JQ-CA^EG4jGf?o_BEZ|+zL902v zRB#|2j0A-`3G~R^p^D@#L5F_#&3%>Z-fK>~xOEHv;~Nl;#XUvaU6;|3lZ#@i8z3dBM4UYVtM2XffoI zWJNVbn4*MJCKAOTt*gig+&C!C%n7MP#PTT|iQHYzN=T;Ah*=CR9JzA*b~8dl=0_z+ z%rT56$!g-;T-&S!JhD)INRUk7(&GXSyMso8sCjfW^PMK=$6mABU+gqVhLsgitp-2L zOb}*?Tm;c*Nd_v4h(KUZD9QU}JB2>q242GzkyPKn+wHKhRKhf(A}tZJ_H4wfSi}hp zrWRI>E4>vV+^EzkB`Sn%ii6c}as#>|t~&8lcvL7>{SK-9TY9pKL#rJEp?J59( z4x7*k5Xf-#YDmktA_J1rNesfsUEC8Wb#TTSg3$ER=wX(FgF@4M1>6}i@#%K+(5~fx zQ)ue8K)5zakYXLrt8IWgz>x5iI%v%lkjwhbzM|tlR2w-#Plehy=_E#~a~7XDDHmcC zWM9B5ZRW+|6X-;V9*!sB8a3m<`fF>KU;XVf zUHQ+ChJ7$kIrSKVJT>4+vf_Zn3UFo-gXR%GZNQR<$88WE&IgOjmhF^e(vWg>+K5sP zpc-R&?ylV#ne1KPE@x`(y=RLmlMd~EU$W(sb48UEL}nhLI@Du#*yDN!^FfOYE^HeF z1&x%`Dhn7_8iP7BCEU=wM|PN`IwmDwMr^0yu96BsgwdJ^NT5o zxW14BN}$KdhANEN+Y@9b8YX&< zuMXit1LdRUolD%%B12XNXDw_JsZr0rmcFRt(>{xA%T(`AqjSJX}ER}gw}=+eo>U8rT2vG9##G% z_-Wh3$v#8xlglylc|p_gLPc@q!cV#HO7ibCdP;4jN$Ty5?iW%UKv)wH#0vMM##kcq zn5{*`3*g9Gv7A}g+&w#S@#cey0(tZLPaMsTve&im3)dBm<^-)OdJPM!VhL$pnrp5> zXSfc{@zDO}E?xAn2By>DfO_gN&)Qa@gfArCZxkM5S&Z?qi4pb(D^LtgxZZ&a#VWTu zNlJH(N|k}7Ip};l%MVP%Yb0=x3dDwo7~>3OiJe8vgr_1ZRZ4MaN$AApc!wlW>eFqD z%{CiPQ!U#$TL#sOM%aXlJO&F|nk)Iv#C9GjebHF}(M?E7%_WZ%i?x!A-lQwy(t zIz2|LAS4F}Lw{~PnN^hA%O0`?B-{V*<^h`lYzT8U29LRnu!u}Zfs9_cb6mjCKPb&B zTS;SKoywAyurC}P?s)m&2k_Xww zEqikhbe;cJz&FkQV_RzeZ@*kEohxYESlH)vZ3TLnV*jIqyejTCL<|a_=E&$(S-EQ2 zurKGRT7?Kt^NiI}5h$w93wr4*!;}g5dXO8ohRI<#i$9}Tgf z>qJ?plCqbp7Pkga@U1~N!)FvCxSb{XtwRGlgvLN4w#-Z2E|V2t?S{}^ZY@l#jdcPb z!x<;9!*QEzJxPS<2{Q#lxtFPi{Uy$Ao*J?o^&g!_VaX{lV4J^YvU)GC*trA0EIoXq>&6;)78S0e$iE>RX!)p+#^~@fYG&7ZTJs#bNJT33_ z#nF%31*!oG5-XFMYIM6NGBFnm7^jKSPYXVEeA>;C<@b~8@Jw?B05#BdQnpoS(`z^A z?W%N%F|a@=^|WqDG?5U*oMDwr?=pC#YL)1OIa>_)poXGidnUIk8e`In0Rh^Pz=+>! zWNk|-L&V63eV$RZ->g@%H+Dd z(KGKZe(b#T`Ici3-v4mHwRl;d<@yp&tU5Vl%{`EpT~---@M7=G~#g_n^&$x zuKVvzJ)0Gu7aiJVO~y~}ONOVUh;Si;1sp(@3vGt|D$FgPNo*>&aE^+C?j)BNhu~gi z1bZeEb8a;z^sD47#`VCP?5I|wN1YtMv4DlbPXJ1@D$8!0iqyH&Iwi?^yqZWwoZ!kj zbldjPD!%w}Rd+dEkSNpgiOplp>~#Iu{^tD{gAE%$H{X2arSMV4bMgzfbLxgknSlY$BwnZd)Hf$vH&b=dJ> z9kRj(O+ykbJt|Rb71@0l+$@uOtL;*ft2sB;;ITdJZ7-f3{`qemKmB|6kFR#Tr~P&3 zKR0@=6-3>*)Zg!nNOvu4+;cSZJ9{e5C8$mZS~!Rj<6Hxt9LdsT*4^gr_Bv_VZiA zAASAGz`%}Q|Gavn#JPLdEmho=@SyEjvbZQlmWQOb8IaztK$EUw+yexXO`O0;cmK#R z2AM}k;rokX2#)YPls0zMf@$gd8GK6gUg?8Kyt1v#$iW=eoUI|sydj)bFGEODSiNMn% zQAUM@F^wf7VLCgpRw5U9q_amo%)%uj-B{7dou4+Xe#Lz4^YwGm*H=WZ`Dgmf(l1d3 zNB50K1?T78r*1{xEByA;2j6v;jV^q+?)^J&B=shyQbN_wvos^u^v}@ry z57qp%BrkRB_tb9>rGd?(mG6F`*CN4wrQ}ww=+o@eO=T}%-{Y!D|Mu53^xC2MLNl)J z%!jk%&#!)6aC6O}b?E;F@i?b<%zB6;G)A_Ivc&aycb7;GJY7RM-Qqm5boR>rhq#$% z8@Oklmw&Twi{{>zpAW@+*mJnz!o>@kH{N)*_VW6uFE*_LOuDgK!U+&zH4`7iVp;qM zCLL?J4e4`)iitsygB7mggX)P2yDg@?)?!o612l*}GfhX;)^lAZEG_O$u0Vx~GkA+) zLc8Avy-qne=>kgosImkffz)juT;6wOtWs9!eq=(Z#WV@jDKt z9Fl^VmWUTxlY|615}p+AMb|@n$aBsd&&iO*b>$H3|O@@mw7 z?^(9)eC^D8ub#Y8X+O4mGSEArQiR>Ub40bdV~@*U@$Sa->svM@-|qAT(m&X91VAxI0g zC`fg_k_Wn;INZ4;-<3;!dv09%+rHU_ogcqz{OVdP<67E3-p5V8_ht9)=q={UuHVZ; zkT+_FjLmadfc!N#<>MpFBj2AncIqF3A3mpE&i{2>R(=TY*Pu8@AAa^_$>cAGcHKH% z_)Bgj6J&F*$d0*qX$tjFw?bd593eLN11az@K(Xx{phmG6a~-T1GVoBVZ=%A!_$Z&T zjys^;mqJvzGU$BKgkA>@Q4d8vMe)l5w`}7=(M3AFx0odx#-}?ObZO6q(o6%zBwL_eG4#zVF%XIc`b*R3P?X<>3!@98B5#P3!6TJ7a%8k$&*ry4R}*SEjA}FuC{V z3Po<{foA#wddO9lKuGb$M_|Q3)d^wRxpS1?MSnDw@A=Qwhp1(h$xqphCE0@!n1^Hh z#A~H1M&}DHmZP+MSxz&H_7>&5xA(^HpY%U{f64dHXM0ZGd)U9}jiH~;l%4(DlOdwq znxEa8u5mp3HL3M-MQY}dtFU#GnjN$LygI)_+~PStC%Q<|NF8*@L8yWkd1j+;!%2%z z2Gm?V*sh))La{`$F-a3EGv00F`8MV(UY{i9pTvoR9Sl5wtZms=scnX0JvG>U+1v_% zT|yO0k@GNnuV|vdR3$*9dbM$?aGkmT>do(;Tu~_9U`W7LZ$*c<5F1Le9WjJBpTxXO zgmSqjCMhlc9MPa9%^kX@CL;FB;v!f2sVDHUjFcL^Y&Mx8pE1*_gtuws9S*iRN-K}3 zK>KACEsGsQQiHCuxCqG-9?Eb;#-6_S+u@s~ee*v*3i^OZV#*Q@yx>rHdY82wKSqUx zE-_g8%At^&>0E}TL4C{5}nC8sALq$`zcqr8AwP2*mLnWBOXLury|#hE|0 zbASG0k9rM=V|n^X&x+bVlW)Z9F8yPsJh||2ZuUoIsft%sA0H{|zwvI4Wd83HjiGxupA4l%m&p6%pAVxL?Kk?-+ zuBY=LF*3^SpxWl5-gbNt`T4Q*H~d3uskxDlyF(Wz`yZCqbTE2}-)%Vci)6u1MSWPW-P zBT-MAAsOr!nKXShoQ9KmVhGzU_ua~JY_Mm5y#iT^b^CWBNoL3!E|Z`7mPNlPQtK$g zVxb?i`*>G|CqDYFCU_lb9?Fa)l|t>O&b~=@?5pGA0_4CWjN|xT(rxn}hsv(yo_Kf? zw7`%SykU)jg9JGl#Ei^DWJVWpFJ)%wLt~{=T~7n?KHYzMm6Fz&1s1^Kf05d`i8$;?_Jnv27=8OS1{Dq6CtoyQc_;>QVk zgE*NXC4ztnLY2RYnCUmy$E!|xfxf_|5(V#GDu}^$cW9_)in7+EO&3pmNw{g$zwznD zi&Z<%Hm-cSe>m$lnAZx0fg8;e4?FRp5#TLoph1un42+6fO~sf3JZ9F7G`O!TbO>t< zT_Kl<6)BM`g%Y{jHK!oP^!xI7{-G1Rr}wzOe?|WB&V^MouM|!F`^&+d*Gj*h{lVOS zYW|(I@5HDJ)BhDdS%uBjkAIOy-f;4Yh)}@ZyteGZ#_lh=KfIB9aIRZ-BJkd&zW1h{ zHr_a*3mrZ%dH?tQN3QQV^U;^M3GV(ss!zTAmUd$9*%xJZ*C)tUGVVG0oD7^)x3r1; z&4V+aah`sbb;qz}$DG&pg%NH8fKCYFxHDv%ii%rW?nN+T5?x!u>(`#zzb^iRE4way z`sX{YKmT*)tK;2GZ}jdN?0MVxgr0N#qrBphvf{_iilNQGNP$E+1spcZYe-p;mbMUG zDOSo0>tto(CdMLA5fr~2J%XS$Wb(10B5qCVhU3=(H4%`=TE#wDlOstxt(I+xk$4H+ zCs-|zfQSxqg585-hWrHjpnBZxA&P5MeP;A^YU94h-2o{>4d0`c50R9t04j_HJko-6 zB|+3s3N*R+5uk1xbGh2$6cbYm4FE$EFRJ~SBu!jC!Hu0E#fhmt`6R6-*n}C^uaW8M zFy4$aj#CpZ+fg-_6Pm`#+B1h_ZE}ya>B;Fs5BHqA{`WOIO+Y9M3#?!~LQF6~^dQ*O zfrb$WCHf>#E*&W)A&>wa4Z!^f6f-6$p2HB$1KQ?&Y1j~xJMC)r*cQd!p%bDbo0d7h z|K&pK!m*Yg|4Rx|e~zvG&&|v4s7gLK{q|dH%jO$_>pG`~a^OT>+~W;bLv#1nABbfi z?0)$EKi)kiN{Q|}`rYoeU7zBgt;xK=Ouuk>_Wt+ZPX7A-p|`j*^S5qxK3#oU^!2)( z-*wiNrNk<4E}Pr6dhyB8tFGS3?E-*uOMHdO3i6h4S6LtF@T14yZLWIx_tTL*m)2y2 ztdlL+E}#kvXQjrim#Dr$>UcU|Wmfa9@FR|%{@KdXZz*4etr4AgyRw7yS}dbe`|Eh3 zf2enR!t5A`SM1MQdQCFlfTCDPvIPablpH|-mAhM;keDr#(9@scF+}?)0CPpQc6J!g zth^mH#B%RV5BW9PEY4GbzQ5m9G*2z(bXYhOl6*4k&e_7PqMaKlzOPR z7}1c*VlP|S@j9yHln76vNzjKBS}YL`M$%cPe$pq5u!={@nrAA{Wookuy zn)t>5jf7i(RGoSvX85|!pS74?oCVuzYLqSq9<5Tyj20K5>JQd=T^4f&UDm;3C3-0x zM&-|U-dffF!!eLDY**B?-;1lJ_LKb+b9LF-#b zq{|EL-1~m3`RITCSi3*9rT6ewoowrSf3y@(pL}WmWkcirlS6O(t7M`7#*R(rIp2R$ zd1C*jwb2iC_h*b}-u&#u7Y9GtSgH7Z-NxP+`rf95@C$F;N+PTu65ek`O3fbnnjB+6 zPD-6b?^zKab$U<%8YR|@PYfoltn^|&8bVs&(%7U9CUrpc=No5dzG?sEzoLJ=c>eJ1 ze?I7yYn#i~xR2dl^dIe0KRMaQ9~?68;y*cg;QjLKvgB4-aRrCvu4A*a%!;WYJCVVz z+uduH+7^YjR-^qYW}A`}y-Mkt5)F8(Rc?QSnMwByF%%Q2+ns2o&4-QT-i}tvz>{!m zjO#S_DAW{w2unk^EQ(kjp)ymTYea|pO`=l<>PPiwxAZ>sDg^Z9S%gGMHA2^9Ik=2F zC>kt)C@2kM!tATi_ZkDic(l^Y3CE*c4O%7Krf=lwSd*DL`|)ug?J`U=`7=6N`Lwxu zNe}?Sibmn!_QriFc9;EPFN~x4Hc%N#Ge*TaeSYuZ*{hF!KmY0BDXt+>(8y^4BcPDE zm{Uh~`|+Sno&l-}dM`JD_JH403pZ~OUY}JF0bj|XHO|Kkj|7b_gt*rv>u{_SD!@Yeg^*6rHV3=3X zobCuV36r20X)E?bj~u@jk&Er4^DP&%VMLO%ag9PuK(Ks8j0-Wy3NV$n3~$OA6jZ0K z`7V3r@#90B%vZi2-2L8~0$r1~OY#=??b2PF@)gw2A6OQBteRM!3Rr8KLo~r&1_k}kCV$@6sg~)JP1Ux? z3<;|}l+Yk!V{t?#$J(YR3sx~wCEog`p3zL=cE%CBzLD6l7eD|*E0b$M`7EJ~;zB;7 zRf~1W?y_lO>p<;xI$h7kc&j>CG+#50*}TJ^c{-~uQ(zR6oEgHEAN<$Vipuxji1?om zAPas(> z(vVCpFy^$e?(FokzyF1MnfuMHnX-fLd_DHK_0JO@B$waZx^Ml}wO_~X`TgLQr+<8P zci|4BLzxTM$i|D%#Q%Sq0T0~B!N0!QPRAqkG7C@QWT;MUw_>(Ly! zbASU!PE^2!xyz%bsJQn?JEf^P8Ydg4PCftsx97(zePu83OE>rTzOU=Luh09lzAYzD ziohFax1%S;dLb;1Ll#G$o%2kji*O#I!`(kT{9y&8WRN)3`yho;rmn8S>+hV|Ud(KQO^|@JgiM08#GDk~3Mj3o=YeGSz=JA`1Syb2fwSre zC`4|oL5!m!q2z#&o$sEQdZUo`@~N; zu*l$X!c>-aEt4J{YxGv0=dzaL-+%g0K91oH5B3Ybn^RQw*LjOu_sxthU_sY zD<6@!DYPNpLD`**kkg95r+n+&Xkh}&xakpWsWz>XBA=AyHkK)~#UGly1+GyuOem09 zZdjGf_Y6L-zgJ2PpaS2SWigYD#{GV!pn=KYL0l$$Du`(A2 z&YMOmBL!6eeX&(U>QMD86ry4!?haA0Q&Zp|tw)Jeo!-Eg>)|f&-L)+_HAj4Pf#c4OrztI9AwM zz#dW$ER>sAshA?0|DmKr8{&UKu!Jk>F`7L2Oqd&#DxWFC?ns;LO0CiN6Q=rhjO&x~ z+a?UW^mQ-i%zwDO>q8DBa225OenW0ttvSYS|TgmnGRuxKvo`-3jg+{VAIxe z2{2w>c#HwS(LO#K5`z?_Sa~@?%<^JF+BEjn=Ig_n(}#KW1e#$c9_sogFE|N`(FPjB z^2dY$9I@Gk>0lw3(M}749zuq|1#0f7Gh&uj8P-xTC8EQ`q2Ge3L1TweEmk_KI(T?C zAw>v9J&ZU?X6I^YZ0)CyL(9=Z>E4ut^G^cJ{VHm?G#rdyR#tVc9?W z%k@i%)c41qY5$azurbKRP(pU<#p`O_Z?Abi-p^ZmI729n^sCnJZ6=I6LmaiwpDBoQ zjsvi!0WH%zePx>Tf%^`5Dugy{om{%7d|p$SyTWIq&S2ZU`k9oCPOGSdD+xv4>+St}@5(*I!@T(8YaCz^&vkauw6*mWR!r_FJ~dVEBy9k4`&srk%Q zI#GCqti|a=0D8~`Nan%DXbqCKJXij(JjCeVsjHWsOxIT~eB7@eS@rf~m{gVCc~cTy zt>HMNJy|HmGI|zN{EN>TTlitF!UyYd%%^cDVylNGuHg~s>;Ft#QNU~E!a#a!s*JSi zVGH^?xQW`4pIxvl+FsFM%UZHpn;>LYv9N2w4xMiII}hz^Vs(_VhaWerU;V?j9NN=e zF170)Q|$3p(=p(8-|H^tiLoY1&+Sq@j?I2F8za4#)BfYP(vK~lBBmc;dl<2iyGiwT zhRhyJ)G$4|WB%UtEjNnKI68)7OB-MH1kUkG*9oocgZ1*Zn1_*Tl2%*Owo zJFlK`YQ5jcI1|f$(Ij1TLs{GQbxhzU-K5?k(Cs~JhCB6q?=UCzzh2f0GevoGOI4%o zEq9(wSKq0OeW0Q25g&Z2F-oSKeRHwAZ)-P}@LJ@kX*=}1eB|-@lcPK@u19Fjyoc2d z#lLj(4hSEYZu1W2#rMZQirbeSB5;RV<6HgRPjW9twWv3IiNATz z;@`{LmK8%kLQY*A`cruMkp<<>x5pO;|4F-j=YpN^+xJ7z+q6sa*#UEqo64hoe+<3u z-}s@a^mXSYwSJcP<17BB;<>VbfVqNi{kTtIe;Nh+hW)*qbGpi9hW%aY+V5juw+HIH zzC1Zw@Yl?vq&LDp{to)2)PMWew>tr|*Y}q1^56OI;TzfGmv>K`t2nrnQ8c}ESK_}n z17_#HZCtCaIi8e$Lp$yXn0>z&b62MS#_8&7(@po%FU8?LIUFw-(^2#J#A)`{WYU7)P>q}{hB|{GmtgLjAqkxP2a=#WK*78 z=#D6CSZj*YWngvZg==>^H-5JX`uEPe)(YpQZN{EPS@SK-BA&>DVuYlt@%9L?YN!}) z+6^B}5{s%z0BJ}jzw)&+syxtcHVSsA@hA%S^x8{c94@hrf6knz^&kKDgVT<+v7%bs zghOt1S%+_Su7)g^XPq_{$qyWAbfLS)f~xhX!M{|t4sn;37D7>UpWRgR@+ z``gq=OcFCV&txgkQG*j)Ql#GDRFi%fkG{qG29xRy$)cDH$E)UW=CUaG*6fr<_HEYD z-p<_fdc#Z12~+qN!)CoB-?Cdr>H%`S-*;ZuEp+VcEL*+SI{JIsGB&neHgE0__Ux|s zp6N9&7s&w6mTYKOPvb;+vdhEaqYpXZIV%v8g|OV9rh_I^*SJ_E_IH!fi^`vDrJuX# zc26gFbDqj-(U)Iu)f*GJyEI4&en+;D=5$WPTEzk~#H!w~8XMKi?#ZO$?688R|Eb|@ zR%Bk6CzLJM=lP^4)p{>gD2zTL=69C$1hr_Kkc;0bChpFAd*-J7AqBx6x%;8reysPe z7&v3sd=OR+ooGKi*Fo#Du>mp)Sv)*08MdhP7!RG@umD6_g77H;9|%7*?eXxq5GZTz z^ZMlI{_bcmacQBMu<^#!)t(}N=TPHZMvC`m?AyRn^9KEiP(2LK3y9$rCXvj zH_wWvo3TwzZ*~3C)}T8w;aK`GNq5E}g1Vzf&n|tyYG45smk-}-%U;;6ug?d6e?E;l zY&~^1X0{01wU+gg8tkyEz$}m%e^9J+3}e?kVP2al1$9hncqA;#|i-_PtAS+CgvDugRI3EH`t1 zY&4l}p1eLW-n8o3i|mWdb31Qb=%TNiSZ{hZUv%2{v(Z)5-7m#?8!K+NzCr`f{&?;gHUIqc!_m738ddK!6|VNAH=;*^VUNXq zpIa^JFtbS=Smt@RDYx}@!k1Rmp*Kr7R4L@_knsIu-)?Mhk(B%gn={3efxCdErT|3V z>id_z!frqOqEpjl-(29R`SJ-PXExEA(|(Qoe(~A-rzO(Xe_M)(q1w@V%smYiHT`_{ z1^1T4Wxw!<&&W0Jm*q;aQCVeKVvS+X{#2NvwRR~K5T*d<_f$X*?h7atW-W(kXow?g zW5MG_;lE!|E01PgG*unG{gctVN;2t@TS)mk^N8_dj$t$_Vc87DsYJvsId8j@4{pk^ zxU26?4Xa6ATyS&!1NQ5c6y*Rl6mQL9wGRJxByG#j@sVeU{+Gw^g7v$c_G{~7t=$^l zsvEP=nBMNUlI+{28jWu?-J&C97AFGKU6h}Ou+UAYyyuzq?#1-La8-pj?_{U*&p+-h zdYssN8_`@9Mq;LJ)h<47?`XnxH1D~E(ht2RfjJ^=>$uLF!gZdpRi2c#P3h+rWxc-T zGSS?3qqQqq`gBA2jAHr=KTV0=l8KbO$2kmIOi#VerD|Ml;ZZv_Jb8X;mUo@bJikQXis#USr7Fl1Tqkz%}s_a+Cp?wW4XTQZ%6wner0 zuekVgokO%&$Mwz*5kiip{QJ+t*IEiG#z+^E+}vb}&#R*dRC!Q&I(suNtoVf~9b2zm zSe;6yJR_+#r!GKRBaL<2AfEGnuPJsOSCHfE6RjD(6B_<9(TgF=cSD-%gS?KJIa9h| zbY8aa>gA?bcjsVnc62Q29j?i26Sk;mzBC#J=@1;pHMs|F>`zh8xcl>nb)#?ml;&m| z=3M5d;*sZ-VB2lyl}I-ad$*0#M&6#AC^^vV+0Q0_i3<+i=?tpBofot8-ZxO|jsEnt z-)fTSCp*T=O&gE&$GJaV@8sl5yIs8QlkB$}*8G- z&QrBdT>D0KV8y<5o%?X_l_+e5V&(jmxB{y#of!Xp-ID8Sfral30;gJgQ@Ya`=-0hz z;XbGacY-aZbNB`Gyc3<)k;u-Ov;Ook|5aL}EWf#dJs(^Q5OZedasT|ynj&l=BeeFg zoLpNGh?ONXJ5@z}TyLyAY%K9KNII=~#<6H05_jCN{>K@!c7uQQ17O@j5Txl#_9{r$IEw@-=p9D&E7oq!1GDmnLS>RyXkR9 z;R#Cs^5Uc(rO%vTIZ9=%hL|`^XL~!ebJ=I27a;O7g?5wakzO8k&kU7(O7iuB)?NG& zW!s=>F?gtV$aa~td|8%Us4so>2)-aAQk}JCnEXYG&h7jcHkcFL?&V+TH=#z@{zGky zOJaIl(Q$^6J{u;cw9mea{?-=|-gRZ^@Qm^A0=IQ9oxyCE*D$h2b;?VviJ_vf$gy4< zg#Y9+`yYmU33TP<`-srp7S17iGV^+Ylqpr>>CT?(gg#fba8laTS;^4bmsqASnVnq3 zx2G;N9h9U!E=QPB7vF6dj?Wq{bo3xmOhydmeV?Hr-cL%LjW|~n1gtYLk z@cs7um+XOOh6L}D`>(WzyRA`j4%1k^B2j&@_4Q}H67OfM*#hSzq6XvA7qG?hDJ=uxcz85G0^5&~2M z60?c5>g3@3)%mO~pGf6B{S~EfamA<8S{wcG-$fN(kW#{RqZyOSLe7qGdKS~jHH{Zo zUwrVlF>2LJ_s?jTx}zSQ0A8;s>fgPM_m34*+hde-&{|WYGb8tV{1)6KFbIgmg z?0wQOr5uL~natqr0H}_QHUNp$3n2RQt+TZ@{7U$8-sfHEnXrCJJ-^}Cw?6w$#Jc42 zP5YK~{L{6|c~TkeS~gc>={|mMgKSSx%yc)tBR_A2hOJC{sXzMMqP5&NT`+u?Rn99f z@|~k_iWFQ7s=vx;4du@>T^fpVwMahtS?LIt&PUzK%P_X0e^E&yaU& z$Dnn<)hOnbW0ogg<+Q}+cx3^v&*4Z~jGvLESnB&x*&{hNF3Mgkh-suga=KxVI-&B; z0pBU^D0;vVUz5F1tRpd&-2Gvk`Sj)KfEw?6D z7>qVuX!`B+fr9ehZ|8nTdP1K0!Ivk@1kc(=TT8;5{(wv{lN)=+@Z=8e62+m1=+Fa` z7)$X9{xY|peJ_OGCu-R8(Mqww8K^_za9YAihta+jMP<2 zxrNa6N5#*vQN@$E7a1FCZatR7P21wnese2EJMzmXKJGQ46z_PIkPIfdhy8KBgX_nE zIe7i7{K#~p(*emPD8JpQ5(UJV4I+j(AkF}grf@c}V)g^dm^VXuXeFuxl12@0Pk{VwBTmIX< z8|Lb=s{%xPeVfU38x{;qGHUNkI|tidM_O>w!34=siss$sti!&^j+ z&rYy4wH1Zt@`wf)+NmZ>LL7pJDKYi7gZi1cmZnwPF&ipM@M?C4>sQE$jE%YR3I0LkFDDPc?7}0%?-r7l)zCpLQY!*% zFGM^O2|cpXNGWRd3BU@(yj4rvnm;tDAk7r)K_4hpvB}sNlIVrug9i!_EDb>pWNF1T zcIjR?q~-U7w-bH4M($x=&(58yh}+a9o}YT|7&#Nw@kh=UUM(u`4BEUkV=2YCDc!Au zMCg$zWHx%_)1vg3ZR5;Kh*Hj$uCVaI5N)ZtK%!`s6U&D}+!WPsjczkGoiY1V{^;JF zfU9%lKb*_5Lul_nHO#?!NK4rF%1mdpx(h>F-LWpBUU&g7Kny5naX9_#;o z+}N%(o>I)U;50rr4T+8#Zq?F|AdqDf9ejw86F>^Y#+}d)$VL?pXooQ9Czwm3&25O_dPqZm-?FjOmTBtUc-a;|h04g~h_ zv{RKRvsf-`G7>V^0{|8<1$Zx$)<&*@kKFt^=kIjT=9VuTr`DM#!cq6{?V9<_-7|J< zFS^9Lc0N>2c3SpCAlA;pQG~|qSorfS@8jQ5$oXz8rlBADoF?@vv-2TxU;D9M`Cp8e zbD|LAcKSyvUop2*4^46NHs1cJ=>lahYl(#sH?A&D}Y5Iw9Tt7~Y zDRH<3$Z?ix7s8t@BrPQU%gSZWi7_Tk3HRam`DGd-dtMS)C7i2!@EGA_VGdvvQ*2(0 zDZV_JW-5{@8D#;LOi5#)3oWN4Q*!lUOYh#WIsEb?+1TtoChLLFk{k6i_qrIC_duKtl_8Q|}`!%;}v#qfQ%)oRMfm6n#a}wQmLN zU&a8Jm=sC|?{F*;6NZAf=t4q1e3@Sy8taeRE)h{h^my+|=r}lC*B(7*?brc}=#{lJ z4(ZC&Gkm)oTVh%OM4s$J=a1X$HJ@>O+I4)5Mjq<$)zWYx zCOb7bW6`igBN}&}ncdLPMj~_&?Ry_HS+5!$X|Zq|3{amE<5nn6uM1x}wl$TzJ-=d0 zK0dJVP_96Zo;#_blRfmW+#_Jt0P0gLU2QAKSl+*t51^f-cM8Dx(i2hvFfl-U!*~w> zdm0FUje~*!H{Saob$EgbK}v^8rNZPKon=LXIt8tuWx&~fE=ZDUq1&fUxj_iMZ(!-# zV{)tHhDFpEMo0(&6x+V~*AP2bK2P7fcgb0+Z32b1Af(ITduh}SD<7%LA}v5{nO)pE zkz%nEir#1(!XzZvCFVCw;v+gu{-ytTYd7Cq7CMrpLnNd)Yb%va;XnFZAW3C0(6;E1 zIJs{M7OT$-qG1`uPP?*0{}4?|;iLz5B99OpVqRXu)YLRplg>>bLkDCUrR>v1QiZ&M zKk2Gs5<-AgEJqzA!Z(cLmutw)3w8Qs&x1Fo0{uKl#4xeLm3poDMeLoGy1#$Mf19c( z=$!ueMQ-g$hcHK7wAwGK{GsuR*}a9*H(7_O0m_}GmlHs#!uo)-PcAj{m#1vL1;m24 z->w*6_A0o?6oYF>SF6U+Sd#cow6mMsPR>}N)QV^b#~?dloEhaH<-;GC9G32RgXoVc zXeZ=wxwi0L^`EaT<2zw34Q#o@dA|7Xc~NKR2#a{=Bx%Taco6Ki%duY>zG?7 zjFQ%j=JIMnW0FHH^?M_LDc{C}T6NIGqJkO5enq$lV}0NsTG;6bAS zn=8Pb<((kMpH)R6XOxkv8;5wMX9E}b1k8mp^!c0)<|kCi0x=}(ibRSq zW+c@HrSDg6Tij#VOBkEbNRv($VLT~W!>V1ER@8Ve0Pc2LQs_oXqcRN0c7kDppA(f^7?@1X0DK?5MHdAb4p zqn{J2((yri3n9*XXClbM_hX|qOn41($_Wkdq56ENFGH(*@@Y_BpU`F$_jN;+cHgPF7p;L{IoSd(F%A=a6E-MS93KI`m$ zfu#MC$ytAaDk3EoITOPoAOp-kcpJeXH1?aq_iB1<^fd>eZ)C>SQ9$^_#Xz<}EL4gK$V5C%cu;TyV81~$mEagMiAHlmYp$vHGs|ftv?ML+nZoKjvRyi+&eESq zrQlazA=C`yrO21Z)Nm3hxpm$HavJxgFv#%?tQ0M}ME;-BB55i!Ccw(}!n`!`qy)EF zu5W^5$ZQ%LEmD2+I{f|0Yya29E0`U5Ua>d>J|&qohz+bZF7$Qx6Ry|F_X|q0lNb~O z1E;Qo;`ShoM7=ajRQ7$qeF)IHOY`X^;v_?B0T11jI18TjroB5ZQ*4c7J73mT5H;*0 znXGplxE%4e<&2}+l;@W{9|}*kZ7Iw!JhCt(XrLJIjz);{2VMaetES6MNu)3*U1Hq| za(C3>*+yv+11F@$c~aD@Z6`nf6@N&3?861K=O6z*^eF&>)5T)nw)=9HR?MmxZN{n_ z%*fe}PuS6SxV`YOti15S)s;upXa7@_xfJ30gZA|I*hj4eo^8FZ&r{|{xFlKoqbRQJ z5P>WV#+@L~om5p}Fhv5BL$fUmvuP_~M6r+Tgnwt$s;Pt!5&4G;q^vgUNP*rE-;Y@~80k0qMkUC#N zBEPy#3L?)ls)73ng2mJclULy&4Is{CTO1rwS(dFy5Th5h&DHr@gG1H)u#o!L zyq>DHgT0=)zbZGQmN7LK?1cpFhwMQ%Ru*HkdbVaVR2L)1td~S|R@kMl75ileKi=0b zZiL5{iiN1T&Q55_lNFvDblfhv^;T29=g{a+~eKjx955eOJ zRf#ZF@nKHps)n`WZH%A<*-}F?JOm9Z~Y!1g%X$Sj848H{8 z^Fa*)f9@a%2DQYIb9E46;&wg)kbg&A;06ejdFiPWGQ_as+LW4>;B9ghY*buK=>(Eqa@F~36zAbKLA*IO%s zp%Tq+gj%%<$T+Yf8K(xl56-PIvOr_UG!>FH#r@@0eD)vi{k&ctJ6+&deptLx{Qc~< zK+DC#8yDwyS^hn%rc8AWhli!2sN&mYo~Tap(4eb^hTy$=!r72FKgfSA+Wua8;Oh+# z%m5!IkQ@paCs4v=6i&#zv3F>`lLR;M71Yo-@dX?n0C_tQLHznA0#C%Eu`FldY^5}!h$Bb3MUK}{?)x%KzXQ39 zI|yyAcx3Fb8vXpKt&3&Vo9OFQ%8VV*7j6&ObV&-<`N?Zzg@V)=PPk;mSc)&P#H+sS zv%B#73t!@%@jrPU-<`VY?_3bOw-IU3in_2edXKjFb?5jm*4+B>i!V3jzQmgSIDg#K z?^O_%YUzB{jN?!y-jOLK79z(JJ`qxj!${K{1H~%~>=c6eWK{+D?LiWHd?KU5F81Yd zdBvmf2^9t476AqY(A}u}$a>n?M9Ub-FatY*^SOut2O}7ajBUi)3w7tM>@Ga{cDmu? zX87(i&1L>JrUY!M^*-tLEUbOC$j`m$}$??-jzuGzR z5tdCZ$QNSa4FW+2Y-;H^fnnBK1Gbq(1ayzc)nn#RCQJ4Y!YVMBW<3X;-}3S;f~IlqyNUgp|j* z*e(UTV7c^kPwwxAb00Ph`}&CG{@inva+eg&oczB(?N%V%3N7h$;RayYm$Wn?+14Y0 zUTGtvTs5-{W?QUoKf8f-((eHwhg@UL;h-Ld+d*5dm0t`1b@CPk@6`b(oNSw|$tuON z+WoxftU5u9AbWmL^6OcbOg8B*AW;8r z?(+e7my>>38CO(=9RYaA@~fm&aaT~WB@XK71|OFI5m(s@N3q`&OGs`%Cf#>Dj>-1{ zaFfCb&}Ec^(1J|zDs)gxO<&zJuN|^(YhAq&a(%qqCAS`_SO`wk`!#&oE`zF`v}K)m z!gB1I;lSd$9V@aC0P`ez>2w7oV~wprJS@VGyCF z4gz**DTmtHG_C`u_lUt9Df_CG8ii8&i;}c(;aWiL^Fz~RU+aptwvQjWNMz4yRp8K{mCW=@`p`V>f893UWY>4YUgAlA&baHpu zVk}WsQPY!_3cF)$^WOML(Pj5c)$11>vHll5wVf|yX%CIrOb93jc`+biH*}3GeaCgv zc$nIBj9;)S#hC@f0f0jwNW=gmQ$v=vPl31XlSbH?2i?eBKgXR%^JyF$TM<=33?vuZZzj7fn$}vSXyAeM- zC+3O}La4~WpbcQb1apmKdILA2%`wcsPvhurb91sE(4F#!O>}%eWhwk~S9GbMadG!! zWvK3>i{1_$S53ZB-=l`UP1H}`bSpLJ*#*$^nW(km_+A~|W2Mg$mu@tQ0WupZ4QnnI zk&swNhz#aaS940nTlxMke9%ZXg1a%L`7I&}FgT0xTx)~Jg(hQBb`A`oD^`lYa7mlb ziWoGvDVv<1ey+gAPqC2yEdQbV%KZ!-k>cWFu=pYfJ|@WTotTc3AY74y&I>?9DAXmI zLiLl%tZ)ZMYRKuapXW0_MQuH`R{O@Ta0wgTsA%8n@0(%FJ57{#m}e>zH1a?h-N|+N ze3)joDl{d|Z;^`nPXCYM=jpPsDa6s9(J0W^bYU<+I>q*Zqb@hMj?^8IWu*dhp zGbwSda$Os;Ko0X7Up!o!ag@w7HwUQz@&+Y0Exbb3=okzI=Q|Pg%YJjQ{4q&h*I>#a zU-gW_Krgng(?$C^or-LBB6;ip=x`NaALR$9)!ALgK7+#SM0!U@Qh&FJIpc6$XLW%J zVBLT_CYjEuqZm0KwhQ|@FfLD_ z)<^{`EOz@9C2s34XP&{7Z!oZe0C5h#0;Ys$u^yVE&0f6UBhz7<>R(i;sd_x`>mskF zUqV5StqIPqR@+G{N{wYzY1q?25+}8>jg?Y?<|=e`QFZ&QZHKIUCpOUwpf1`>EuU&h1Lq|V0C=8_x~PPS04S(h+q+n` zR$8aIugbsAa^c%=9_P!X1Fo|zb0 zIW7!P5X{veR)9h~TT(UqKdK8CWiBi%9uTO1*M$a9*Q1hvg9kl+Z3&?o6mcO>VYJsK zqfH?&tmCI)D-C_Tm}Vd{LO#b$EZ4qW*J>v*#+Mcd0`qW8T(usj4=R4;q!p<&o{Si$3B+sm?wtu6n^?-B?$b6A0%N>-GhCg zVNsSbLLq3As~+!o18>u4fKP)T$WZk_5&*vYJ^-fo7EYjh1Bt1PxiDTu13nH_HN=;( zl%>o4o(zmq4{^Mn380i2CW#@pSSxe2MaJ%{8)zX>k-=!jo2-I~0!9tQmRn-I)xSR#6+M7Ict+5oT z30Z;X*Q>zCYYoby3WrWXHGwzBhrdVIdT(*a-=@UR5xL0r{jY@(hOpCk?|yB}+>by6Vovk6;w%Tu4d*?9co(x2yWjin{5 z<=~jxr~JZ7pL97E%#cP5cwgo5fQdn}h~&WHlohHaxC73SH|69WV~aH>BhiEesd0d@2GH6-(Ag@pcOI`$fhy)2sv2zb zMr@?2q8ic8Rw3x#o$toHUhDJBuJw_w(}jznmgK~t2%9_c!Cws`#O>D3H=zd&z0flL z?rs;)YX5!XJ{3-j(vAR_n3vte1u()50vwhoO!$`(B^cn=wIzrH#V7%g#IG?Iu9Oxm z7Wm)jgF$5$(9rOn(`UaU*DMBc@LWlScg-Dt_x)Bo#bKAZzQ#snset&>fO9nPfDMRG zBS9iH&tlA3Z^|;eNQzxN?N)W0xdW5++r>|6Mdk0SK1pTtxzBC6TwIRuYb4c z=&E|WX?H1b4#35{>4Nyc8>b6^Hhnw-Zb8&?s`EAnHZMS%57Gx2>45>W65whIfQKgx zs={wzY5$buSb|H&y1ZRmE_*3f9_201WT{fnrcTyEbzU;7 z)i#-mK)|M2d&LQe5?m;8#>7lj6KQ;+3cZcb0bpHUR=51L+>&>Q+HC8ZXnqlA5mhxT z(a=nk35mPeNB{5jnmuC!W2L{Y$Zjor51?IIorTf;5qYCqH{?`i(#lc>wx^nZrlIz>_)Vyu!{`ZKx=IPkKbQ$?p01x|+3 zCvCDwm`1TOn{khVM8>aQJqs9&g8L)!rAP40XussmFy;s z30suRyg46 z{`+p7BFDYE!MpGEKkkN=1t@46spm-!W5u!r-zWKCI#%XWaolTwAYWFsBHp%140Ux@^m zDS#$%Q193BCSVS%a0vjXJPWX2LE1{HNoqH9TT6WvQdJ^-Ezro!&`ckhS40yxt76~a zuf+;w4~$o$|J!^?^V7Kp|BjyRTS+RV^-dKgkzU@tlJhAga{QrA2dPGqbQEeuGZGEFf1pcs7)>A%%cdlC2)D$br6=cjG-EI$!h{|wKHNl-J$Q~>(I zB-M=OoiGW9$i;`}<|3bJTGCp^v%dMxpm&II?%B#%#=Pt239XKAX2mbAZ+t5y^(Vfr zcpPege42mdBIxv)NTd4{C=!_YPA;_cCr2Okjw2hzM^3Fu(I5qZP^|s%P9`{uqAX1G zF&5?xB-8n-9uG)J5|MRylG(OR^U&H5VZ5H7uj-T@bK=#5sqok{e!KodefPCgxffpZ zSUpZ5{Wb0;?oW1>;x5G^?EJx;Mr*B+*vFl$Hg`LRN^juv`^9nQ6SIo7J~g)K`MpqJ zDme_>PqMaJHHji58n~6G9}~>Z>J)!{FA)f zRFRIYWPWwAGg+wkp8C;$eO8ovF2FwsN$yRJYh#p^eh`ICcU}rik%bj8PZD z`=UuxUrg>s_C1}+*?6Gkm&P5VA+ ztVdYJtvd*$)C; znIUWSD;6tv02jcTYN;A!^$d}WEQm`k$`z&Hl$0!%2wbOaMc8w=jLLm(o zepxtvH*N3%EnBaj*FCb_c=JiYc@Oqx;`m++y*>IK+AehUYMlD`TApMTe{dCZZEFS5 zi2C{Md-&+Jw(TC=g*mG<@%bR}!GefLXd*VaMn$P0bwn!@EXfHM_pnlFKyCzSNwL`z z`fsOt<|i_&xH44@HEmNz)=*`x@;n(6B8=Exw zC(D1CtzNbkv!Q?Zv_X6*G%)e%>2LAbvo#g%Fu*F*tb|O^tGc_+8=j(euS|=EG)HPA zaxv!lwR9O+ia$|?;uer$x4ArctYDREM2aW6fe93VnQGD`fH0k3X(>YCQZ;b5APP zZZ`R`ROa!`OLb5Eje1$IaxZ7?eO?y5?1x9GP9*gy?`jb{c`(Z%Uu$4>&g0z{Nujv( z!i6X7lXO0z;Nr3DxFITwQ)(j(9#T9JXfM>7*-(=CfHD0R{kH>`_Tk>02J>TQ!5Ez~wgat%;o<_~O^&d#e>Lp8B{oEc!lw$J*ly_XCf z)fGo9qDOc%!c-f$(PY)E#zn^9t9z!9s6lH9B*VZWYrj4;G%>j*^omfHnw7+w zl$8yiUfPhlBtk@(x^nkcv70XA{7lK0fBwBIHI{>V7;mat{igNP9=d&fp)4nkkhNpm zAkml?X`r2}S}On$G!!Bgvr<6|0N4g#6Ak`45Cpc~+0^8#knsZq2Re?|`?dV2!v#Re z`SyF+bu-d1SnX=mMZ=Vh;I0MSMr+o~<)~y|UVx|fj_+=;tb1Bn#r21C&(9WYM3%pZ zB}cXO9G*eSYdpHP@bX=R*TWQ>@O_7oXVVMev7$Z~)(|d|{b)9MwTg^CYAoy?3zMK$ zV}h#^+n9Tz=M|A_c!Q}h;4~XZ6~y?`hxl{(Jr_m(Yu7zqQ+vi)w|V+)djIS ziL!_iE$ZsM2hl=^=snS+B_tt2B>(Y!-}nEU=P~Zg&YhWC=G=2X=iKwrD$9t`(RIyb z)uoqS-~1Z%wXSB_>w*33SrAp>*rfJz$4u?IxhIeXp$9o6m|TF;S0G1^PaESd#;2RQ zN~O@_wE0e^-GyIr1^(RK5_aVNx)tu*T1uh`Vjq$>$VqKsW6B^R2jy@=$Tbc5QTjzo z$T@fa{KV*na#CoGXTQ@@$?ySfdyqd;@K7LrQiqpO1!WM?2h4TwM3S7T)Nt&~0#&ES zL$l-4d#;k=zbqEC1&=W8&A89%@3r2KX$zPME;td<%_OZJiT`P8NJ*oeOLoW^ayl?t zNf-d}8D_BP4jYUD7O$IDB>+&BsTeB2sX`*53q1E&{+{oXw{XMN@4wHzSlow!$o4Yz zcU?BCP$HTz(FzNlj4i?WJY^Dw>{au3T(ZiUR4;%T8=%VLf#OCr2U+YLF}^00kA^>2 zO>5<{xc~ql@CXM;v!Q_j)Eq}Vj;M|quF-Z;7G%+dxgU00|p z_>~u)y>Qm)u_xSGshvL(gD}^^#SO;j0Mp+&p~?I8Z)d$&Lx1K{gh|Q#5NmZPW!T^& z^j`z}=`qN_Q}vljEwKzUWg(L~TY_dJf~yVy)2Sg??iU3qU6&M5802tmViDZ@x z>|&vKxmX^bt8k>Sxik&7{PRe6hk%xX83r~_R!SYp-Od% zNh!a}S0ji8mXL#rI@+P1?*+KOgx@-73VU7e8)VUjj5m?Ng1ZFa0?hKrMx4@65>GnP43e$8Mk*X~6lj#_uLqCPWj`M|qX?5uI9=|gwCy{?y! z_q0m!pJ3)3z;D?{OxUcw{Y7>v2Y3*)A%d5H55gCgG9k4$vUl7%uyfF`b;8SiEp?}0 z>A-1Q>uZQ13wbK>3iZoHVw=}89-2x-5x~3^#L1o2(XU?sI{m6e0p$PxoC#(t8O_x zP?NjXJyWz_o90uS* zGF=WBz%B)gK4|=?rB48$ulBnL10dQcF>Q&a2=3Oydj`hjKCko?pBonV=On*UJgm>B z+Z>~=NG{>n9&5RhJDU2^#quEg-Mal7C7 zC0C4LHshwh^x=sX#WBQ0iYVdR#6*iEWEfDrG`$Gnm&}6!E^@gPAtA!rnCuyR1`HJ~ zVxUKb^A>-tkGR9VJ2XBf*_xlQP{nD9rL|bYM`=q8PzE!$@J1m=uE)X zHMA|G~Yng!PqgE2{TpQ%-04vT{_E-m5JtUw+7GUpaQ?j zEJ4K-Nxrt+q(_yOx>GCOqT_UIY4$EmHj!~a2Z^NON#kbPa@aC*L$JV?wf^-yqNR zkwS5@RWU#J1(SUT$lMcFS)C z{}-Qk(rEd|{GmgW3!aZTZ%d5(n`L~iPkL$RIJ?7}mZ$cT9y&$fzy@N2PAsWHdwMJXPaJ%SDgq}KGrkgXX zWSb|%^>Eq#wOETW&+PETWRb(D3OTRYSb*7lRfyZEk8io);-W$tDji>PHBMJlkIcbL zbeM~ezyS;MFUj|<#3Qh9U0^{a75TpuOZs6?T4-zQF<(XzE=ZnH*+yrhLSxJWeBL(| z4RGTr0U_SFJ~AC)a=z@o^}8nr8FnagashmViOIp@YLeTC%GZnbyI+~Eizl{2SC;++ zv^~`R(rkH05w<6Y>f+fHZ6r()!`6cwOhGq;1t>yuP)oD{7VnZ+mfhZEen`)4CX{Ic zm{W$BYG(VG6Jv;zxGnA83is$R2F2Xd-2ctw^yHWK^>an}d|)|C3(NJ=xtCluMd=9U zGE?0uS3vW0)0!$n3+!_QYm6K{dTrX}%cF36%Q|qFl*`S-;3|`_$xaFqO0GUp-QkNwG;EX)>CHK7uv6uxT!!M zlJ0Cw#K|;FMbWknTJJIeAD}ACNn?`2QSgG_wT9w?{6r?jQ+Zxasj5bZz7_2xijx_F zU}k^B$S}z*_vee7WEO{L1_I$@Iyop{RLEo?ruosDInYB(&p;)5fYzCb$Vh!dG+r0r z57%Lliby0_VH44uGbq+6-Il9d-OOdiYp<26s_g>Ec#HY^NM0lPo$1)ect9XP0RiYY zZf5#$mn81^N?q*U98J$agO)Kb8NQ%WwmO~aTN67^r&|dvU5e<}=MoO%QVJ2=-Z@2; zDu8$aG=C7jE(yeFL)X}0!K%bi{{DxqF!4-*p;Tt~vLF<~Q}O%2{P)_V9Di;>mc{|% z4Ai{wbd40xkKEmmPon$Db5<*{`EIAd)rNi7U#}-6$uPoCh=4@~#wFy|9$K1xBhbG8 z`YV|dPuwtKE*bD>0st2fs0jKZy1`#i_$4ESz`{}G?B=gOcMuDTiYD4ACtj(Q?f_yw zNv9-DqRC&j<4AZAA6+P;1v6n5D1H6}2VR`kl z1}`hJ<%x`nry^ma_9>-LO2f)R=oWQ5^!x1bM5TZS(D6i7uK&JQ%tvei!{bux1Vx$7aaPi2{gDrqkJGznd#z7XvVg>ZhSQPEqu7xU^ZA8A`_nk zXLKmuxtT)#w*jPn0MAvR+2Uow#iA^qQxaa8D8N=Kfkzi-n9dB%v)6u{U%sDz4iI2V zI%G5{dB+oIve{EkkpsCSh?@~LiwA9adWP*kCOhPWV;{=lI3DLCR*PRss~|Fs3mx0_ z5SEpqiEH*FJWbjI!Li~icZH5GDB3K(Zr#=1#|22HIuXqeGkS8zHl#E-6G7TGD z!lw?{go)TZfDVO^4^|QZsm476ro3rj$LAGaN*o<_OOx57*=3d{eD2l^{MuWxE?L;J zMAfeDH$xn4cj;OL;g2{(;<*6vMnjxbF+5r-C5w-ub*SQcNykt8EYC^VG(npY*?={9 z5eMrtW_uAqDG{Y7Mm)yl+WVT*tzuCN!Jf)M(ynvk^Fo_VjEB#x)JF-5CZ>elrq>hZ za?2CABYuZ}Jlc^dpUKg@^aZ(HIkiYW+Bj~BlPOs$y>v%9mK4pSP`vcfy^%Vem){U@!Aj+0uio3FjNtjQS@hgh8%#~f$6BK#x+H=53mw61Hd&tinyVyxLI3s zl4b&M@e{of?STGIrUTCrDCeLvK|fGh=Jpf>xOIGVn3VvGvZ~QMd-=MSZr9@?sJ&HF zTfzpIedz!^siNfg9Jf>rlLg(g&5WDeo>iu&lm0MFi_}y@`ApMp8yYlNX7J=h6ed+Q zdKcI5K2NTn&G6;S5cR{l{8B3uADhjE=%%ePohDt0_?ECGHCdQ*O7b&#O91dJ5gQXA zHh~4CG&)Me9Ae;ch`t4|m`N*9aJ1@6I&C#0A-7}TZ+Oie=KUAz{_%BJhA};HglC0Yk==CJ#v=3{Q-k?MsGDf@TiIT^i7w__2=aGys25NmR;#2~j2C8oNopT_%>_B!n_c)XG$)3682Rnk5Yx*0~;l^$jpf-UbsaRcZ@br62irrINc;fZqi3Mn@cOczWjNsRN)EmiZ~Q6O>_qGjqwfQkT}{82oGzC$z(+q+bUY!!SE6d$-6KLU6R8y_zf3D;=!Xp4(d zbQGs97rt6;Yf6XxEN9&+3Msk;Pfq3UtTdBYtp^guCbsxF!()4wLc99;X01p))xuda zT3v3x1RTqwmsIA7^+|=))x1m}`E_)i;WZmw;gjj&nz`}KuUHr7YTM{!9=R6rQYIC0 z%|Tl<0S$@4G;SD})Kc+KF>x{ubTO)1d9-3|ly2V51{4A2F*9S+d4UJCGE|FB5B}m7 zmW*x4-T}r1O@3mQ$|~jb@;7A@?F$tjH=aDuEx^>&|3BX;h(H7rz){u6?&xXAE@`lu zruB88wK3lhAc0hevp=ivU#;0Tbo^2F+oO?Y;)RS3U{tpQsi-Ic+AQF3Nyv!tGoWN2 z(5Dp~Y9bQZ?y`3rHkQ%Pb zaP-KSiMK?<@>5331_)CKbY2U4XWKj2&rR2k`U0MqF~cCh00UGBFfLN|N7+MwG6~krRhDsuYBz;kNf-GEOkSxpObb$=hW%fuQd?sLu1?ht#LHsUC*=iE; zG9Ts2bh#u51R8PN46xc{Z=ttCT|sZ|*t)l`Ff1kXk414g8Z@!bIMhf$Cg}{5vDLHv z>xbu^agC;PXuy*qx|kz@PQ{>cmZq3TrOIKGPn8Z~rK-cK_YXwlH=-y{6V&~I9Xamf z%T7&D*5?AxP%UMGXQ+`BL;0xVAzW%ei&Mi(8PzkG8-v;E!cZ-49Me$<-zg9*Zu9E<=_k+A4~Dr1)m`*tS5JP#LkUSEZ2x9UYU)G4U2@c9qTirRbRLzJf0#oQ@6QeE)QXP#gnS4DP zJ-`5<+MpRcKt@;MJpy=TBFWjZ4WFk*R$>)o%o>wLGbeOJ<(zZBblj2KFDMa}^=yLd zPQG`^$#lzLY2>6HeQiSW*Z}1dW4B_Mtk3y8wq><*^aGtTG$F%C=TS0=C_wSW#{sbf z4|bYnI%mjw`g4qKX}uCQ!dd6qvr9R=76IAt6GWRro1yQFj8Yli87Of>Q+N8jzkh!A#EP$u7oeIxd~A5j$j<=qiAUVD`z^};TmNfd<^dMv(K^AZ%A87NA-2pf z0Rpu&_RZ}rln#R4!Tyur;70Q>4+xl+l0@8iv9jTb=%|2_+*Jiyb85@UHT`-BnLf}o zjQv>e_?i5M9&ncTf6;{1Z)}vF1407=zCO=vs4~F&`N4@XUw{8?-&4Hx*kVD$iOyPH z0{YtcBPlMilu>I&ncgyur4(34-gMh=3eoZIC(ZCIQbAm9ks*lDK?~>TDNMk@r(rM} z$=ILCr3yGzZ`v=U{Y>zQS`B^1%>3}#{;LntW-WW=KgZ7VgpPB@bLqr z(Zuz%W_Bz@oBPchWzjD+BW?kN&(VqC9UgTR+H{aN(C+_jQIP$!VPQRNSmgkM#3Q>U^!jOdS1ir54{`P)C5+ zUst9IzKHWAOH0yF%h-3Th4WWeV;f#d(^FGc695@W1*#;GNtRk-tY}nQu*Y%hitcLl zvTy5+V;rynt_%QR2P|~^&!a&_2~@&`z!b3RniK?=%I9UlpS`lCUNt}co?x^+Alhom z_$_~#-3a!*R?=iju*B)R>?I= zDk?*COGiIXCs6y2p8&luPG@)5N2GH;vUhjWM8>8IeWf5|h-N_P(($E?L{^jCB7azsRT&2dIjGk4Kpv6HN&&zSro zCxY#TsOW17Az~j|3lv`;M~MNa94M1-c*p`rkdR=*X4Mb<{WEUC=?N~QRi=ABn~k_~ zcu*{weSvA5MHpOL;cjb*#bgj^5}CqOqlekpx$H(bI{SjYHzC#6bAqzXf?CHqcHfoX zKqddT*%3t+G66LPcrO6!_tK5kyEskFC~KPt6&5GC)034->>=6SQifIU9A+ZR$SJ4v zZzkJflyfA707Ux{>{UC)>5jYm;Qx6cW;0fMXonPcKWok)Y@T5XvaOl2H&0_30$#{l18C7l@~*K!uH4xQ zxb=@h(u%qxm7QxYi?%ka6y2i?gzh35`5Y75(&jFFzm;as0k9ctQiEKGlUUM01UV4D z+Ug7^5jNo62KZi6^9$!f(`;r4HbDcz4eDb@z2136+{ErWY#?Ad0HUmXy=L5I%9_y^>1Sye`EWZ)a0@@v-I}; z1ljd*qrf(`Pd|*$xs9ECLO+y!zxKnd+aCvgJ*v9DQ`xRZ^pdJ*Fb*K~EP+zna7n1L z&8c_H>8lrf7g z&;U;3fC3G;F<{Vr&(bO?Iq&*(zKb7Q+!Se)jImCvOm`3(Ge`uGIy@~dd+4n4EUF74 z=1ek%@Zb&-ZD3}pa4|5r8L5Z~NjdCSwbF9p)CcNYR>mar?@k+5Yxy;xRz$wLqOSFdPDE;_IU#^bTn@>pu@ zD5-rOE+CGjK%!Duvksci{U?pXr87_=I-yQ~+!oL*ZEBeooK~oXrSuMC%jZ(n7VixU z=Dszv{y@liD1Lb4*Y!K>ws`)Rj)QSdCjtA&p=2mV55h}ldgvpQkV8q#ky8QFZxkz2 ztD$aIMT}LsO6q60Q|{1cFB-E5m&R%qWHQv+3fQK_p`Z~j7>*>lO|_rDg4i&!k*E>_ zD`<)I*i|FEK`*F53HUPohGWEeQN{yApgyG=7>*L~9>1xq|JkTPO6rA8x1uO!S_{P% z`#7!Z$py#A%p$?cbferL2Ea6Tvk}ln+=vfwUmTq}TYDB!R{gtCwg|YX5fEa5KnnP_ zNbndiSXKUgg4asIc#*{TfK%noLn6o{*a+|=5K5-m41u=Y$J-3EQ-YN2Y=WTL!fI_; zoo#9ilX)b28dX?Pjz~r||1jkgRkh%7yF{;TLLZPH6b%~&MO2DR0B`V`7!S;@M3Aj@ zQv^zWWt%<_hH?tHh7iPQ1N;LVfR!D%2o|;205ZEiE)X6uzU|?y*DLSEL7a#u?fSgd>A|RxsCZb_u7rjjjp#wUsn7E`8hlG?qCl@yl%tl2`Roy#6`rk5$ z7!MDGM~MGZxj;2r?a=(7^fpHoZa^iZMp!U(y5en=0Xk6WqLYi3jZ+&rXZ~eYG zE~?yOR(FB=Ge-3e)dsXUk0wLl{#x+Q;0(h|ahLnmr=(2nVf}FaC&ikdvHV6@e*Lr> zj{?gpX-wb)YFwKk`Z;kp{nL+{<_WXL#VwGE8Z{KZRvM6~^exrtK7Sh3J2qSOEOW)y zv+d37Zq1L(v+>}a9^ds2FN?NXO(yX|HAm;}Y#?D-$I0TAS{il8Jt9Eo-Ye%sd-K_^x*b)FStUPLaQa zej_*ACV});Lh;DFyf`LMouZBpJnNGud*N|qwOezQ73llzzU9Vvw`@SH@b{f{i>~nn z;1mUr_s5Jw?c|B`@)YpB%`BJCTEF=@_}d-Jjl*umfY{oTvvrj4Dcoy4IRN`6 zNcn^BVjk5+3V&Yf7O6}O^F%E@0`%>%6JfJhN$H?-a7_^dm4UGaaf^fX`nuMONtki1Qh24q3QWy7GPAJ-`(JYz<$W%j;kIpOUFS4$@+PvO>P1wrLO%$Olaa%)O^Ku*g01(e{g%?>lg!K!|G0O zH{Erh(B%p8>~p|jUjyOJky=mywUx!-)Kayk;gW^!?pqsC=2#nMX^)6$J#dfEmQPRA zH$;`YFmzh#9-Y8wd*gQ=!S2|EI(EA_cM2Z;MKYbDrE{*{dv;KCK-~SGc<$ z1#06}(~0(MzE8OWA1Zf+Nh>sdjUkDd^W=3WcKHk_NuLgOsPit2^54`Mrn{T*Rg<&B z{J25N)B0P`qy%WWqvKVbRhAL_v3s=!VcQ_mZ|OQ;SEipH+H63Us;x+eY_E|&a`};)6-Zi0b;1h zO`7KDFJ}C?Grix*!7;^T?ZR8!(8reKjwSA2y+^oD@Pvp|vfwMC` z#_Zh1-%!}dwO^B8~x3_c27fPyH%QQ z&Yb}%oul<5T=@Hg^vI*+S7bNU=jL>Linc&|A?{Az&w87i>_jsgb(J$W+~-(r9os2R9$^X_F@ zy&tncy3z7K2MgBZZgnv&3vW?h&~6rGXjgq4#l*2Iv$mX+gke;TMxdx}Y|ERFD@orA zH+ZOI@c-^nXD3UC8@Td~25WMy3-?|og2@S{8hObU+kq3L4o42(F*fdV=0&;y+RG6h zD&F!u20gx!U#0kR<$g@&_(o|JYmWJNh(wDux!}TEZON1Qj}#NtQ6)>gcV}XbhVNnW z5)p33b0%FhvFq*UKCzGACMkx>yj^%(Epb2A6m=00Qx_X(cM%lBzO>8Gd)GD&9R?)- zGE^SjrGu2c+dyNI$%xCv5W|e>pN$r)g)6^H(RM$_+~?fTekWtV48dTw-wOqalUnHw zETo?dN@YZFrveUpIiiC3Hn*c zobHRz6tc7v+0sS&q|FUYckDTT?D-EwRu`mkCL|KOe?Jr)Ifn=_f9`Q6jchIB3-B{> z7Li_TNQP%P$gIZhWGMO?u7NjC=MK%`YhrcpydJA)dHSRtexWr?PgOGuKU^r8qlK%v znj2%w=&(huzymcsv4ui-F7UTtZWmG|bNs1h!a~kRl*`IEC0I78k}9YqiVoX_?I3*u zyxbzPuua)BYK^e-e-6!!ykXPLgk7ZQHe1huH4KmduEGP7(M&)-%*~xim2QfIraJ<1 zw*V(x;ctPt7XB1axPd9Uh-Si~|Bf&$a>f`S`9GO%e#dknTgril7kSPf2`vLRN7e(6 z0&WIeQ>sA#1qxAmQ7@1`4x>xHo3xB!`ra zTIHa|IAVK0Yn`z*m|9!}RoYY}VJF zU`1bOxr_v25%@F_vPGvoRK1XF%5Oq@ zm~wm8gMAYnagkhg%8b~g8-KGR{14>b4OlN7birU}G0W1E%D~7cJ@AoZ(KD^EHaxLJ{JB8N;BKPyq zyQ9Bou{4)H$+V8KL4&b@cneKB;(fN&?t8i_8~Bx_JK&Dh!`S;GwngM6Z%K-Lw064b z>CeyICK!zJ%LWF1sMB>XMb}B&3QV+X{qUsj{sbIskWMUW{XkQ9??e+!H1{7chg3;t z{7}Hm&)K3=e)Q6&tZdi@ZGo+V-s1n5WXNy5@Y5)$AKE*T47wuI@H(05{s)TXY`X|F zYW+nX)YMz_^pSH=h{~N3WV3=|G&1^2|kQ>=R+o3XA8@--#|+*;0{Y0W33H z*_)@`J}lNuzNZ!rcOb(oG3-gQEGi6E+=@+XiC#thmiaKN-Ech6n6H zJ~5Vu=|0}X&hXNbmH#BGgwF~!ryf51S@w#$h>)12obTBP>pfu1k7#XRjxcl8MJ zHS;G-L&Q>K&<)?)AANm&p+AhRJI=nG>8Y7iEz`C>L!ma?C^}J}M7@@ncR9@xg9rIV z5nZ2zw&>1q!LtIA;3QLobhrDfj~6?wNVOF4TTMN~hnx4;XOQ!p-qUNxlD%bg=Q+UP zvRjz0*CO3@V)ziOl+$43Uhh)p?15B`LMzU_K)uK9mxYW)1FnZ~-MnkuBnb6Q$Z`YM z-CTRpwfXHIsJsWi-Yw^9EpypEF|R>EJrm-b;Nkz3TYoiH_=IbXaD5}7zH9lLZ9vQV zLb&KB@V6ABuW@MlX8J`$PwAZeN@#1FTQ|ePu&`JDx{~^FK(FO-z(ChDJrHB{KC$IV zNwC&8rc73U%NBG1ihtG0(Y#4$-#*00|EcMkm)+>ov6en)B_+LWteXuB>Z?KG;s0cK$M< z$oNG#eRGzKrrWa^q)96_;A+F@Tg~=D8+5K!?flDwdFf2twF5s==(q#9;twoKMAh%X z*N?tFG%uYuE#uS%Hx8i_zA?3X`P+P>>tmY7J#}e2PwX}y32aHeuKH%&AAk!GKJ}yz zY}0LTCB5y(a-BE)$7x2WaoI0QKg{#|_!?&tUPw9UO5g(~3|J;!Un%kTOkN}TmZ9}6 z!z8S@YmH!KnG{^*vA<#ZOjMh!b;0$(xU;QdS?81pv+1(=Xp=oKj(AhZifieVr;gf{ zmzppz&Wa1dq9YKhWLw)-yOIMgRo2W^PB9uj1)Go;kJvrhEDd~p58^A+H7e7TmCzB` z^kQhe+r0P`RT@}>P+_PVA%nLDNK}DmGdTlSAB_D2QHFDd3bU%`*16SQn+1#=_AaQO zH-5urg;aN~A=ayE&kzAQ6&ABb-`otPE>8WRv!4u1Cw|3lU;)OC&+~!IdqRMEK7i1+PP@j7s>mzO?v`QEp4rCKeYAdFwaV@J*zuhNm9Red!;M&eq+_gX%Y5YD89v zVWAnUsS0|g7 zxArV8?`FRte~#qwDUDkTw07ES zt*Su6mj}MHZp?xVjyXGW+5JnHiOlbg0VIi43!*L<_^K2GZO|2tV7e6+M7w_6ga#Ts~w7Y%lg(8CbLq^g51nbw?+lBj2r z5`VKKf&7?KSN-PVMMjYy?E!zTUj3X3~ zV^>YqWB(s0{vW6f5B|mcIux?pT5LGBA}(_ne3zM7B`lyB`QQkDE55+d$aR zLqDK6Wz1=$)IKa-izIx>Q|0Bq{iloYgX^5ixEm{uwdK<4B;g!G(k1Gp?L<5#{tvYN z?2U=jKTxyj^JD%n<3F{Gv~Z-N2kek;`90Gss(A1A?_Nje_bxsnt)6Z)9XvhQ)==c@ zSJz;xqBL??Z^L(Qbyp`aUJXkqm<%@|`-veXZ=}!aLPUP-uYI&vO4iBw4tY&>CzE=o z?Tv%;hl~@gFP$9U1G#<27lmJ5(O%}Q%wmV>&RQEXVHBpL9b45=o$ zbgLge#o!Q}mY7)z71FM{`>gVX$_~Adr=-W1LuGnb3ykPB@S1p)v|7~Bb@Enh_HQ<|N z$=|Dlf|V<&D@LT*xm4&5tr65z=-&OKwROfAQ?XJ3kId4xd9s$l3&tNb|3GGn=(wkc z`g%%_iN=mv+!pL)$lZ<$SGt*YYdl%x574NmqlkZ?kXkrNB(%m_MXlV>?8KlqElqDzxQX!eKah{>4_cTkY$?fvj8289^z)Twysgp6j}MV!pAL@h36=2| z?X)}%Z`xCr?cyjBZ9K032eNh=f2VS3pmGfdHbK&`gQE#|Nc8^%w?a)RTrwl;Im$JWftM<6#Bm^GduHzX-6#n{aYPCgg72CsZ-XgVO8)DV9m z`#~J+dk~gUTdHUN+O8cVz>oe#hSQNhiBpMck_7W8jmbD_VWAyvs<<{!Rxgv#c?RBR5@y7EiS`k`25vm(U^0dGZ-wXvnH$WUQMG^4~qY zy%w$F6~liZ{{lfL3u_+QAU85kC3)(EQ=K~7>uh?N*vE-LMIR)RbPp`}i zWG;RBWQ8)8R}vGG3<=)0MlKkCZE4@Au)j0#ciF`CX=@wx_Z0VvgU6+9i_ctEm@dLU zBuCDQDoEAR2Nd)?|B7aNy5nfH-~2SF_@O2vOXYA)`LeuQ-b0c>YmD_Vn%gnK(wi_g z-phFy?Os!NMR?c$IV|pjUA?)9XVZ$Dv}ykOApKT|uCtqc{IGKg&ENm)S&lzsl-OBL z$Y0kFhzxIL@j5jem%cp>6*-YbUR%bbw}6Y63*MN8@kS)DtL|uHb=$53*)<---JA28 zxX$}KTdi_lcUhB9L^Ub9akLP!O#Y}W5PVObv-3#fv`wh~ikt@W^d|?LWpw<`pqnjr za@o#plA(A|lsQSSl4Zf-px`Ik?+D|ux9Iw3ZwFc|zO?rbw7Al_HE7|?HwGB-OQTp# z4d>UdlMCSYG(Pct&MRjF0{GN<2TM1{hAw4owD*u6kcSrfVIM~AXr7!7>j+(bqHODD zDy&*~UwHW569W!>C2w{4jSwv;eCseKF(=vR02~1kY2lA@ldkxg@K7Qm0xSFUw?^41 z_xZ88lJSCE%BfN1Eyh7}et4zvKM+;pT*uvV*cRBR(&8gD8m;d{5A7r3g%=A^f3gzCz0Qd(TP( zi{9qnqR#?gHshmjk2+00wNyMDBao62thrj7XKYwtP#1^2x0N=)J)RB@g7iB_;_O!Vz47DU-J}s7o#^r|epBt_xheVoCSnDj;2fsA7Wk>Gl zjFH|!$Do7$0KG^wLjQ7o1ghUkeVKXC+i&@+DzWA4s_WABY#geII|S4*DpxtFFcZL^j{-Vh98~BR%zwK2J25 z#vWUqnN20dsX8{nvXVT?I02^}YK8VYT$8BWH*{KtUhA5xo zU4>6vCy#sneXD(N4?CJvW*D_VI4LYp7FvY7ckJT1aoj7*$QiOcS?d##j2loB56zY65&*8`XC+ zNgD0RtN7yZ6eIgvbIavEf7Giqs{M|TqP_Q5T~;eY3tLG+F9PAv$h6e>##H-6J`wjVlLE<6=J zJX!jfcncWTDF1=RW(HQQ--z#QxFxa%gjk)e8a@B>cOrZ=*u#0TGVHNKpmThWn>-LO z+qw4RgJT!lT1x_`evK$;E|o|8nz_gx}a7XsRL5I zzwXqe9217x?Ztn>fdP@Tvuu4_^T2L)BNa@?oM2(|i=G}KjB7%jnoID{`kOc*0FlGJ zeGCWMPjcIZgF=dUwD?XIz3XEOK{ zQJ=7f&Alc-r%>gfQiZ|?&%DmiG{41gGpOJ8=l34{(BLp~#HFR&xjqgLpZFVT8Y^#j zX38GT(fnOtoob9$2wXwg&rxurv#ew;lJvO(N=?PtHkE!6ONnqC2AF4_pxuu2kU{56 z*0T4uMEk*12f_QVNQ|RgKf^=8T$miM{rAvoK7t3&cPVyUnHKMw>gTd`5AvFqBOwJv3x)SVUUFnPKOG)h z{#HMAIeD)X2HhdXFtWrcQ(dln??Asi1N4TMtNn~mRQ&+WCPhtF|FVnpPmvMQO@spXzJ&*lVHK|2jxa!L0MB zuh5umDww|80Y3f@wBi0~akcfw5p8c#vhWw!xthAAuR_zq*XCh@caHA^4a)Q9$Gcna ze8(i}Jk6w|=oQKzhU3@X-WS_lZcd7Sj3dMSky!Gvh_&V~)ch*k0lFci0)6~t{UwB7CZTd>={WPF zLLL`UH?_q_t=f7Dzn2Kv#9v_U=}QI#o?a%qO7~PazlSK&ZT)0PvbLgO2rhn@mmo`- zN0a*hno#MAT$1r@oGgsUdT!}G{oRu1pHuqk0zGfa)E2yq6IuSY2hkI=zS_HAgBpunsK7Z8-A+F%aGzHb3q% z|5N)J)3hRL!s4&@!Q<0o%6eR^#R&v+?Ku(N*>m0Xw<4L4C9#~}$kM8Gw|}7m6|Pte&i|t>V935y2k!1wB9Q(*`A# z#NC&(Bo_nTc>o7Qd-K=*dRT_1A)<;g5}E5sQzvxBV(-+@8L$9r;5+ zsiPMn*;qp39BD&eJ0fD9EwpQC(sa2z{G+LcXBE&SCNC#;XMLl`9LMi0t+4P6T}aV1 z)3s$zHrHjTnH2s;j?LJ>C5qYH_Eam(^ z^#8W1>&i#zy_`IHqJKv8_ z{(<5G!;bknYo0qj`zy9w*D!PWqKM_3qNRrgiK@H$D;eD~AHw#voBR+lNv~3};PMpR z@>>gY%xKdE_lPDK#GO{h-Gpz_OFcbLRiA{`E!9P%=bcD(KD94r&}SS zhG%l(0r2_4=ntuf&F|J1{d|AL-yOGcn{h;4NOA6x_f`!oKvkiS!3zgRD&;7MTFaYJ z?|+~Y>bw;F*e{Dmf<5ieUC4-L0ZRhs#C(Ba)AM3Lh}oZ2{_v78jMO)tTp`V~_7Z?J zm{61_di2znB;IX+MxS0XetgrkXuYL}M4rn%HORhJgnv=juV2gV91_AEA$FLA-2pKx z@G0xb(xfT#{90I4!H0+Csj9vLRsB_#Q(e+s>d9X}4l7!n_w?|(^MT^+!KR6f-iZ92?&U82tc#Py#~&Ln!Yrg+e8}ipMVkRWF)OKuWRCe?1tOcM zM!G98vKM76f5g5;;L2hR8~Q7j%ck%=qcFs+>e)hD+$Pn0t?O26)O9XDiYiO<*u;@k13v)rjO_Pd?N{f z5F?iikC(@e@1JK{%WmgSz&HhIXiwa}#hJ)o*Ef~Me|C<*JzjHn{yt<gv;d8)nIt0F#TDRa3DUT7Iq<&(&OihR6W}-TXDsNe_relg?GhzFm~9ZX&Om zW^{_heC;aM-G5tj>0D-e=S2Iea?ndQWGhNHmaVz=tdQ|zLuBBi7;qc6Qg~0yP78mz zZrxv%m}|}%Ees`WDY>-B!vnMQ`OLHR;JW1-{N3C1=LgM9`Acu60loRtS>?4tNru)^ zPHvvF7a!~H$h7cq>;A??22>-bLRP#);v#(DDU*O(I55JLe}}?HTl}R=lid(L32U<& z{0&?%MJCzouSn^0>6(r!!iN$XSJIg*0~6j)jn_a-)jY*`PLJP#h~@!H2K}rw^?xvvI6>(kiJ$<3=V>bA;%X z6)CJ-yQTEpx3b)CuB~4&lVfJUa^l6c4X6ASi(Gx)2k;;}dLdqOidVN8b*YBKh~Jmr zecb64+S=jaG+#;H$~1HQ4`j&L#rzgmQK@aPtiWY$zHy<*y5R3YrS%aMrWDjE5M^5S!No)0oCl)!bl`K)!<=in)HH*b-jmY?PD_}3D< z1_+$^bn0m4Xvo84asOK9=T5|i@URc*6={MDLN{sA9HOr@!-FKyh&pp0~YHSty*Lq86+KYPMqsl z`h%pZOtfNY+4tgRIn0-mdA!2XrG1^BH5uf{2wR%SDNiwLvh!j6J8$AdA3<4?0o4Of zH2F0=9S7rPTt8fQ-eV_@hG9esbh`|jx?~%%Cx_QfgUn$Gbj3xKCHc8L152YVxu5U_C^f}~VfZ2jHWpgntN4D%7<;(Ai=$Ah>-ssnbS8)o(_mo`;Bqw!C zcbQn_XM&BiIHlde13aYP`yeK_BJFXquXnA#TMhf(= zV?_6{#=+62eaKTlfi<@lgzA^%cO~;@94sFjGdspE9}Te>oLwY;%`lHcFl2w4DHBA~ zUyNtY$M^03nT_4&FcRXY{r{MH4|ulT_y1pq4zp_SqAg8|+SF>(qNTQoicng6Q?Y4F zP_tU0wOS)ah#4^w)K*#)F=Fr9dz8}u(f8;3d;C3)b8;tV<=ppu&UIhc^LhpH*3>Y0 zAXkPBd|P3kT(mK-SLLP5=m;M}Iw)Q4bg665TA;E+<)#A;?B0xrqBo^+;Kw>NV;KA$ z6(6mE@ylOLRE>l>#T>C`AEF24P0WSiG&S6w_qdO90|Q{)(jB=KUL+%DUO%6dz1>4m z+1TlktMWhEB6x|UvGECLGkD&Jg}ZU?rOrBi^@IU~X(A@yC_m$2jH{yron`@V=++lj zorLd-K1_Kb9Tz-3s>)a`bV6>)8r_7;I`lr*ZV`2=$mSYf>-C0kwX2mC=rJ%@8xuk> z)rxtpUjpSou_(L`M(Z|=u34r;Y1F7PtH%BkHdkwCk=i(yWKO8bgg6n%1MP@yC(0dBL0I^EL{3bS# zHIJc*D19UWhjpIAZ0~A_m|B~Tk;@9^qxE_7TC^+x4}Ts2ZT~WwbL)DBx&&Nqlq)_7IrJ20b3)fx+m)Lx z9l?|AEy~LdBi;ak`{%(HjJSnVEw#B_|HEcc5Yy!bleoP-sl-80t!&nMg39OnoRHo^ zckWb$eSh7EaYK<%^@k$H&txace{$dbX9|$*W+A;1XzG`+l>5H^Rsg9W3Sv&VB$w7L zcxntZ9TuwOaoTrkVxT*GvgOQl)y2kZ`BtK#CaFVe^;mNK{80C)3yr|vUQ(YKCv#TI z7_EuX@+RjS5O;83&7LS$^@95s%Uw?m0*BAWNqxO|&R#t33jC1gFgEWcPNdXsei9E+ zDnv3L^d$}izW@Ba;qZClyA1oe^SoE_xo77x*g){*CwJ3?%?5~WfmYo+gy$;^a4%q> zDj&n;3vdjm8hEw(`!UV2t+6a^w%iK%@$HK%>u?{{$-dvy4@DIqv=f>08GuQfV2wH7 z6Pa$Nrh6_?IYP2`g0?_Au`oaHY6#i?wHjg9zmZoQTZfh%UAM5*6bW66xv|U4sA}WY zo@Z)wGa75yAHsSwBjE+4CXb`j9g`(;Y#vviITvY`7%sVOCsy)@w<<8hveJt`Ucsz} z+eWpGR+Ijr=&c5;8!LhAOV9pDG69yRydX~1{ht#a9@M*XHl<#FK1;q!p227$<49UN zFdxb`DjgjZ|1N{?kxb3YJb==;RKb?jUTqRY4En*PPGj6JO{BIA$A3Gv`rZ4^da}T@9HW1@yV1e z)D)lDJfBI%AdJZNArXzKL{0w1ryuiIOuqn{+(c8RoRAv;Ym#7+e#4YxSX`RAhsV~+ zW;4Z(c;=k#`!1^qpqfXoQ}nbPSI~rz7r17tIrpTuk41KI?dX(XO_4 zvi`VT(e7jK8m-!1#M*oIgd`yBJa^sIULf$XH$|)7Q2pfnCXHh=2T2X%6ES_OIs9xc zsGE+RK;MFJ4DV|^x4ATAt*L1AT}>8+EWZ+X?2stEqeoM2jsHb|jDAU@%;zmbS#`d4 z^CXj#=lr5hZB9S9E+Y$Oq8o5C$E!oqqXp-c;y*D!3B^6Gd;6f*qve*u^2N+s@WjU~ zv(oCica386mYyWAkMVA& zkMRa>3f@?W0+?9VLY%duh*-CqLu^qxyB&vT#aUB;rQH|Xm>cIKHVqsDoNT?vM78TE z@NQ!^8j`X^v~lac+>lyIBRD;Br92QdRJb9xb_$@9PB-EqcD^groTw5#ikx7r73l{( zhV!E8Sz8SUCZ@vH$VIo67nf_5#CGP!(}^R4etzq&h{ev@YVS(1tyvcy;{&W0L+z`^ zwD2j-`cAxwXUU3w5`)z`%6mcV&)h^RaXTr<9kJP2SMA*!F&Hvy@%Yq|4~w~-+H(D?lQ1dC2-_=_xsJn@@UcZD!`*R z&bP#Y0f+0G3(iW&iC^{D6c%Sbon7^i4@rus_TD0Hs&C+qB69`@kw2!=nM2TTd)8%> z2*qVJ)~lM&o3Y|dc_huu&AT6_W!T?B&4r*oF>1ie;5_0o@by^JjlVrxZmz8 zcI$HXbj4Qq#6YK^un01+viD&@GpP6qmIT4B@4|wy#JPtqAcczxo#movao2G;6QxccF`bwc{M-a#-$kpjP8Ua?USr^AMV zbYWmlv8}LmWo9&4bVfGr#WKb0Zpz1Jd7yu|Z{NUbr^k+nihnJ13|qAN)Z4WE=^(hu zteCOJockq7b|nL4E)Y6#^V#6St68Y}wdpH<PDw zFb2kfd-HWa%}k!k_P*nfo{JoiU8c?1hdExMVFAU=Hnh7XK=m$hq$$GS<7G7$BMvb@ z7n+6U0)5CrQY4pPEK`oGYH~!ncTW+&AYG>GxRnFs{FGDrE$ElT!&zZXkp)vN(2e<; z15bqE%0NLZ_wZa=`LCbWH1-V$itNEBGozr&}e>ur;?RqoZB-)m-X`g7m+ z!Z9%MzG-F};<+b&ZKCa6>E$i9Yow5fo5;Wj1CH?DWa+1v; znt>X)X3Wg!7AF0wuEwaE;vuESMO!yTNKujE0@G3dZ5`VPZ0eKh%gAE>poKkNLV^3@ zTM**2m97cQ=6lYvqXRNQ)6JxnQTJ+=@9>V7hc$1*c(>A^xtAsG(Z_B z{So!0r8R%h_yNgILo;Z-F=8|jcHZz4sNw^dhA0p=prQ#7!*?oXS0w1)HL)k0vcq)d zMolnu$t0;Xyjaw7b1qd`&bIJu#&gJoWa>BFN(SA=Uq(U~D$JRjNP?7rQ+T+_oL9}8 zMytvI(UH7nH0WWq7L6bm)Kqw@i>;8WXo;w?#(X9sYX`}=M+=tb#YiNrk! zG)Utex|G|8j+|Vn&kD8bF00R;oWzRdQ5)`v?SlOl1TO)DcS1t{Ucz8UPY8<}G>=2V zA&EV%35No^36WJkL{hkMu$T51q}{HBi=F%QQuq_M$%3BC8BI(?rq`K+8vsfCp$xCO z0t_IWB1KRc|HFO({uiKh0mAo`41SkEx0CgR^*>0scNA4k>rn~zs1$I~%c9l2kYL|2 zife~ss^RY{sYL(pW49}S;q{4Db4=)-XuoI=*yA4yjjo~fV4D?fhi7v(&_c#Ud&dAk zy5tm%{*ROf4z{Z94%Qq4cKE6i{1P~Z@uG|I95B&{NV3>oDg%m%ivY3=Y>NBe)>=pw zHvoNJbxHikR|AOke^ZY`EIk+r?7Y$uGKQn{;6-Tx8&6~sg|P#u<9|~I@ao^c2AwDv z$kPCZ0sm&ex=MFWOFjSZtzLjW0l2CE1C(h`R|LubLz4eTCjZ|;<86zS{G@>D`P&PQJ)D2c8!5x<0T*##duyz2!J zO{>I)gP7VGvv6s7p4aUO`w-T*P%)l?F|#rG`m4=F1^NxH#p3o;VZ;4GntY{`x4yM! z{vR)+A&8xp?T?EZ7H2Crzhy(xB>SMT>w5sGTy*3@JSOee(ANG$EJd!rg!yI?fBfT< zwO#XZ$1?Rfl{<~j>cl8G|SiNWMax z<6Jj_1$>D)E{B36p;gvK^@QvI;~ze?@8w$}%%!YIE?>pDOUxCXkvtjAmBYpPnvy== zA048+F;0{ioI0a$TIGW!%1KTbn!5T5>*xKg`S! zXJJc7oJtxt2GHbETvdUWDdrb;?eHscdh`5>T9NrR_FxCO<$7_ZJEM;(x|+Wktp`JM zBqA-@s>H-KxJx?i2w{oT&cXPk!S(ex0GakdsZEP(09L9X>2YWCI8zHqoE1wlF67a{ zmekb*nlS?jPZPHI6purhr5U-wi!EYjIBI+4TfRNusMg>pBAM67*EViOy(3vI`m}U? zX0J9E^^`}-MN=wmHxCu>+b4n}^&4+*@>VpvF8QQ2Zu-9>S=DStwm+9)kM1{@dFnIf zHRClHXVw-s8tT6M9=BO@xFSnfr}N|kNr~o-TzIyI!&g?ia*TZ4?D#NKafxF&DXyxR z?^>!?3y*XAbM%!qK9Wb~rZ+2Uy3+bc8DA%C2qLGrYO#-W7IduD7wR|j@(IEs5+qos zl}L|$A~)p(BJDKTZZut`ZLyCJd+vk6C<(D1o5;_W{_tOns}xdxUjjWiYrLviHq0uC%JS+-Db_5={uNk5$+$?U!JSJh);JXCT7Kqv(Cub&A_5siw0v5#T-O%i;G#>+p%-gmG|ik z=IC^mE(m+D@#DR46Jz72gFw7CW28U=e0Qfls}cro1UF-ZdE4 zCD!^pHXyTb1;hM~=Iq?B{>55&|8j<4)&0qQT01df9e>zGBYb7{xy2pMR^6OSfwp?w zlY5d&4i^E`r}500M#JGe{S?0+FAqI(dquE&)0wT}Sj(3LIA?x@Ol*QOd#!P{gKP}* z{V%z9xt+8MDi|MbY6AY4+-imP+Nb<`$EU_Qu8`=JbD3nVQS*t@YvwaFhe6nH0seGg zoh`~`JUje8a56T=EAMmhl_x=6O+}!Xn*OJv&bzW;!MaxnC{zsLFu>_w5 zRCb>byQlkwG;PEeL`~vVUfhC$P;aBFg{@bKTuW64y3!C%}XKoJ(%d>m0zNlqD7! zf9%h0aUv&s8?^6G!J;uBCLFCw&KmD!)GqKDqbke%f3~hDZD5scJyYV`pt= zpND%@42*w%l6HM6elj{YtZ8{gTZ2rQ)7zr(6OXRM<^QExM)DtggZr!>h#nU4;rI*Q zG*Gg)2)li;z5Ll(OD4RcE8qH1=s3j_3s^#+f{tYRFN|x~D4OyhTK{*X3WnrLp#2T; zuXer<(AQeC4@J6j+10=d$52Jtqswqe@a$izHTa>Xg5qb6MNW3UDbz8(sF(3iforjz zpk$n;F^@2Z4j3PDa-7sP>!Bn8lD~*}ulidu&)0ckVq;3A;!4Cr04{$f4@ISSKbK?M z_r|i=Or%)VonZOk&w|&7-|9RR>FW43jPMR~zVKbSWQ`%u1I{qLVhL3u^sOhRF3z%- z@gg68aas+6LO`%853h&(V1N%xxTbsw$P9~4W&0%nxK!zB1KW+?P#T8Wg~?35`nzPz zH9H!5_`CiNKM;c?R`F4`%*_*NVQQnWNNB#9vo&!SZ>LnqyxyQPYNv*NT6-no8LDOZC;Y zD|xGNpJs9Ahtx8_&iBcFl=w^aevTry^6Txzw>YN@(plQ~CreaUoFvi;K^Z0hxP7rh ze;Oojdbx?f)AfV3vfc`_UCnP0KG0B)&fOWQulspuHz%=Z;^}I`?*QgKdiFb2fhS^T zDUj9$xrWFFyMGVWxSb`{;<|fUb3n`|Xj|PIA*pbIoiFv?BUHHDF49Hl4A?HHN5xhy zzCCO4%t;vg-k?~o?w?NGQKQ+&=*Z|9`EJ^|=-i@Sm8o!o`TUhsW!+1yKTx>6j)PoEE@u_IO5QXZW4I#xZ^A^FOX2sJ3C zt9;2oo8$~x*ut4n9X50nWK>Z%Iamb&)yOheIq}T(d+hz5*l@woaH?G~N;hLeP|s-% zu6_uYYPrM#Z(~)Qp9|-x8q()B!0xoQ9f$qFc-_zHa~&bD1APxEd=|6I7f0s?iO$?9G4H;Gv$mdxXr6;^j7lE z52?<9^5XHNQtVlKMRBLv?bMIYmK>S}ZRXts0jjcsF*UAdG9m%sA>;KM$O<)D8h`%W zR_B?cXi!#FJ$Cfl^QJkc+D`Ix5!uIW48PMZk8S}WK%VFG!{W0`x!S~&2dJ0+zDx;% zZ0vq3vfEsuZAi1c4$06i*P*ysYGb(NpRg9Db^?naiwOLd_Q#;EX%m!_&JxokFOqwm z8Va}HKT=J|?@(WramwAqHvOq(tTHHeiBR5IPw~PfP_D^9$QYSkjH7A)7kv@VF6FZ< zA`gLq6^cd*2oSwttH3!JWEu4FzfnG0N^vRHW$@B6E^W;Sz4cz&?sh&fs(@nU(+0jQ z8{+`Eh2s3OHe=pU$-T6`l0&HY$05h@{DA6es#lY@)uMFVQd#wiL?6VMqVwor?0NQ6 zur63-L;s%AXRhV{Vip0alT!0L45qd4E+mIAg@^dig$OrZatvqaUO@#|*F9`H+qNfJ zccoEE>;{t%!*N$5(U=UXl~^a1&w?ZyR^SoS@TcYd8{n9fhE|!kt2W)`XNkkntX1PT z@KsPHZsy6Xz17eYN#ikq0O$|WP`XGRq!8pU?a?ZGHHZC~N9wO*Cz0UU#buYa;}O^F zIUWw1ue6;swJDYAq9KC~%NtVxW5XqV&*owoe}^o1U(;XGhbx4Zb2Y2*P+0sG%1yfX zMx~hAzoa)dt`C`>kUQ}oI=WLjz*q(PNSTtWy$oGW-qEJ8+!|biQ*d`l_+KiKf3W6i z-G?^<_d0DhRC(%JH)c@N`rY3q4*s}J9s8eVNZj9x-~LN=&}|UwFZH^Tx4vzoG-TRL zGJNIEXM!>^ey>w?^s9T`kL#I|!>h@-SR>>Qr{g!x$gAVi;}z_B`Ay-0{lY~*zi!@^ zTJZA_dXFk4s@g~PAeqWV^P`=V*-fi4Ig-kTLC%SD_>RnxBKU*QZO2A|E0G|DsRa7@ zw(((dX>nf&Hh0?zwsjS|Gr~V>;`Xl7d*Dizh%uXMJ#j6ax@>(ZOHh*5^5mf4<%o;V zac<|wcCTTQ`aoDW6p4MGp$=UQTi|M3su{6uA$Q)|GR@ExPwRnDp)oE4p>R&QjW!-p zkO?*SU=Vq6aL^_H7SR*54I!hOuaX>wF6@-2ZO zcL^BiV%+gm?oNU)I&5&Qax9}~e5y8`vS#O;!X4$bZ?pH8YMv4myB+Ih=o!Xe65Oge zQV&p@MV#t-M5Uv7id7P_YBj)kYwnZ!%OC>J#GiQ8oUy>e!8EyDjcdh^x#=w<$A%Hk z$2Z9hK5o0i;Uk)DK@=75mkK<|(``t9=&rkSMbV+pZ%LoKS$c%lzP%y@=1-!k;?MTE z5}&oD9GNo4pdM$Jo8K8}lw|8IEPYo`pD=a%&O528k>K+Bs@2?yq`y=@b!=}-&96`b zSUdTSOn%q@ahXkxe0zF|MWbADR1z9C&=cTv5un zyg<>a)GzVlXFGXEd$XO%{nDe4z^yBy*{wToXVxeJ@j0lIRgw^-y5j zsdCFp-+5w-=em~eb$vB?YwKup%f_{>ie1C_uFu`27VVJ*eNOf9mT7bq@`4fi-g_xL&$Y79b5i zs~@dOMtkc1iLrYiqVua=JYlBO|FB}qRT|PP`oq*;LudZOo)5*$R3=kQbi~B!_FAaf z+S6y-`d_~?jw{5(J(`_`_&}jYGQDL@4Y`Iim@J$6rYOJDIDO*yUn>3_RecwPR;asX zpk%l4_8tfOa4PZ9aayL|JX5D-yNfDC5i!kO=`aLh6?tC!r7dQjjemEK@$@;*chp4H zv(jb;9luoyF!SLhBS5Me`*7gfjL|!AEOF@%%P2hDNqF-;VL6fd!BQ=Y=gR)NPaXg3 zQS()G{?{Wz*7;`&K&mjtb8R06#*Pn17K!|;*wSGt1VRACppqQMStyHK&y*GY`c){cA#|5k7 zOnGm*T=4+qSox1S2uxrT-C+gOOQG&-L{%nh_g9lpF$sE1(*~TYSO)P`#X0fh$>%q@iOCYUXZY_F!q+w_Dgc)YH*RD;czb)`!!+j=@R3vo}L&T$En7muM6b{ zO(m!36uWylzN7d{d4Ol?YQ^$03HvMbszsi{(ekeo+&YxlfgyL=>%dQ)_~2nvVbJSj z9TUW2)B0W%y-U9(TjJn=Ji5dgxe8x4YE8n=g$D+Po)`hFB_l~{PAKGg7SnKS&l>8k za{7deyP0zq&G2?(Ye@JC`)N~IF{;aoS7Rk3{r7(mv-fAZv-e`T_~93ieJDUOSH0<< z*d-rORk`I}Fi!@4KvAold`B^zN2=I{zXY&Ny-(WaL^hC~`+-#_4_ZuOSM860%(c3- zcgJ2#?XNOk0ydyd@pk}?CN%YmQoLX}{l|NQzxbZ=5ExboL#?CFcEbc*pPMcb6gX7A zDTXu5`^y~NGWnGXx8SZQ+vgm&ylqdCZb0|IE~o9L1cdex#iQPU?*3 zgg7*b*{!Nm`SFgmE&}2Ayx%e zP9V*%>@U?f<^iBX3N##2UGfPvf7vJ?S43Zq^!70pf}pr-`!fan8$%lo3E=Q+U}uzL{~g~7#}DYi*>SfKYVwcYM zic4^qTzAbPp?t&uPWNi=iSW=*WY{Ghjfl+DbS&y^-?-!+#%GvEv1a_go5}=mQ?dP? zA!V~vf3_E25R+bM^DVJ4R-jv|%}4d;SCx)A3fQLOC(Ep712S>_yxZz^`|6R-!$G(% zK4C9J-1;YwO>1HK(Al7%&n{?NcXo0ZmxrV({cd?Mwu@TcSpM;4MB}-!lJw$o)80h@ z%Nh%e3@0Nsls~nmys;9-^T@REjDYDh=#`{Z2whc*_CVTJ;NIY3eUYW2G_y^z{r73;8fl!@Z|SPV=YI5s6i>dVLdJiaih=C<$ER;KZd_~ zhoECarb6ajWvhf=Kv3l$+Z)77|G-+^ITo&DKwhQ)0F9@U6fYhb4%+>tvM)RCJWNnW zdXo#m2CEl#qg#-5N6b0Cd3srjcU?C5ZHs#AgTsz(&re3do|HIeS2e)h-IlQpJQWhNn!d$$$S+p0tij9<;^zl(VCn;@0=6I!$U}FQ*Kg+Ueq z@|)$JEC(Cy<0^Bh*Or591R}R-Hv=VApyB0?2J7HRiFmmJGICYcI3nw-PmMLI657@L zdcFdl(-U_@|654D12FhSczExuzCO7#SE;lC;@AJRtrv;nY>cJhhXc5AE>L~@6V56S z;HfJl6+hKT;aU6Mnj(DLGo4E%*os9I#Qr2Jc5<qTe?#%chgO!(!h zp>cm5>`Tgrzf{P+U%PrymOO{ct|?wH2vrGH$N zN7C*kN8w%Z6whiwU>I1S8LEz85geUKOR@S(HN1>3Jz3+Bfx2nDN9Bs>gMh)0AdC1h z!QwXv`^e}Lu6i4H?j zLDsrcM{`O&4x$|_Zbye=^WpRl2R!{{WP9xO(sYbxb0`%qUlFXk^?oGhJlZCE`96G3 z^DSzkDeOt~2AYRjLD?8Od_rmLK1vTalFtXy%0%lgUQGme$Ah67m zZ{*<*Io~=@4Tt0ZB4iImfR+LKT=}nS2Sb|wQYB2{8Nh69jQ}bDtHil*D{fb3O-v2Y zn{OO*jq63}S=Zs1{Md2J|xl%Lxq9anGu>$j5G}8$$*l&#!7Fc))XLuj2~v zfo{=K=N3Y$fpKaS;}|QzD3ALo`~sjp(bIUWAUQ|7IvT2s{HRK440hWT)|Dh~1p~6?`yLi?2<}tg0v=Hf^R-*TF z&A$=EjRXregjSu0-&C+O^(B}t%na_{ViO(3QKaK@F9NmxLigkm(1_~mhZydi zR}gCW!4>BZtDIx`7aaCLixj)}ySL@iT-mw7u2tTj{2RFDp@tet8IareAfg|dmCr`r zxcXMdBM<5gX^wI*e0I#CSpVyVvZ;mStxlJZi1M8=%$SjlM*^LobZVB!}UCQC^Pax`)wMN&f&K&;Nz1fyUU2vmh3fJpW5|3ou%Q$A49^L85E%d8Qr$ ztQh-QtkFfrA^sT#xTngwdo2%sLV%toYHA#6n!%RIdX^i03At_GH)wZOVublH5UB4{ zxDu7Y<@i`H;^A%CDnfnZs0M0 zsf^nJoAn3!3yzu%cRuS8jxvS=LjRd9IjkJcfnj+L&IWme68pB2{c{jDG|(7~S6zPQ zL@_z^;ypZ}wf7&VeF{t{vXjtdDmisxBT9V<|8NpY0F0y4R;t_e)zttxA3 zdC%1NRizzX@S9p*sX`gP(LcGb3{4%Fr8}<(v5EGisdA<{q-=hp2bpHX>Iv->X(hbx ze*jy2b590DNFGXxiVie_`#uH%H54H{q6`T>vRkl12UC3g(*kyQ`3%dR?_LBU*GrigUsqeZurB&>S+8%@K;hM&ZqrkLHb}+iTqr2Sot>Xa?YqfiJxW#SZ2s zc!(oOK46l9>tmxd%m_6~k-mYaIN#N78mU`S>uf9GU0DP$mZQX(B!7?f*i&Ru0YE1A zfU_g97FIvoV{G2MAoswGu=I7pQlKT4BP!`FdRnVF>8Y~B{b$Qk5yQVRKQn5%=$#950-rZt-m%Pj@PY(=rcCOOk+ z3L?TtxQ-02g@U1)QI>b)CZ-=RyKIz4SdKPW?>aa(ud%-!G#5sk2DCxH{$nTw5Z;H& zbpwd`eE1rNdR@gYxF`9vlFMoF*SW&6Dxtna!cdNlBRIba7|TL4eY zy;%}(|J-CC1hJjySTEEh}rPq3Di?peFAjPW^K{7kh-cqoiWPL zWNN#Kmf&Bif0@IKv9uVVNc?>X zDwH2^4@JVdgBqqqRUS4kw0}$n_`m;PK*!U9>`hY4+hQTBsBav}+)Qt=@N;jKrg&4! zKIG|J*GfZLN|H@5w;wzcvnQ>Xi;&Qf#W$u4z5zrbQ1}!nQ0jv{eU%ic@qSZnc12&# zYEuIo)LjZH8a|F9G<1NeP1~ibL|$` z2X~V#B#4w238=W_r!Ys!Po<@fL4#OP`VFf$6qB0^IsG3n4}rsswt{Y{=(WHSB1WDt^!y^vjz4Z*i#{J6(k_}D;Gp|QAmdetIqaM z^>j>%n6;QQ0*A+%EM{**eHmE+G86W$6gsP9RD5kZ0*K7Q-nq+f@%^uA>{HIXt%dKo zMi|L6G*T>eE&*(-a@mf=YtP}a8QxjCN8;D4w5Co3>-eXE#dXr_2-GZ{T7YUZtZC&w zLwD)faNEko+SCbF>)|wrTfo`A^Es(lDZSP`u?f~d^B*oRkj}oU^uYElh1Zs$>Ocw2m-3uy++I zSA3}`RbfH53TpzJ99TQVopO?x_a&5w?|EgV`Be37a6@y@Jk{?w(gDD=>J$%?D0=TyY;P0+bQV24$R== zR>5sZBFfpwD5v^E7~}qbB=UKa)OI+!=8^#*q$=Z|8Rfy0V z=6v8VUt`yRwJQNC^7OM&v9&9?ml)2HYv5>r>s$q}NC1`uyH#H5fxLph3K39uivms; zpcwy~-kbuJZk4z2_wJWATq%7Z3)n5G%Sp5IZ@!U-S&j?ss!2f@g zx}T`sDg%jB}aw@?1OL4Po)~{KcwYR!S1ZgP(bhHYQlm2Jj@e@W<-wKsocFl}zR+ zn}X;_;}kF)*o}Z%jmx-l{euL_g@Dp2 zt;s2A2*87#h((820wgo$(#PyNH$+@&KNV^ z_IHqbQ#(fsoxd&N8%`G|Y3caA%3xdlDyc8DSbFLoPpET+G#Dx_J9TL_jDcCU{-SS& zr{-cRyZ`<0q52;t?tD&7Ny%;p+en_z1P(8$g(nie-Sp;OO^|JLsBdF?2q96xce=r7 z1PpTGc7LWnQb46M_LO>IgmvMFb@M_CeSMj5H(UGaT>0aXB>*-Q9c<>J?e6a`3}nb1 z04?Se6i{R_1nQ7|%Q4e|o;w)1q+I?thUi@; zb3tn@XtrCg`aJJ+Ku5dQJ(^w*ul&4x`S z{RDzPA0}>iE_#N=%~5N$gjlcJl5Xnmw;|!s0Br>+thRzfCH&Dd>;gXI4jTnCu8-TP z6WlmH2~%fxt&WP4u~K5w`kDB|zSW3E(5UlGA-DaRBx|45>CKUfnaYS&>P2&9pIBu- zL-&z9?)%=kZN)U3Bltfq8-=P|t$F1(d&yxv`ex)GEsB~Pi4$gHWcj`8yE8){U>_w! zkUi7j?7`@XS%itkJ_&C@*u`HTx2U}6W&r@m)Atz;>q@MdCO z_Yg;(XwHpW*FCUb3km3Z8P-1<&YM>wvZ_qkRvR*RsCnSv0!uD}rMSF7k`d_eRkh`% zXGafRiX?>}wIO#`3ucDWJlqTK^SCnE1u4H}o%tBeW6Kma^YNXz0)X_99%jUhe{C%u zjGm@eWUKdFA$;I$f`?8I9Q;T(A&5S>fNz!^INZx?T}z5J%ZeS%iW$?5HD__}{1V_X zCB;+79y2P=T?mUcijV0xa);xLxu1H4D;F`iz;y|94HGjy1R?!Fxtb&L*1B8DMNd}Czg|k233wB8J_XNPyO5TxVGS(+-!zV^I(F%Vy zNcjG8{ZDfN7ZT*n`p%X53dAAV2bC>*^22pybVYJ{gEx(GVdkYriv-9-J6#B0`LV*L zzcjlQVcEX~ttr8-p73$kHO&NvgS>qLrN-~HjNfS%yYm$_!1mcI%t$mpOo!9Ul#+Nc zy03M`gHns(R&|_4mx4p4wAG7PVp`d=f>EgY#)gS0Es`ZTIJ9URCEyt1}VJSD^^)6zOw{A2JbfRsgu!eM&KlWK+1cQzGYsM~MtU>t;hHoATe zA74Q)5-23%gJya&Ev!m*M)34Uz7`AOmEpOFF>gig&dM<}_w872I!F<(r*)uCpS{N_ zKXV|6tMex~-6J>Bqd`J&S*)v=-;hDa&5pvOJrsK*m;BLOMvpQ=W@lj=u@Wo!y31>Q zwL(!qcdSumHBmF2n$!d63<&;3&Ql9IL{@B7$-4S z6qgUB#}szrqwhCP#d>MT3j!*gQqfsTl8Xa_{%4Q@h^vJ8O)^06rO@hM0z7uWPDQ%u zssg!VyTzg!Z?g4{)y!~J^3S*dm=3qsrf3h3B z5ax3?&ya`8nhXh5IF!r{ec@wf7-)`VauC(VkgCqGb==mT#V~$DU&RCTVY^N#BbNJ8 z&{H)aL87tFYSyDkaSE4QO-$lmL)pA|LUi)zht0KL+6dU}1f{lfsN zgBC^;x>5rIs;e|G;X3Q(7A5Ek1|TvTWH<9mKJR4qrLy89;pY6yI((^uCDY!u$xUS> z?*lP9vRgccUx%62KkVEdl%f?EW^fostBK-yM)#`*v=X<~qPI2TI?JMQkT&=#T>0)OH! zWi7%va3yAYXeC^c>i){ZULSzH)5}nIPc6{J4Fj1%cd@}blEI4<#!V%N01z-4{&O}* z(aWn0Y6nONmaABFu~<8HRN6j|hXG%S@4D@k@-F#?tm=)tS`U%*wYX4ZGxpLBjWAlObI@B;;7mMl*_mn3s!4!X zw}Y>6lTQ+$)0gIwlMewM^EzXW#oFD)KY(kyM&gz9wuw5~;@RFok%VW|pwJfQ_R0!< zE|hKf_>ZT3m-20SEdX?(P3+%pP~8)HA7?OucK_Ll_uah9%fkeKNIb}kf5tw3-u_-N zk>Q#9lqce$pNnMvN%xr}sU&-Tr;J^m$k;o*_|(Nf#QT@4R(>P8vuqQOrvY6u`7Fu8 z^TcxT^4rf>*O$ic9foIJqgVzzd#nnrz6>HZ3hJ8!F8t_lGlmOF=|qU?p40E}Ee)Z; zS3EP|=O=-$Pt>70DbPPr3^C+;kpd8uX6#;$XbJwdTg(l0w+z!`^RCzIG?ksvfn7cH zeU!-PR|{o{J9pAP55pu6iw3MtZoS=r5cn5XRuvHZn-f8M-HWLm`%6|#3kmsMmj0=4 zvU?mXR8VOJU;`ZfKvuxrKhm;NCZ+5!Z)6g>Y*5vL?7UJ8XHLIt5H7=)0advBS=StE z_qu_GMa({TtIO3dXHl{uUqK^_FJJ9R1icQ_&!SZFi{l;a53j#e#k!~Bye-OW=MNR% zznD{KL~*uF_8t-y|Ky)yVS53gg33=6=3)R{UNQI+nP)xczRd=j-tL_OE`4zING^kW z1CKS}riYjFd24R}f*yW2q*3`~#EES?a|BMZS9Pj9EV^_kiBZt!pBq;HMQPq8)VGWJ z_6D1T>hEZl{yvgC(ri^3bJuvF(Y}TP;`2Q@dxY+PdPDq^v795N#c_SNdv{>=E{muji3VD?QiGXfKEk^}ig%%I4tnWJj z*ZsJ+ZBfY+5FUV3+-n{1KZEd~7wZ~HbLlojcYg~!T2{?5x`mPi+@a6KK7B~cmQ=V* z%Q&KVL>nw?;S-bCi!d2ApwK28%iEC0r;Hq(p}YPgE07!^kI}SmGQD-ra|irt&i7RY zVW0OZ2`_N!@>lJ)<>(`IE|U^Eo~vHZt&)BcE}XDYWbbJE@`fc_Bj7s){QcifMJ;r) zlFb(XJfp3$4@7)ss?aPOQLQ)ljcBs9I~qy#eqQ9JWe#R|c_e$poY;4au9quQfLKfE z4SjfHCyeV%Th9{tptPHCMbPhaqzy9|7D*k#qNQR1T`1mkFJ{wi>Hzgm*^ zzPe>&Mu&mAEXik*L?{R|U!cwS8gzmc>#19RhE4E9l@p~5`Mp*P3X95OX@M#aL!yn~ zfbUW67fDz|FBBwtp-90tq>`#L2f+jyT;6j(!ocTRvs#=k9+;;_00M()7b6FkkpZfw z74CmDB5)!PB;wJ2yx(#bB5WX_OKWBO5j=9sAme4b^`+>PZ;VenzJOYb!tsYKM(`QM z!SjbjPhQ4yJlIN&)t&wHeeuuxNbH#XH{fqo$D!2q=xKMp?){SXyR%VVZad7OJRkCNpL1sue9P?P& zTQ-@;rehv^&;HlZ_xFE1>P+{2&bjw>y`R_f<=b7_oM`5OTK);`Uj@4v#>HJTxqMv& zvt1KK2`fF!%!DSmh_qQE?-v~*Oq8wnZ8{Efd((7Rg4$<`t9@CEj42(3!@#9jV(n$^ z5ARCGHj2dE5c{3R$X$(Jc?kc}zA;#Q9^4?8e#tgTos8RR3QINH;6l1zzK|brsnx*Sy=WMJRisH-)GjyhM}B zNcMyI2V_Rz9kW<*7Hew0FxJDKUfVBQZ%>|-Qaik*F~XZ=jMw8Kk53(l@IT; zDPE&9pJTk^^^kATd;JGQ|MFrU#ShupSpH!{DvB202?ifLCLymu~TZqHokKp&345y z@qDjH>Il%eaqxY!N-mndaA{7{pM%>l@w7_uAiL*5T1S4wQ6z4L{YD|_aU?|NBZgD$P170w_?43zeaiy0pv8h^(u61=*0#;&S zr9BQ+@+iCN98swM=;3C)ptJb5OtL8s?lJs{mTz067c$BjHTe=g2IOc zO09sJ8kVYOth*=~-PcZOd;7cA^*Q5%$WyW|+n%1u5m7n@gz4QnOD>(e`R9>-dF1S& zU3~J11gon7bI)fWWWg`Twz94`t=;k#nqH~ZP~;INm54`OLRUS|3D1!=+x!_<(Q9=8 z&+%zc6;0$PN&qTRaQZ7YbtSrt=6IGaj zT>&evC6b%tv;sy{osa9dL!W$h8cfuPk3=O8aG2{A6H_mS|!S5`CX83zAc{hYza zLyI8(OYmk{A2$qsa6XY5OHEDL`iOyJgYt@rM9?5lIGj1l>YjIYcBi>x)r(uJtS#Qg z)KJONveh6R@B&DIM{mR3po7WveC>(s5%nBht7 znu;>~?$hxb%aLTMf?;0tW}gdADpa;Wd%ZGdnuU-ZPbT`FYftxA0_!qI;{NBhUs(k= zh<{}%bnny~0Mt#pZpa;vZI`;<=g_1Z%TqCg|6mA$8X0NTOc^ARNmXbtG%fJ~|vMdWJL@a9KjoJOZFz2G2od;i3<>kte z@Z8_y20Z(*18W}yB@Lo1UJgw-2+~WAShbz2WUaG8a@Z)lZKr;}nyrvlT*)bsG@XC~ z<5OJ4Cu=GCM-i@gL7%s@7&epQ9h4KTbzhVj-H_xsnbTO{Fp(i)tZO=%3j=7bP38O{ zfUd6eE5;hk<_)h-Mpl+KJ?o)+s-2erjN+%rI(~7`H=RuM?kBy`R_D!P(S!Jw-}jj) z7c~_9!O7WYr_#W?B5s1WxtKtn@~a{ncRDSiUnyc21tDol4KtdnP~J@J2E3Cx6DUek zyz!CKeq8GXlk>@7$|)MmCA?7^s?n@H^hEU!vx+6BxacBF2<4%g!32>1IA51mn`OBL z679ISEZ}=*Z!fkJzvyygmKwB&F~If*OP3@G=7pV`d$Wy+4j<+un!m5vJ5e?Ex7kAx zUkvJvVCj~E&8Skm8)@+{RI$WuJZ#jLsJQIp{;AB50RxkGI|}5oFh^fR_L=RKltEBN z9V?2~VErK^HOM^^b-8fHT{_|HQ>V2|dv7uN3UfOz)>oG&oYr^_|CYcZiW+ zwJ?$G9Lva};92@h5VlR&zRZAc`+~Q4QwW)puTHMx7b~{1>tkm`uJYLcQ)~Y!t^dM58-GbCq#Sp9Ud7$bfwp% zUd>EfJFU^J(WX}V2@+G@VyC87aHyG#;pO(jfTKX_@u<5ZA@FvkDd2~BMC5Ge7TMX9 zeU*m0Z!__#&=E4taQu?H_1m@7eS{V$kRd2Y;I!y)FX~1zC4gd!}KkHw#jrqPJ}Nlc#Z!q z&uq;GX<^#CHO>*&8cdR0PY7l;f~O0vBkLISYOrNx*5eMQ>+aB~AJbcV59=JgG_8bp z9X#=O3-=_pXq?9KJh=?4oQ>~o1U_S6MR5O7UrFM8ZcWC`RC-VIkPrl`iHkF#YU}V# zA8B~o*1jvx)Q^?2{#i9lB6CtnPVwQqo6HiNuXiVWEv6cUjvr@@SYu)8QovRqBwUI} zRuYp#F%c|f0|Tu*<(6)KHuohCPs${T3qH(`W+KW)H7kIYa>6m074@#-C=;v`4%W3OvF+X3u2MWvDHw9- z0^1Exu|0jQ&6-<`n_QE~E*sg{_s&i!EVqh!trVCB10-8Ir78)T{R*ykA3{R~fVp_l z-A@gb{Bj*>isX4n{h{w-5?nQ{y_ZiOj95)um7M)fK*K;D19yluXW^zu64)7zcsxe>&a#8mg<2CK&; zp)V*3U$9eNM{|kLl=zL}4q?qhM)3!{mL!o4meSWt@kX^YtH4Z(a1CoV{cY6PUxFc? zMQpS0yyf<52`>ugh$H+EGZ1Z%9NC;RW;Qeybp)3scgv2!LDq&@xJ&4xW2y#*xH}d!?gM3ZKGKbUY#8>UELA7At+ib zB%}DS3AuKy4^BERs4$@g*FRAVHF&v_9BQalQqpSgurO&jT$5`NWo+7IC;WE&Cq1GY z)%|v(5()ZeVipxwP2Py4NyF==mrhZ3nSTlLoZW@-o6IL-s#un8ElE++H`lSHiszjC zuF@Io;tC*1P>b7Wwq86HG6IuzLND}s1YR#s0-G67H`3ByLXEXe4D}9FoYfya19nC0 zT!&*-`n@*=%y}rcu-t6%;-n#BN<9@NmmJ(552`q@hRbBJpu}V$9xgW!`A|Qc(rh5y zlmgXuROZWA8mI-eIXGcEzfAGSnQt*81u$29H@fg_SDx_a_3mql@}cLy{t}4&5l4Lg z(@%JyT3*4Z_<{oszgMNGilV=c=2j0;gROfINzrtoUwX6m9`JvDr~i}|#k9*llog8? z#a%~9;am=j->xyPS6HbM17wI6#Vsty3qAy0R$ zJjpYt_~i0jqG5P3?lx>NiELd1AVy{-oaNG~%VJ1CJd>X#@v6{+EfNr8{?Jdil2yb* zyAi|_^6+=r#tvA=?Kyuz$xFxV8h_H1LglL|W0XjNyBVRdQd)RGkJ^C=&1Lc+?5uS> zS~lFZ)_MAKbQVAjD}Qy8C$4l=V-F;pfWu6P;d{lK4eR#Sm{-M9@gzzC4LQe`1G58| z9t`ywf0MKSPumDK_+%`^WG%l-_G(VR_;`rPX?%yqW2zhZgZPq|zlRcc)>Ytbf3kYK zdv2RjHukg3r^XcY+*vYFq$%Qe=3jN0CsJqo16;fKe!|MqXg4o0u2)mxAsXvyC$7A{0fhe%l=ESSLTO{GECoTKrzz^ zc8MnO-Fg3lCe12FAMN~bH~^OM6g4eLXVdltk9fN@9#?)2lrc>@u&|3=>RGn?lnLs@ z34P_(A6mF<;Z6DA_){a&tk7>t77MNi*L860J#W>`CQ)~|eqXx%y!%h%J#E~d)fNE6 zKAogrpw#|pVf9q_5ix;*KUTtXDe#wg^*44J9{(?(8RI&Uds@-y?orFAk>=DP9t#ek zp}>OQ2#%&t?^LqWe%05og62SrLjsx5-^AbkVORf=YTL#mQZrCy@6E&4m8~B(MNRcw zWtU0(r(E_P@(K5{Hp8zV_0vXV&7Z*vXO-J&#P98I2yK4|f40|4smLDY#GUI`u=@Mx zlbT*jd~H#6nNM=-CTbZv0Ibe?1_i9V)~SD zCpUeDc~h#M6!KPC$hA!AzBOESFRmKcAgRvZ4g{2zZ+WNIkDwr&+ijfaRmiQ3DL($S zNthG=P4~?j*l*N#_aFJYLsOWl8N*%vQ#x_ohce3Mc{rHTJ2Q(<31NG}+A{4{kke zps!9FTL3jRvo8$g<84mq6V#sTY$e`8%yW8l70?;+SHJQ|7mIoCcYRVOeKbP&FiIq) zO1>%5ntCNc)sAOI%Fv@t;I@&FZp7zm({^(*BmP5BgQWsOzOapg$GzEA;*BZR$Qntx zrexLaWJ?W)mX)>$X^+WA)pp;EL^V9H^79C~NG(N%>?lF?jBqVT3yZE_wodt=Vam)# zQ6W%O)-_JrhZZd)265?K3fH-AP7@a~Y$W)~qsC%xFD`7@i0hR{vi2NJoHz219YkEP zl&8*YQ!~KSLT9;L?+I#Sf%ZIogNE)c^1`jhzo*W>r$M{{+EQ&8}*Z z2MR@S!^6L$;hunx>b`i#3oZ%)8q5@)PL3Gp=>1{pS0YRsgXdDh8#EeZSs9^vnZxGh2~aO?R|7#TI|#(_`ndRo9Yr`+igwmtk`b!li?@!?mUMDlP0$u}ZG zQ8&IIy7}e>yMw(}wa2_w6Yw8X87hFEK<}dwwE5Fm*VBt<@nPHR95T@6k7IeykY4w~ zwDl}4-ABk9?js<`4x$lt#9|-1wW$~zW|c{w*W>ETNh8%sG=ceHl6gJ z?K3VL0{#YbVz#|uGz9sRWZ4fv14^Epo z3b1H)yw%C!YX7G46rhdf1T%|A+O?QvYK|Icw~^y8Rg;0Hw4=g5ooY+q-MpG5iwP<6 zEtc)^$ZdXJ;Q1?HpUZ8pK;kWdp+Nd)mf#&2##ZQow5y-9c!}GfbZU|m%Tn3p)-3y; zM9UQR4)_G%;QIa_F5=xbXq6ucKQG**R@N2SZ*&JT;e)vr1;c=)qaD8!86W3sBoH~bs<>;EFe zt16)HswxyFXeus=yn9?t5h?!v2{#*jTsQFD4NP9WhZ2v0a`z)HlE(@0E@7tddA7KIiRLH_d*Ki;`6V|cSnFt8f0+8`1j=9f%vY^aQhuT{d#z{Zd`z})QHoH zE>vHD>H-tf`Q=gidCP`#o|dGwaBtqj!$=%&p3{xShk_3+Fx?jrnpMV=Vjx%^nCBkO za<3UtBpx<%K}%akwJSyG4SBR7GcicXoArErEUk?JazB44^A>%yzx_v+oti%-z=9>o zffhBv;H6&B>v0*bj^%L*?0dMq+W{nONLDClFM|9Kn8qN}$J@mLsOK%|?K+!bsd@z;V@|0WEq-^7c>u#;q5oZEyA~%u0*7 zp0$cBh4o+zlyb7rblYx0N2KX>idF(~@%#to%ylGfTUXD%HEUp`R6K*J|68MlAXECy z8g@mRsG5c%_m~t$c#Y}OKIl)b{owLcpRX98Ul%5AD|*?>($d$u_1W8o!ALTqH`@H! z{9(I{X&YUxC5%58 zf0-IDI8*J%{DiqRtIe@}s|(KL%y^}X9HN1j}g`q|%dguKb^;Sce z2u;eo38lSkv+~>)yZ2&? z_FC#~5PM+*{c~G)P6uur=qSR3Vrz)9_C4GhC?u;1VfE(j@3YkFN8B3-CQTd|-d|lb(Y|Q{ z27?p&H9R%pxli+#;Of14o$9M$?qbaBc7@^_V(c67^oHPN{C}f+9|Wix;v!7%NYXSu z-!k|wn!Yh1NEis_ss%xqJ(VAzn2EBWY|qzJII{N9_sa zlvw&)DIy!C{C|UW@<5A>3007b(P}C98a6sZcYg0fp4mauW@mW$kf)=57(e7Cg43Eu z2}~pphV|(2KhP!nSuZeVTKdW@G9+kVgFIV#qO{T)4D|^EqkQl)m$!y!JpRKZOCwt0 zXL%UDasTzAm0kZ_Z?mAvPCpV`?Fen-n~yF3-~ZxLydqtHS_hru9`2VgRao(@td7!+ z@qNO&s`DrC4F2oPf&eajx0_q%N@+LdUNq>ywUjp)ml?i(1 zu+Nz^pnj(6ob+u;M-Qc8BllxJY}}??-$a-v{$-S90wn7ywZ5)k6NB@M-w-5+G!M0d zg5!}x8n8q;dor5L!J%1CP;sHZ;YzM8x*bC~>mJsb2GUAN!Fz?NYA@HliuqmWCQHL> zt+giG-+1(>s;a5Vs~?}&2qbdxmAId>o<;8#xP{v?A8)2t$@?9%oBZ4zq~S@x+g-=(}gN^KbP6b*;*-V$`haGMqnUW^xwk*SyFB<<&IUa=thNm)#Wlp zX)ITC2VLqVgrId<30E~%D0kZozh-ER&N*Vsdy?#;pTxXX&ucr96v2Xq+3wsA`=#i< zryMJKDL$!Z?@$>oCOsO*fF6qh)d5A$XDTUOKJQR9ey3dCe|$fK?6aUQGcknJlPfii z6`3GG(=^o{f)-V{?rVRhmGd;c67>x5eNUvNuH5TN7r*ielK*Jy@G}T(()o{1@^&;0GpvL<#(qYZo-wL91cTw zclVeJ^jJ7K@l3Bq;|X!6apOW!r0jrHE2}?4BU6>4TcRwmG3;6S7=}`ohGnT_J%}Vr zL1pY}jH=CBzzeo^c4B-UkC2`GbXV=`J+pp3m4poj=#)%)%~?&`;9r8l<2q1Y0;Ec% zTloItJ%cFu>Ke+CH`c9pk$kl$lVnlW9D}9DHOY0JAv92Ii_EOu-NazAr`(;ussGD( zt`oNJ7Ie9X+~z@V$zOu`9;o8JD^5;)Mu*ywSCMWMlKA*#IIxKk3&m@BVr<(+4XPfU zke_cI8-zw|Ms3VN<;#-059JrXZW4Fh%nl*>;byE%diEjC$`6di#8AdKyR)ak+L_Xv zACI*@TIBHva*i`1_v;P3(M4#b5YOR4Kc2^fXkX|1UJrQ>H-6iT<8g*VNL}SC(q3is zrC9w7d_l4r0Otef{_DkEL9(%`%Cgr%c8c&bf={tE_m9@UQQb7 zmj-&qf#hT4@Zh{>$wy(Y37p5J-GUFon3-2*=lAv<_msfW^ULXc&XT7R)$_GJsY?EY z8><(kSa8p)VfP(WY^hCKzI@ud>=sK>bY{Q)PVQLUMi^>)uCA_BHf<>bQOdt%)N7G< zpTySEPJo|A@n>#@Z5ZzC_d6Q2_humL_YlPo*D<-c|HfaUF{>(>4PX7uq)2aNNAI~A z190J+&j(wNNQjII&?vp|N} zd~2b4icv|uKgEBy`(9vX6|K+CPmuDW{%2lnM45UWTtkma}iS~ zik(3h7x?Ma)F@l0eNXHXTBvszPu;Y=G*rR~HpjmNXOn%K*FG0c1~xkqyZa>1J!H@i zUt8IHu+a3uTLst>2wK+0*%~Q(rar9a&Pz(Wzppeo5%r8LMsaC)Z|ZrUCHlN9@C|Gm zys{hPobmX8Mxkl0*-TQ}FGw`Bu5fI+sM0M=cJ33+M6_mVtiY)V4I%m8}t*S8z>GVsS*?dHI_K`wpYml?>a2lzqZP>b4nEQV-nYsY!uoH@Ft8eP+xt6k9?%zKoE$m(yboXs~(_W|`3 z8200U&P-MR{#4b|*Ovnx;bAc`2L4nWiz?$4t2QUpF#i>`5c>EcPwXi;cJ+6rlSPq% z{YkTh!KpXvrcwlOHVi3BbCa*8T{(O8=q;971Dxo%bB!l1Of$n6A0V5v{}p9G*`~)b z|G_5*#Ltdu9`%yw8{mk@GmAZFAt8e;_8#!_d?0#&XwwdopF|ufNinCI?Lo?I3aYPM zllFikZ@ZnPf|NSjzXV`IS=UgM4!xYxsk^8f;#y?7r3F^QpxDo^IhZFhYnnl zFfWC=3H=mr5mwnZe+kl;a_cgj_guEQBleY#uJzku%fvOw8kSA;+_*b%HM6$vq=_da zaaMJ&Er+Cu#R(ne)l6}@{o^XBV~i59jIS{FLG{ejfvkeyFF|eUZ~OBDtE3P`{_eG1 zeO$^Nfn3`u(kz>xdo|-xeSGD5i-f7exGC=!$VoxRtXn0=YVu4`PuQCXg7f#tq~sk7Y06x51l5fTejYkU(`9qyC{K_;70h^!WHLYTd0c}22=*#jP!-H5F91qwf ziizrg?@#Sa+4cUjnb8U5rlPW$E<4%M_~=k`12Mlr+*-?l(#F!HkLh-dGuRuLGtc0m zb(I;BZivSbHM2$EAfvdEQQ|c!knC6_&*WcoPV#PE_`bnYJ@wjVFU6BAf}_G~tW1E| zeSeVq^p6IE?H9?|4%;A(NwgU?}r($B)Xo#t4q)G$XoH{OFf~jZR0sBMd zQ<@Dz=a-o@{YnUK%1kF!ZofT%2&mp$ZU>NI+dn+Ie3W5?8&MMfz_D7HrfBozl<{&! z8^nZ>_tb2C;|-9DE6_^r@W^CBY_F-_T(9|l-k93s!8qJzbzfxG3-w8R$u@TGyUk45 zaTO!}>|HABZP}sGL1#H!me`GI>k;+~Fhe+aofWLp4-+;i@7JV!9ZN53!m$GLi+|&B z8M7i*KKi;+3~!I_zh%Sysh@Uazs-3j_MrYc*+Oi_D5aO^dh@ zL`U_psH_(2fCd9@uUk^elG^u5ghUiuNtKjH@a>!^W9)AUyWbYTlGQd*^@MeE`OK0% z#L($yn{9GYu9--@=>||p(T%2pmB>@D6|-0Vm%tOPD$i8iX|DJhru`6No@i?6_-=e? z!eA|^Z`3SaW2W>tIr*cb9CEi+X3=6dDFi_>fyOdWq-$APcI~_G3l;T?y;_lZnZYej z+ZaePMa`bBO?jUG%a0X=yU#ae4^@RIe&^QLWXvm#9P|0&CzQs7J9^nrJBYg&@MHHox*T?IfeL?0sslmo_+bZVs(0l%H~bB7TsP`BahKw~+>oWU zmqO;oW#0)+TkT7|8b~WPqM6?R^7F7fhY7SVB37T5jIDccfFT9;S$M7Q?^W9mdvLSt zvfNm)>6l}5oz2+vRwi(5n_f8$a8m3OFIhli!?O7M70;Azv`5Q{@ygisU>LnYwRM_j z#@9bYk$WxzbGPk{3^?<8=btY<(~%28HckbLEMY#v1UqF*dR)U@+sh4kM}) zv(@`;uX|;BfF#iGq*q@k`sFHuG1i+SjQC{$(IO>c%0Q(J&Tb1yk-Zb_D3ZNB1R z{&|(Ja=1^E5D?{rsE2`J0g^$Tek>5QK#}Hgk36vGg09CjSa+QWsghv8d2*UlEARQs z%TcfSnj*!X@=2vpfQm0SPrKijk{TI)q)Y4hn#tYp0Mu3X0ii&J$q`|}$cQ&|0iSxv z)Z-bE*;YC>B^iGSD1kP~zDh=p@_I-IZKp|0QQ}(R*s3>MKVX1DEoiMQ=G=r zBqgR$OW<E?o{4;o5L^{*?IW^-;*?fnoTD^)`OU-R z0vWDSFvajeUhrr0JZ{-P48)>{W`+;#`ql*h5_C8k|IzkKnwu!=MGH**k+VOzG;zjY z=$8S2*(6Osw&mAgS$maF`EH>*_(0q(pE^*? zgy?H3t~OSJeP@k4R?vd7Uk-a;2SK9r8;p_u;Q0}qOL=b8Q@gEg6u&XWdv)M9k*a(W znlCKl(OeZg`~cZfy_-G=`}i=OCRKQ>cPy;+d_wc;gLIxdu_&Iv7N}BOTsi%RZgB}} z_9SW^RrC92k>_ITiB7Ux!+TaiAJK%Fun#ZRCnMLMgCUH6Ja4tj2i|rZ;owU8HI)E@j{OoeAo~Yz zfwEG~AO#fxu%rMq13xw{YSI}2_N#)8!CLK17qDY2XmLLPR7tw+iG=Lo7}RwC+Ubnb zMLl64Tn9i~I#K#;v`Da(a&oBx?BMxHa%%TC6y`T~k=-oF<@pJ%&{hu@`K@Hdujxt6dZ@nyk8l$`H?fgBO-gCUITSi;ozL}euE(ws zdmq?5hNAVuHDL1nu%gs9;&DewDVn<Pc){Mh0?WUd zQ8H1e$X88Ok_L-SP>&Q&vl6U=xRu-KGnGG0d){9OO~1CT{+$+UNAbN}J(A&Zf=*yM zZHxr400jc(GHD_sVEfKr0xJzpn7;1)L=CWdo%!1xtf`iIWiGAKIm=Tkv1#3cXpn2e zHLb+M$#OL7E_w}$!i`SMUF}x&k1zW1KnEVacko}Q-v7E3^@l-PBG3i#%>iSO{%|SE z9=N-0^keZi!#O&!1+|sM<+Q-kQ1hv};V!wy1@+;-@mS)t2CyJ;Z%L{*eX zVY-Kkgf-xkUr3pp>x($zV>qNZ9>Ab8rR1E)cIM7^P`|0qZs2>$xN zp}7|qH<0G|s5>pwW6XQuwZan1**b%3(Is1?X==+Dm9!DnobMZy*ty3ZUvxvSI*Jrf zku+n#`1)HU&6hVC80$5v2=#|0*Sqr!h>>L-EyH?~ByrGG+9&h@f*;8v0_X1(Fp#uf z-g2nc;|L3!Gsy_?Pxj%~c8+bH&2MrLDu8Wp&xzz0S7*FE=4koYGRtJ98(u(9(&7)M zFyII)_4sD=A63MF1QT= zpo)LBUPc%-PEOa`IhGl&bG*_M>{<$8DcQOyQp%;Ct8}zGJ|_>tX$YrB`~U|tSN*uN zuG0O0{~SBVZ+O;OC+_hfWQ!;t|J3NtANvViU#E>8!@bVh8uyX>!@X&is`(M`x1u2V za^vstm6vqe4J>0S7v1cz9s$P@1EbpR0KwrFHp+29%|Ok+9Bc7_SzW`7Jn07qUCF$@EoC_NcBmPnDuhDcy<}e!UO?8f9 z`fo-rTPVhu)YFRf(ty&2F2+$C%d6q3Ms1`Co@~iuK@8ykJ<-O#(Du}&HqvgPLlntl zZ8bch)Tt1&&yz7N#OTo%s#Rsqui=|gA-dFi<;E4+^>K$oj z#KQ2;S?*MmQy7u>R@0t&a2{utj7Dk);%Lq$UvQCRd>G5x(K{znqfhfGiVw`KCZfD< zBIduz^w@*!PW2TEBf=1YOU-oerwgvsfyZp`r_!IQ0z?kYJYqxLKNx1rDofFRwB_F5KhM%`K^jr1PM?ga774zPj%LvIQ0z8JG}!DwuG! z!Tw`|z;B0}a%n}Qw&2Ulmc-mAd5w*j?P}&L;tQ`X^QBbcS{zXZl2l%}V0Ee^nP{fAnUKJ?V!HX(TiYCk{VKf8-Oh^%h-?%T1=FUZj znU#zWz?K%F;i*dlHXHRZDZK(7v)m3qgQW1uXtu<~6ulNvrqSA$rFJR>TTpHxX+(LW zH9LM5wz9r>{zRXv<1l@@mZusORsb=}#^v+UAsn?t)Mz0c9!N&cu!4JLEu_&cU)&^V zd48J+O7x6PelD-yzPE-zLuu6La-by>D#7h~Kew;(SAPgExMSAYPG6{K;%EYVMtzd9 zEx=-=?JW)NCjQ-ZX%z%}AP+sm1cE)%58XkAWPB1?PZ8(l;w=D~{JV4f-yO~7B1LM@ zLFa7g_95K6mJy^yj&#nVRu16+ahxg$8ZjWW);w!EHr9oUR#BDzurWh9p%XvS!(!78 z9ap7)j(@@fTQeysp*o6Zbw;V(pHy#jsit2Ta~!IE;$1!Xe;t6508~akUZqp@bA1&& z!m|SPjifVt(44!IfY9K_lN0}@Zv>MB+>^WffK4JP!~R0?gP%U_ z5&t@v9iGdmkb9@{+JQC;uq65;(31-;gA&XU4}t*91JE23Y)`NDgFu&G3X|^IlX31O z4zx4)kKnK+o)C6-oou9?6-l@vM41Tz-wS{jAk}cz4&)hj;I%*w6c;L?!IhWz*RjNx z?0|U)cziDM4!jh@(<~L&)vv4H$P2)~3s{xwePF)$Svvr% z1CNEseFi`a5Gz8!YyX#rc%cQ-gf(8e*)AUrA`>r6LLm0=A{Y_RbX>xVg#M)<#{OR% zB6t9VL;qgH-{Orz__#;ZDE@xb)>&_(2=dix75?a zFr+Bj(p59MI5HYL+Vf@i#LTGzjj9^Ml|A>W?PN#q{o?hr$Tw`>bRlNkQ*%7yv=~Mn zs5n}y%zp%ui*(ZxBrUG1b8Hq=6rgNw++up3(d;M}#2H3Cp+;lG5f&q8@XDi%r&^cl zQ|R2%TzqYUUW#~r6I`GWgGLtDNEMxBme+rU!nTwL9KB7*>00B$=kFa3i^@ERqMWB6 zj-3}<$cUUz^H`9eXe+9bk&Xh-I!3HS!ak2pBGaOhqB$es!wZENDW}=))}mr>KSoFw z{lw9S+t$AQ^Q1&pTcrz9E&@X4pB7%Yl74T~{VF)LU;y`wV|oH}90(dP|23{0jd)@u z60JjC7E$@Lmj#m`CW&106#%2Rh2|rPRd;Su0Rx81dj8N3zJW03McjlayKX6sv83~SC~#MIhH;=rzk8-q8@*|vz!+$FexWh4 zXwm&z1lv*Ijrwg?@0m*X#99zc^@=pBC8O)G*lo~k=I9G8q$@tpGn&T8tOYP9hMr&O zjo!2pGd_=GL+dpt!WjQ7}!a*MP3yNza4$wTyuffPPO0sy=OQRPi)`ZpQCVw zkp*5y&tRq}Rz>Kw^j23{E5UfXe)C5La9T+A8jDEOfVqihG!sw;&Fzqh1ofF;_1M1; z?gC_4+swP@=j^1TtV=LnNOpwIa8@j5==iM2NTO?~fwrAAx>&Hw`#1eU|0gwi3>V!z z^y%+;ucwOPYYaww=udOwO4Y_N-EcSqivZfn%uDV`!Ped!qnvsjhT4%dO{)l%UM@y9 zsL{$Aljmpm%Jfym@W2!>X`mb2&oT{9V7guyh4Pv;o{dDM$l?ApS9G`^ntKF8Q;VUt zUb|h^ydT?b0+oNlXg@cUjd{4`Q&ah~!oonQn7@+9M_b#+%+)|(=M&9$qKf;2H@fql zWog?XZ;k6?#`J5b-p>b9p6c?OK8V|&77EzUGbcsY76d|t2KpwPf4Eu_KR7ro`KoGs zLam9A*Z9?|bIl%f#|UxfnQc_ej?j^#bSUQ*lNR62A?~kM^Bn5I-3-&FV+UN_rQ&Ef zLHk1q4+ek+g@w&M1X5GX4KjD=XX#$bGn>=)4D&)|WHiSUv@`H>8GM5SG)KQ-E&TKw zOZV9somJ*Ch08n=3nwTh#BT@Q4%Z)iK765}xnLOh04NNK|B*xb zjVDPUk~q1{A=rQUz7aVcS|}wDO^T-ua>SkSzy%&K&<|U@1@6Jub6Uu{2l#tyCA4T6cBUut2+P2wjFWb&Kl66a&5=UV4! zvqC0r*{?HQ2r~1JfE{zSR@ZR5c*8XgPe*J2FL^w-dPoki&)RezNK_Fd+;Y;~cri^& zH@z&CLd=e&P9pgg)}E<63fhLKemf-nGLst$hK+y#l2OU?8%JkQ`vT<+nH~t519{3~luzq2 zj2rsxRKQ9tU|fwtl$Jt&?pY0HXoh*P%CLTLZq$YG|0PJ>5Lf(uHEvcnCL5`zjeUDp zUmLxCBJ6@Yf@&Ssrw)U~4|-RG9EaG9 zl)TPa3SsXx!?eMO>)yw8s*f6z7VikH4~a@&PTlH1HV)E-tpnUg#x=y~ND1*_mS!tA zF^s$n)i6Y8+EXfVWi>JHMXLvyj*Qe0(;ieE8FVQ=b zkm>OWA9?fQeXYVy!{n@Ya1kmvOp~(GhS4}m$PZVjhNc097oWq7a5ETm7(?PUVfLec zcr5JPyxFcO+}qwhM?EttS8>T6)TvA$HcZ|(CY?Bdp5!esr%ONAj9HXPTD&VH8{yKH zcHMh2^=uDx_Op6rZU+qO<9cqC>baKMN)j~THgV*)%8T1X9JXA*#@thsXmACW=@z;# zlDhp?Oo0wcTb)(tg3Nt;X`HG|OgpoT;g{dxk`HT>)0*UxCOFN|{9>{CM%!R&*h@Bk zD)4iTq36jDX3&Qs&?Gw^BUJ4hCcP;%g(>jxlF<Ww26_Z}%;sNIXK^3-eDFe8C=; zCoVj|r0tZ?1EUY0R5zXDD{K-4cxEaWU-1cft;TZyF}ijQbfU6ODxyq#vfiXt0>yi^ z%kU#8T!%b#T3?i1C!fCWVgG*mBq5dmwi z!_2TyIFW08kkp86t#GL>ed*15#Djb<)}g}WG=tW=vT{pvI=UB!#7sZ0zb=#3y+f%ovL(G<<@@k{KwC^zN(KMA3k`_{_lCYl09W+a5^Z>}c%O zx9J~3G?TVx-6d60PS`-DJ~UN*Yqy==a3y`}r?B|^WS!FVJ{ZA|Upx#wIZ~OoGF4yx za6*VHA%au)xqVoTbTWC=XE?0`Zf+{2t}5}Mt96=SBU!eFHd=LG@|y~-3^gRUgphrw zX!nJw*u{W;{T%}m$1)idbmrdp%MCjpirfcsjp0#j|CY~dbAkiK!L+@15$Y~ZYgFs- zy~4i)xnp}uuKYtA5_GbfgT%VaB4YT6BqjUfD?s^dl!>yg`DQl6H?h5_zqSB@!96{7 zj*H7|%S+UX5T(Xu9e#ZI{b!4{4IvmB5G&x+mJPg1I?ou{s>>=Fojs(HuBg0M1KT5OKYCO7r()EP9{P;TO{7tKZu(1Tjqy409FHnr5eMRpJ218wzA1HC? z%b`eBWz|LAC`G9F9Y6xw$yZ>oaE#uyGW}&$3Zf-fupaI4Xme6oCyr$aODug`R?lY; ztYnH*YU_i8-OVjwNf82LNL`v6dv(qptU{y>3?z<&G2vwQ_lWc}ln?pDS-NGKb%uPR zqo}#(qFYkvciSGYCOk0@pEl*{BF<7lo$Qf+#11K5z1s-yS=P$7uHyIR6NHtpnn3K& z>!*<(^FdBTXI=th=L{t1SRnQIRsTlx=F#FlJB~ zr7R)a3}xT46RELf8d=8@S}Ykxq0o$d4UxgvMRr2Jqxa`?Kfd>UKkncC*YEK-=DMzP zojKRJF4vs%TA$BWVM9dIm+NQxC*?3FflVNSm&iO^fJc@c zFGRgoJUbA={#2cP>;BhzsjGs{q2KQ$B};!ebmHEXKP=fW$AdYDs2kIpUtlH$$KJwg za<~yE!XiYOHx0+t3cWxcFozFB-ZQ#@>S+VPu>J6S#)K5$Eb(ge! zs;NHJY?~MDOAcOwd4;!HB{Jzu=I`-`K5Kp-{i+WvX1(vb<0bvQ`%Q_hRsGtvRvwpg z8$>bJ(jNUiCMxo#QojC*Ro z&ucBnzdi2~P{HG)7T69jk18L=$#9Eb%u;91jGgCEY>wZ)TmI?~OFayzsjW(^0vo@c zHc7&m;P+A}qTM^9Y7J+^RUHUi0>7_EKc@VuokM-{M~KYxV9t8?u`znEe^KJncd%#? z-4No^5pziccMsIFUgW(q45L}b=1W{NHUc5E$qIUS%RUuk8A7qnz2(u!40kCx@as2w z<05#~ph62}Xs}3-Et<=D%=?O~Lho91-{m5v(0KqzwMXmKuDe*NZnAFsRm zQW!)!r`km+?b8I?lj@A!s{HR4GOOPHl7IZpgYGH=1(&%#5L(0T_SU^C^FSAPmn~61O6j!NaUlDz46^@W;rIbt$=kUN zybWrwhf(WCS5Sms4PK9W=LXkj_JTiLZ{ps3(rf~NK!Yci#W5aDtX$u&&h=2-?v@jg zToHO-9dzyG(6`CT^?l)52b9#^r+?dvKU157O5HcnD;wKgpk zD9~sYFBQLkET%Cz{*0KFW_6uR^A&RQ6RUgNCmMX`Piw6yRp~w2w)fZds?3rt<7SI( zJAVSAE7QTp`{pqsJUEkIMEf-7IpD=sFgF74A6H&y@C26#pOMc%_-nFUh<>V?HO#8> z1>wF+%gx|J1`~Pyu2#t{&y&9SKOQ=d&j?|tK5RK|XZVlLulB9a;sT<3L5skD&UN%z z#@#fP+g$a3Sgsj%j^*3~0I0U_MXbBOAa;oGNcobiN2}eE8QM!jjUdEes^ZgmCK*KK zFhFQjKZuBRd41aNi*6q1ClOG50H@}FbyGP;9&uvkw|(&7on%BeZmxNzmNpRh&W*%A z%QMyydcayc>M!SxQE8seBS1eRXmbDskp4jWj4gyetDEG@3r{i8D;IB4*frT>St*he zJSm6=kzbv8?VgSw)>zG0^R#p4FAU0Tdlc1xD6wU_0#yH5H(PoCY~gFAaC&c12y_w< zw(uqM?ZhPCpWMB+*#UBwg=#o_@z)KYsATD{ceY-7yRLA}p9WJ~?%lVq#~mwOErG_< zSyt(U>t()9ETaF7P=A+qcoMn~n?@KiqjIi4_zvPZqMa*>V*SCdh?%9K$W(AMx zaB*y9CAvKM=M)~@g5U@VSdP@$~{qyHPfZ4E@D;)%T0vImB zl4;MQi^pnI0CB4Ye_8+b#Rm$`C2usLMn$`2@XB=udg<*C%DG=gFmJEN%xBj+zN>3! zw0pyYsH?y4#?@Buc7ya~>YLS_Dr_X`#6`b38J4;SMT^^f2dc{64iMQLv_kc70-OOE zyN@5u?6-waIqeT-n5yctdq*&;t_7PL)xDu6QQy?B#j_oI`Su%qPVw8^jrRHrO7#0~ zPVzD8KFd7)74r>8oDkveyK${mrUw8=o2T{VfIJKN$G6(+-deiyG|aoI%iOzv&+E|h zq1YkDG0|N`H~*x7&N~;9HfoeTL+^YDKfNt(>o_k)YcGubarfA%{obV?urE*FJNDTv zmiutyLqr%hqw>pH%M$|gXV#-T@4c7$!*Z;MJQ)Ht_ihxl@6iXGSAV_oWYyKXn3JkG zMs`~{Euvk2SXimpEkmBA6`A&L0T#Li3U7C>=w#FC9zLw=I{5j48xdKxh)u7Xsj`tZ z8n}4plIGxo;vLhAVwfzr!_fPb{g-~6zVlFJ;ey^=<90<*`RE@OTLq}|p&KcmoQr>n zCKfu!y|+JW!QI+1VtQkz<|_!TaMi2@W{>~^+ov~|5ZSo{`y~j$8K65v)dnMeJnsaSPd;UFgYG$mHIeSnHNc?1XV)6FEf1 zbIYYK{(GU;&xP&RR~%}V@03hs>9h>WIX8TLKVjo?^eV@#Q8Q32kcy%IXEf{cbZ|jqUWACOxwhfQZ z%k8JVJ49-(X*cciPZBOi)ruTP-Wi!BF1BUR&-L<))P=$7!1M))Qj1FjQ<1sR;R;2) zP{n94t-&=Bf>0)x65AeN#2&xY-zX8~FiwTYU0pz~Bt9-EOr3vU9$~ZiKyA$a*OTo2 zs=+zj5`$z@x$9pV*jxe@0xQ1l-wuH(X2rA<78drd)jiRHN(15($*cW356)HdZq;$zVSqk8+~SFx~Igr(iGR#pH;@Nb^gao!#Bc|rlh!Y zwvyT6DB-B?KGn!0@icNQOhmUhm84jR2>U0`D8u)2iN}o1#le`tXDKud&XI2w;%5tD z9z^V3aczHnNw~VMS0WZW8veu4{ko&zFZmZ{abFZ)`ntzpu~sGn+yfPjM=L#7ThHLV zz5PKuGxi6++_iH%Zue3vzbGm$xbxgy5G*oue(UJruI@M~;!}|~VDN3-seiP5jKrcA zbk87*YhbJF`Oml4(^lcfvs~X+c$Kwm#nBdp`yTqt41wwIWLZCb*QgiVN-7%E3GYUgh+#yS|=r z=+A|dunwl#p|}xXj_K!XeX%c#V9O;iEgn0mtvKF*|6CMSFKeM0%gq6XB|MMdEamt7JUb3jPJ>+GZ1nGBBmMx{j(4%}DGk3J>V9tX%y z4k&JeBL3Gi(f04{^bebMD0_#VLaUqrcyC!rVHp>mr~tOGqhXn*3#7;^p+)_BAwy*# z+y~Tzfz1Mlx#`^_!r1e5<>&(1i$mrZq3oi6iuT`^zhZ>p5BqVJKsD9edJCfYAh^K`PM>^Ei5e)NxDlPE^IQ@xAw!YD z1bmk~lHfRF#}BAK6x*kh?^unVpE5_?b;1DrnN$!x1wyPYzg$h92f%;iLJn~w7X3sc zM-On4aI-YewBfnQ4e2e@#A}oSYv-;AnDeSKnoT`2q%_aymF-uYC6K=0qaSv&wFOtT zB10Qxw@1+={^SILTyI_Gg}4F-i*Pjc-Os-BE=_`p7AQv%3^%oI;m&inXxab_G?WbiD?DW+@2K{7YTCGZ=VN;YAZ=-#26PjU?5fV{ikoQVaqElR|ry(7a4 z5xto+D#GfO#ZDxk*|gv-yM>&cjj1;XwSK}}p^N2X?EWBtN)K4zvx|`C#4?y8t{X$} zJkuQ{{h*iIOr*+5$6UMd&96x7z9i4n#VSJibTr5?gk9hJ*yAtz_baAkS)48W+(1Qq zS$Ux^nx!tBFD%jXy7zVQy7aKyWz0M(-6H81-HZ9U$i*DwLGH<#`QWe!`~g!t%n3IP zbA{~H?`ifKWRQ_(fE!K;E_wM|+Phi^LyGYV1 z7-NNk3ve}naiIy70q^oUl;0dv(1&R}3=37QO0nn*`>#Ww+hQih7v(BeQKFrreFO1o zp#Y3>brw`PDlm2T!%v^!EAoA0|6uTv@!J#Dp3YI-8= z@1{F+KV}VXxDqV{!d3bQQo_b8GO|xwbySq!3`?}soY->}y(ahIhxT$n&7(nDXWp)Y zPP61orD4f3JuB8t>GlE)K~~`$&|74bjgDfvpa&#_nWjv_pl{jH-o$zL7iOM$D1Wm9 zOP%Jyt)|(0*ZMZ8@>@|B2sW0-hVgt_8a$2FNucV(s&oBcP@C{4C5@r znkzAq(^#$D7Q%a*)?6KHPMDX3_ZBkB-fcL6PAu+&4XDfNFWRd}>n|!bfA}b(!}H~n z6UWbHV8~KO`l}uoVCDe59iwRfVsG?@v!0(ycFl2P_uU5!N>#M(567DIycRKOfUhCG(4V#rq%6lzt^UR#tJ11AjNYq=TWp3O<`6=bg zyx%)ES)>(g*qZpAE2t4Mz0Ffl5^nNzj#eO=YI2*$QL6uW$K2Oh#*@FTY*rT=GBr{$ zI`5CiFrNJF$QI>yF0bZT4`_QPT*a*Z_`f}ZJ2LT8F5sVQ&49m`0bL5$gHKPhz0qY& zGIB7(EyF^JE!R}7YQOcvVtGKUSx=gxNaVvu&ba0Ny)l9@fiZ%?V{Y@1BJ31z^H|j! zgZZ5W&qQRyhPn8a&WK5_myzgCFqrEwf9UFZ7ZfzJC2XToD&_`m2(Q*3(;J>i5fB_wba zs_@*lo&R-yw^jD};>ei@HaXdY9L{YfC`WE;X@(KwzPu!yYu(Cog5#Q zGWZL+5~D{hY&^~HpRankxb#eegu2ZK&FW|Vb4fji<2~&xF1K`Zixmbx`ew zUq@nwKJp+@53^Ovll3c_V8!N1X9I4(M<{Z(cD#u>5-nqtZrYny$vl@QyeF+ML0PctXfgu5# zzXVrcRRas+-a`Iw`AdfNm*om53V+-Fg+}=cVKVgZ$-NVMKqh+tD0_jxd*$E#>!1=` zKn_4ucI`pR?3MpeiT!5>$PxdynktJNQ2rm4e-HOs{-xvsXUqNvH`o8?{{L(S_9+(G z$AVBG9)AyFdy0C$uClJ!Khf(w74YpfxO4x&& z!h39+b0kt-(ZseaWkg%7I9qh;a6xxxI?%KXs4hXMgJmC$RxO1tvp@JW)W{>*FJ0hK zWQ>PM&v**rWn@Bo9gU*%3KZ}r7MzvaTxSx9P!!=`RZj5#)Fu`nzkmoL0DcHd(T?%d!L+m1gK(g$PVln(QBKc4=RcBVji4X-2@kJyV*MfD9nWMNHmik~{WTe9+q} zkH;-UK5VmCD}9i&|HHC!7G^lYJ|zYX=P^B2Zp7qW|P=h>m7@vHoz`H9?DJqq&UXy~pUbL)|Ig`+-S z`m!|+ntumUOCJDhSf&4E(%dtv?Nu!Qrviu|L{PCOyR3pmn2P&b(fY42^LJJLe^&_A z{I}I;#lI~#ik6f9{o8YYec6K&tdaLK-6;Cs9qs`Q{{J@y{r=kt*jf5-w@W`1_Vxj6 z;TKm>KQjziWPK_MeSqi9bk!9x3~Kf&ssC1j`u|Od`Fj?8#(3>D1E2q=OX1&3{LdlL z{VMhfKuz4^&fTfB(jXIqZ|`PX2O&l0p{F`h_m9EkedhOJ-?` z=85+F=MkQ!#wNu&iAA5Q_oK6?1CU6hQC`5<*0Db<+Y7MYwm0%KwOGGD)eK(YMCEYE z8s|9b(f~90DGjbjPsc<#6Z4i_(Wv((^=A|dUOq1?G7WDWu&_jB&ziAIshSuZ7%9C` z|ESrdqR3P2RpHEN+UbQbkM#=E)z9Xgozg;XXoJx4aTG@L;~$o(?nP~f%S_D-lQuc5 z*1k~N1Hf_}xf=%(^-r44g;f!5<#>pG$EYYAH&Huobhk57_l1kdhq}$)M6MEg%xH6+ zDEesC=QQM`V4c&*&B-WJ6^E|8nRZ!Aj!ee!i-|fYU9#ZMg81I;TQp1?xSUAy{v7Ri zOkv22h%xvoALqd&b(-RJ}d^Q*h>${lZpLntR%j?w_zM|$?c_Yad zgXW%Mje`ybuWXA#(KK16J|>`IRpt-Nvfl(=JjjG@OQ^ZMmtPck4dR-UuTl)qp{tA! zg<`oz$ucvJ{)oiXSj*nG4dtd&W-3r86#HSP>Atu5)Lf$`GWi%1mnav}D;}qDbA=MP zj0cWp?R%OZL(!k3wV=CCQ!WEcZzDM+9KI_pAN@+KSd?Pd&QMYO@qXc6fkA}Y0og~$ zClKC@)A%4Bk8yN#;mr@z1tX8To4eUswq^t2THF5=7G>1WecyTi*71_74ga7obea0p z%JW_PrGCSgub=K6e~CWfe7Ab`-*TsV_D=QW9gdf$gRQSNmqTci^^iVUQ0x zUoOT!=%?}7IajQDHsZ`BYkKPjZn+pzJsBZ1X#f)~txqN{GAGqFyxVyHnNJcECc3Ro zp4Rl6j5q_cuJxw#TDjGawI1_Xj)PP)B1%-j_m8<*x!2!oRdu{{FJk|sx{7!GA^3wg z?bXxaB``*gcjF-|m->W3h)Q9r>fKA$;SyyV4pz?fS>OxH;m4~P;f-mF{g<%eVG~4k z^zy?KhX&?U=q(={m+c_oy$pIwiW}VNY3g&&h8vyySv)hWE$4Uik-_&zD@Z zE(ERZx)}?aPWARJDfht%;k2_o6W>+53weXfQXH(D>VuYDAk~8rXH={~hxajS&v^^- zP06=DZtG5XMQyVaPA$0sSB#joTK1ud@^-#6iLyVSz7rb~q5yeUsSZid4iCDT{pd>Gathf1l=x9P^@| znp+kZ^)A{whb%Gk#jxd%-&=ug8uv;$Q=hhCJ%D_EQY;^op%<|V+# z@b~+0yQ1i&p@I_^9jo6vX_}W>-6~I47zkq)>M;v37hF31DjlmUoWv~4p>xc4py6KW z$+xmBw=ypS-$g8e?ppG7?3}MH^^|Tp_IrbCf#TB1+cH47aJO6Fy=e^nLXPN*y^)$F z{ih@+%_k=n8THeRsw*E#E_Ms1kvPIL%Wa#CQbMt?%%6w~OS;qNb zKHD1y`tK1XmFd_kBquA}qbC*8_Z||hoNnY*5{hLAT?lljzVc&RfT6B;gb*O|AU8$% zlabDrC<4K;RKUN#rT!+g=Z=$Ne&z34f@Ix`J2dFH5K=pbo`%7nD0NVOe}~oLyb7^W#iTwQ0u!o znywGaA}GcbtdFd<;)yvJyI&tBjym6de0w8Fe8wQq|0iB9kYy2;Cj{aL4pUwQKi*Uc zM4dlw@}yYIfk#EiBJHwgs*m7qEQJ2^OkTVY+T7sp%afu^e_&eti=ep2F6)s9Ch zLEN&SE4*G$SgqokwBR7MR1TL;^F{k87?mN!+aVFn#1tE!&a#t$J~gZulZ*F|>FTcV zLC?Ar=Z~I$Y~eAG|5BxHAok`>>kVZ0dh~^!AE+0VTrRjS`op7y8Pb9a{0b`I*Xw;7oyYLYVa^mU4j zW1a^Zcgem+i6q7vsVTZyyw)%0wi?^_Sr)_VA~}$((D2YRq|biwMZG|V;q~*2iS6Sk z0MLZm`qBP7z$5U+18-Z-;>U`q9yBSKRg(uJ?v}3`E0noV=biD@(rFrQCjO_@#yZ4sp!+3D%$KlFeD0^dG$(X z;YS^X0#{j{G0EcmS=$qGnBMN2Dr*~hJ3|%-gZ9DtVUw5PBc<+wKjFqbPy$G&)h&?O z?%F)J@&0q*yUb2jqsqxH*=8meK5<_CNV5%2#P^I#w5l3^6gmN`9M+{+>AUC)9ZT2c zwdByO3BH2#SCUgMX(hGpO#WW}?A3O@J$@|liIKCD=+goV#~)K|41uH^m}!!HI3))n zg;r=hkkj35Q})yMfD{}HIrXi}*@A?TJ)pw=$v{?5RR(ubm8{Z~Os-@c|4vV=ObbrG zZQwJcbzCb)lgg{38B?+^t9D_Yz=ZfKlcYWg*T927kjWPjo_H&PPf8-Kx)KEDJi(CX zmypHu+y)5H`@>R8BOOd{kz2fR`c%uefK#oo?Qh$MgN%sT1;q)0k>xZ}>s_JZCtQ?I zfcs~=&D%Oz4 zcAk(?3xjMG)ZGe-Yz*8uIGWgVta)51y}^BaVTqD+@^d{6B*^YWP1YWqw?K$KiXOOm zLciTQA>m41nMVHGeKH;tpr8r%geXA&SL|OL!K9DN(W4Mul#$xobxOF;# z;KiZR6(mXBXnQ3Pu@xvSw+yXUC*kiO}-EzFQ#Z=Wjl-TD>nD4AsbsW_~lJDipN zqacnqTAKG5gBUVFVTvH}8>;~Z+?yQh3AHd!MoP0{j)-x*;pc}&ktV4*Mn%=^P#r14 zAC?>HEbfF8yH|m>6~}fY?O@R}jv06dNDU|LO;gR`s4to+>PKdu&$N-dVhYkN0}fRj zoFTu6r4+|}6B6+S_;-_YW+rV=lx8qvCJsl2(totCi697S#g%*pThbmQP`92E%E`6d11hahhJZBin5ygWyq@c@wFYsUR`>>910q6Q0Et7 z(=hYr)t-q?au>js3e}b{7H95~yx{!X`72V3`MN|(iQmps_zUhICnH!##K~t|B172{ zp~cZ&ta=bCm9@6VV}wX;Ga|!QId$4a66ZgA%cY;<4_wmv$(N0X`N}d93x6Rm6ALb- zSwIexwfw@3bF`(l$Z9mU~lPs;a?{!X3iNUrJS{Lj!vmU2Hbe_Q)upGu@i8@vLNN5`gus>S+GBuyl zOGKL7!uY)k{65@X3jE2J!9C~v=zi@R`1tGr5n&lype85tIgj;vZR?6UqGJJ zj7L+}XK!1sj2+cGe^|bLEGMx~we2e|z+{G7Mrp(n;!$xQpy4MD&2w zZsbUOL+%yax>Y~SL&J;VaYX*d8QZQ9Grc~odrxUzQnDl{)+-ZZE8;m8))#6EGboPa zq1bs(@ZOQC1(Gz6reDj>_M-0!Jr|t-$;IU9IWni(@_xy#a$@8iUUNbATXwh8AKrHc zN}af+s)ey{+K2WT!`=gWu%P0f`5j?7>}_2{qV>fNSfZSf=4-{&x;XY3L39EbYpQr1 ziI)`2SX(kahiDyWs$fgOEdcjA>*ARmP&ro)bU*QlGIkL@H@@d00vky1=kSf zd&+u!XeI?m;{MS|GkZN`?kpH3n1-8UADvKY_)5KPRcf1-DaKTJ+=zakeYJ+ zuMoGS`ep!$AIK$Rc7;y%#h2`}!}tt;7Y$@fZ#Bs3z{gS+#k^S)W_x%Qt97#T$CY8e zi>w5u$Zv^yP97$NiKmwcYQuiJ`~LR1_c$}~x~7H6AC@{_wJQ6f&ZUvrPxv@atA3scMNaU}H>H~sob-4d zy=~jNQe@6OovcI5eo~G{7`5VNs2ymct;i}()rZ*?qPjKi&p85lA9x*UVY0J1HU&TZ zb-Yp$VJ&{ET5Ma|QW<^c7S^;8I>@bl@3Rnh1jggF`P=B<3e~DYzLW#QvpyrGdFD6? zji(i%5*R^8nQrf!B#u!*2kc$18_12jFB{6JSH4_>o;!M~i>HL4->~l@Y4NnW*_z z53sAY%SpJ@Aa@1n$PBdbmiluVY)-!UL7w{@i7F5v+rJ>p0x&1fV}WDK$_ z7XPtwR4j+@b(X9S6h@@5#GmUCQs2sIvRBGAd#8?0H;994rg~a!Zdb0Nh+7<))1SO0 zl97D(rh=kH{V)2Jf20LcQyJ?}?Wt%-4Q~VOK=qv{&HNiHO?vs=wZfQ&`AI#wat5l# z`JK?A<*Kq%8bxXjD=Q4mr?}3WI_+9dwZ%hlq5j5zy(8D(U{V(Af5u_ulm=`B_FFcN zm#8@$VUQK=Me)(;dllI7GwL!^J{MKbd9}&YGI}cFp92B?zLF8@K_ta!{A`1l|B^bp za$}z?V|#YQJC{KQC7Bq_wGaqp5esTtpRq@aBA z!FFzUF4weKy3f(xOYm!PZ|cvVDr241_B=Z-JAGq2wKed7hBkoflubv2l%CP3CMbO{ zS`}$bmip{}+eP5B8B$^zK{^Te%p$Mgz;dNo>B}Di;UERH$DhtpOf`_b0*M+C*`}$= zgqvA5a@@XZ3!Pvi;54JGHU&O2zJee}IUri|+7t)Dkd}n70yCp%u>>rqI(sbhQOl>x z3W-q`N~156S_cL&PSya9R|GBqP2}`ZZdA(ZiMPxIBa=(-h8(FCBP`P55h$>-?%ZOC z{Lc6S1GDf_p1)rlzP1OEI8TK0>uQC6-4Lpq~jSB#7BbNyg`DLkRgR(NfzKjO5qX`%aFFZn!7tC{glS ztvTEqYz48LHRu!KDY(7GIoJOxYDO$QqU-Tsq@D8mbh_ovWD=@T-9B97%CzxT(_~*a ziSLduMy{Ak>hdYFf~@9`6(*4?X`wv(9Zq5XKbw6;*r|1jlv(ZN5lRZaNlHtHCnJO zPl-tFGnfqW;ea;#)FS;=ZN*ZNww_?gm`2PtYQ-A_z5#gbnU#;}K#7G3ef7Yi+P?8@ zWoDg>GYX<-u2~Y0XquO&jS`8ojZ7Bt_1zESiJq<%`0RhtV@K0=chs!LAB;Q?4wSvj z>4b^sacX9u(n(q@px6l(zrvwY(Vw`~m7+tvyqP_|w)3fnBTh3Q)V9l1rU%w5 z3)V7IqL95y@plaXGf0!*9MBbH(_3JC%}_BJ6$Gy_Idm7pl=HF#udg(Ei9KVX zcF7Q!KAr$=y%!>2OipG2*vECkmJuo)9wS^vSg&}*kFOw*drC|^fX@aerxb_fd#fI= zzWID7p8@4n1^j8;h7*ZOuL#FW^I~c4NL_+u6sD$Nb^{L+ny-~Fhy@qpvuwqBY4p{g zlgk5kqvZUR(<>lb=L0<#+9&6UOY(!o#?)}j4*GW{EU`;5S_uYcLDTRg@dwlho?6Rp zCM^B3TN5zOz%?b(%K-UT)5mgyK_qGeny^TcdO89T*zjokZ5$yDEqRbD$oxquMkvWv zA10r~Kg8Gfg}@Z6tvad66$TW*c{fe8?GS?1Nvg^yw@Gz{`s{O8#Z@v(wFbqnPGE2f zG_!Xw-Ir&h)j{N`%+@;TN;O;wOv(j;=$KqA2B;n5s#2QOQnDk{PoWbqS*(}T8CPQ^ zm@i)))LvcrrSfh+O<*zs<|%=gP+CJLQy|L(wW?>_--y{~?%dOiPWg}xYaMB8_eohV_;^yML{gJV{%$ds!Niki42X6 z`B)*|E9jlN0utSu3Ife?-5WHNLS`x_f085JJVr@kkrK&<*9(9k)0pk=YHJW!(RIev zmiwD5lW=B-V!uFOa%YkdO7hR9q9GBMtti4WObF(|g`rKU@+m4&BNPuu%+S0dFE7@u zt3y7JRl|P1T>2sY`^P}s{R#ZqU%tu1@!GZl+19>oD{qNgWC;RvH>ZU#Tn(&0>_-rUEy=R<{1vrV_;iC`o~wMvMsq~R6uk{uz808$wRxi;WE?ZTDM#pC&v`y3FBrIDR5^x1Gun0=tNXlkM`Qf zc+u_D74k3YJM{z<0XMRCN&x*1XrX{RD6hZ zOVEoA=#9mCNH_`AmU}P6dqVi#bP85rvbIaAEgWMjPv$@q+Vcf(xNn)vCExf8|qWw8S)|Yd&FvHW~IwY%Y$h#@ifBG8&sneg3dWWMG z(b7YxiD=l7gF{-caU4A|=QiH}-@kX(SKc^Wlw}>SaSqVQ|5ZSL?ep4n(}&05Uuk=$(E#f<080Bxy$BOl;5iDkMTMfHtp^ju}zM zpsoBPB(cIDijiN9DQDcGXQD+`M-qE{FQe+e8p3poV+y7}c_D9-ryH~~W`QYU>u|K3 z0Q?1%jEI6Kkif6a9L-mNDY~x#$oUfcLUK?BkDfRbk9)ro;4J6&`vo!|-;q~+Y zdBir6k#%BLD1IdVCX|Np5#^`hm`~dVHLH&#Ls9kW{;DZ<>#AS^3F}i#-3OP3iC8Yh z@daET5Ip0O>YGtdS59TH?!yy&ik%9iS=&;eiuV^0aV(hz+R#@Ndv$wNtYAhm##EYJ za9ep(Lsi;iWy}3=hR8^$&+~+5Ibwmm2m+dKW`!)Hb~N=$be@Fqhwk9*{KO7NB(8_{ zv&8GetCmiu%?c&bF#hwNUX9)&cO}$zbw-Y)j7$^ri45GT&xBH`xH6Ms`;(dlo$T;zPO)sqn(1{(&>v^61*dCA>^nBH#!RoyN4Izy z$4k%gqibQ>u{rz9D6`3L^bj0UPumJ$VwG{Js-GJ8cFuMEZfaU>k^=IYo&*f3YYT8a zuThVGK%x>{&#r357G2e{c!#T2tnlPYYv4V&ebb28J#o0O;}YdUXz%G8IsnUk$5I+h zP9|px)iu9!NK8+U&cyEn^B|WQ>XVv#m;hlJ-VqA(VOM2vVHU&Wi_s|~_6yy3tT0Bm z=<2dnGfpSAb5oh(#6`4k~8bs)Jd8ChftPO$Ml?p}3j&oH-oH zAL*W4>aGt~$g^70e63m$Tmm%wI*{a1Fi7dR&u<%h89@Q7))8^iSd;N&ooP1DxJiAC z$;BCnZF})AAJaCMC1%o=L(x^qx8_v^#}qd5uYOk{`%NnBKI1#n6vmBEoZx3D0JwNA zS&;vxylbNFKI6eyZ?@svw`5egzhnOb(QSp1uJhyBRK)3seP;qBy<1VKV>4Op_TM$O zm}lesx*Jdcpwm;Dhx!iK;+nMA^bld3Lfm8yy`R*bEZNIab%+DFwK&;va?(9j-R0>o zUC}ZpL*&VVHl`}Sf9Yve#4Gm4hal=h$rPSd|HdZ0IR&Oe{x4-T~?SqZhwI+V8^ z6O?;9l8;V%$(7Ua+^DDE2g`@bMu%^2gL_9<*5y2P63o_l*oyf)bp+~P7Qff_Jjpan z5KruJ*?fZ=#Tzj{{rlDxzG}8tSE6LmBbP^EVGvQJwbc;VJ)hA`Gc5qW^U&U@MH`{hSMuZxTXXrwS8GD94OL9pP= zYV>^FAPj_@(t2AaOg~`uM~8%mE~3%fD!wWG^DJ>!(8f?@;is8FwE8<-93FC(;&XEM zA9n~tTS7C&>MalNz_f9N(}|1==AM$PnaDYrfCT+i6_&5f`YBwm@_MwBC|@!~9c8;S z)KCulbB(qmLGsm@Y^};BAnlZq%=f=J#^o=N3dOggxPQRtKZf*C2}W^PWsDNamrP4)rzD@6ZL@K9O>hV`9-f}RCo{E;d!kjWZwi_a( zf7R8X^wqbPAF3iB6x6pAul61+C64a1`58N1+XEjn6lf|SJ{N}pSoQ=={HD(@8nad` zmFDH+1C|mBvrnSM0}f@T&;8{5v;%nB-x}# zNT3!LyOS$$bG^saaHfs%JBu)mk@|{6(XT!3QI&$=q9#r?Ppn(~Qt z>+8X==B)bpbQ1w0vj?a{g#%tuO^|S7ik%@}^|F*i`fZ(I)o|zE&t|9%aLvl!i4X$t zaw>6<-~dD1kEXs9lH&{1lv)uOoX5sX<9#Y)O>h4Kgw^wkAY!r(7u?w6K@&D9)fXF1 z57Td&f>6 z+*-xL+<@6k5__@%1!}ci+r5HCHT}ZffB^@nRg>(q2u(iEg^vC72=cxXm!SM{rh0i~ ztv!85gyxT*(#NvN;ipqu1)9Gyq}8j?HxcZStJ_}Nk5=xl=BIo?&*AM1-KD|-=Br)W;dL;+vocQ&9X;y4%K{8-g{og&)>%2T=g<6yoDk>IZ^ zd9cNl_G~Nmhx6$Qg8AD2!vURS_J9f8BvCP@-w=v|{m$3(OjpOo;~Xm-PX>3c**_ad*rG>w?U^PRYgT6`N5q5x92@2Z^j z`^UOlvG}6nK)zg&j9ep zon$oy?5%CEi5&Q8;8qZ{hiQv~)dGu~@?U~{;DTtV^15>u8wYDbf3ivQCZwB)v_Y}L z>KVQsmdjxaoXeMOI}k8}0E&1LcwpT;u0TQn9M2p-KUuMIkfOF~c zNwO>>mSP82Db-2TdcNeddvJ1krLBa|ATD51vl{aJ9gI6^dO^ZaZoInl)xaGeu|F(N z)Q!td`r{jn&`q2pvg9Cw#@!=$pOdqdZqCXVqgy-5SLo6v<-G%f`%jvKTr*uc!m3;; z{c1rOEn~H9Up`~Hb^*9oS-+?^0QfTXBdO{fFnY;8cK}lo1wUgWxQN%|^;2%q_xJK) z&V`8lVfk$$=UtpPx!<#?#0BUyANACi)(N=j2H2t_TrnEgMF97%C*pfe&aB*sVk_cd z*=LZ$7IitJqyLg5CXd7$tu$RLpGklfS#D^M6;tQWd^H^39q?zbZ6|^Sc72L6!ANMh zZSD76FSp~vyo7%1Z3CXk$f}f8CJ3EH8inIxo=%kRvO>jm@<*V%AzTCmqnBVzV~>D7yO)75n@4;F@~De?24JnT!O{n7#bAcynJOp%r}oF z;n{@5Dy>aLpb5)FA~k`AvF%f$FxB6!hjnO6X+4O)1@yWwkEX;H2*pjTnj_=@KB<#$ zv12RnWmk86Eh`+t>l`z$9PDyVr^13IC5OAli)2gVpDiMQ}eCMNK9}dqzh8~Ot6>yi8IeOh67J5}#H1*r= zc%R9w2Xvpf%HwFu9K)lpGx)QJ`8tw0Y$WCl_-Li!S-=fcbh)?7@6hP5!quwhqBN8F ztK?G53A{s?Ma*@ddkBsW85xE9z}%66@Z$X4{1XE+cefm#hwq5q3Bzfjjq3zB)^ZU$ZR~KdRnGnNg0jR0jS` z8;)bZrbJ@@(hQuR!9ic)~-fq!aH)dlo&`(`4(((aJh5fqf~veuEpbAe^WG8KS3u zO1acjL8{VN;9n#nwk30KA1+o@MI94Re-Uohzhw}UWM~JaRSt9vZ zk}(>U5T#jQS8yz$G^m;BfX@QRdsJngAmOiJoO^SOai_JOOfn`z`p9cKb&(xa zk#1g7L#C~*5wRiE7p8?>P!t2r!y5yiHjcS|E$SZ6E0>wray_g?L66zH+oKnDHN3Iy zc<{9JV3?olkpG#W;`cYlp3B}5$|{9IE{;{UU^qEbbVUl|YwfUt)P&*wx5gbeOO92! zgbckJk9=-fse;OuDB2Ezn>_E!W)W|LFkj83S4){X=C|IUS`_NkY7G_Y2z)nmSP-2= zw{dL;@uBWi#khj(*`vOyqtLDqwP+oElil{50H5GX@Y*-a=pzM^6@(676p?RTYM&}Y zfDtK$$d#@A36j{SUk&x-2KeaBh^@_##dY>JK3xdGq~rLi75mEn!7Ud;^@BD#k*O8b z->3I{xM!$M?f#J@#X7Su@8aZMvY5KB$MP3M_;LKuX?V?ko=JFrB_mZH1BK$nyj*eT zQ29P*ux7SGU39)w7>{sF}ao`m$hURpnm}hOvo4wC2AYv=(B^DL$Pb!R>skT#_v-LedCE+=_49b`qVO zBKs)GUic8Sp;r*L)k!dI2a92?GL27RVmAZ|=D_IFl)AUE<`m&ev}VQV5{Sk9a93>l zT;$m#Of#U=1&FErPQ*ydaQjG3Gu-y=Z$D>xs>&tB79XR$M^!30Q(EY_g*kV~W|_s0 zHfKz?zQtZ><#vgYqB|zQ5))fHKIr?F^H#tG-e+2ZGGH@VSP1FQYbUaq*WJrw3xFYK zAU@)v_I1_OZelygwsEMgkKJnF>*0pVmI^tQXO}nBRVH~~387Q?BFWfLTWAYdcSd9H z(kJF$9Ve~Jq^oiH3wyWxb8)@pt&#Z^b&?p}lA8BI@axanmK#!n+hsAnh5$T>a?$G@ z$4vAd?A2+K7<3Bc_`4quAIpXZAF98a5PGCKdt8obAZ66AxWs`aOr=@&YVD@!F zS2W%Bciptiv->D&_y_|GKY|ouWSg4lbdS5-zZ@3Av)AS=rhWz*<}0r5p+F>!27Fo~ zrhS9Dgy?3PuXK^hewTB=6iHT(MK|#u_6d44i*oKZc!-R|XVBE}H^c|8!sitIbFr72}>J7m=F3!W`NtVe!r zegSG?rtmC-*G{`>Hm}Mc?V4I9k`F+;s}^Ke8k>~f;Nw|ozZC=qlK|m;O@W24D$E~& z{hR3Nyi2PW<+K&Mw>Faa+Y0&gsz-i>mL_|ImmOHE&T`X3<<4EC z>*%PHYK|KlgiLMzF-Oawx{fj!lo!cNR(&Ie;QnP zw8{J<;&NH%O+Gs{?V?o>jV3`~l$-1F4C02HM zb7D?Ca!Kl9e(@RM0EECt=)<=nU>7Iyqc4%yUr*=fCRmCOotQmHOjS?93A{i3;G2|i z3g7gQTK4g5#dO8#tyIp)g zZ)_4YOeTZWGIQm@n5d8|j?|Ql~|(D}Zhpn@#b>WVB3OgyNHp40HJn(r$#) zhdH(UPY(aS_5HWDa!)Qh$!$VcG{yYV;uTM~(DA`HbCacHcdJN+O+7t0>D+lkqz1u* zyYfk%fl6y(ESlI)L1t%{jJ=}z35i!$rNPGh_7A|0xVJQW!iNVjEG)IZci7m3V*{xc zu^3h9Xl-8Oen0El>brBM8*WX^?UDPA!c9g;&i~X;z;}=J7WA2yz*JZ}Q+K`s@oLx@ z5lYT>5}Dk}Xe&m84UDjWqqq1{b?dycx69%RwlEpCYx8fgYj)bjCuF+#1q!}lk|!ci z6S}*O`hZYo1c+Xu*)NU?)`iH+!fnAm0jkqm3AxZ1`wdN66%0*X>X8iy%2$sV7*Y%W zARJ(hNof%rz2BB=5_U$`_6%Y%C9?H&^1I87KAxY}pWaOoH!SkK+3Q!GH`KGAxZS$t zZq)humaSS@ivFj^)NP_4`VCsY?c*8e+EX;wlknJ#xVT3z7(9Wbo(3T?&qvq=oKs~U z*|WOr4a531^ReLN@3Hjg=arndCj6T#2VfiF6r_?>EkQoGGOd5v-9rk`2H+VGDM7ni zy@!0i+k?I-|J`VhVL{$S(FXY4UWak{=r1^mt9Butd~PExvvm?dLi2%CE}Seb%0Q*7 zAXm5nO(OIN)^Yauf}J4aU=X1`z#|p(b5l=25d`(C#bkWSu9KQguVs1fPL_$%N4hEL zWP!lNM233eo|{n?yP7Sqr>0d$@)r^fi2`W`X+{nGY9L)91){zMQWs|GXsNnS=L)m7 z45q=hxLUUEJML9u5LaRuyZ8#Ce&)>m*%jz)Uw9VSEQ0AM6sx~G=N1-BJ@Nfr@3~Oj zyRXBvlf=A8+o2@@DkQ9|%R>#$=sgnPn{Xj8jHyNSxnaI{AH6IV>6UT_YJQV?!7fgn zMs=xBtWPM-`fjcg>}&pZ{ccIW6NYJ-IxUbkz$)YvAE_;`+W)!wfFxFHzI56*0@Xl8 zr$5pO?5G%hJ|f~fa5~*qC)b8~|H!!;M^E4RDpO#vMptCIY4D96h!Oj zcI|#qUHy1M1}&Ai$Jze3q-~O##PfZ5IzOj}&fd{o%jhKk%fajnOkGNUe(R+U0cLzd z&GuL=;dn~s18Ulx32wo!i1*_>3o?J#CubM6D0sD+)FTvAF16+j*#y`uK%G-{DFNfl)d zToel?wND9IatR~uR`raL^LegH8Vd(AO3Le8^Y)#H%1*GFz1yCzg#aTa?jbA_WuAUR zr%b38=(3VU+D3g@4z3g5S`2^w{8{+=OCu0?^l{yT>Zwb4VE^v(Tpc;Ze)H~XotI!S zh{W>kqV7>3iw_ve_fPBY%f12OO0-k&ubUar?RWA{yd_X<|AX5&7)$6tDubu0wvmF= z!`DdO1p|>ty7?UtFRkA zRX4(kxJZ&qIzPP>&tLW7v-Y{I6Z7w+(3v=Z8SaN7*i}{#Y=Vmj?^mnruqCelAY+2b zQ^xZfACZgbJ$Mpu3*M9GL<)xdSg8Yz2U#}^0av*)ztK`bS-DN!Yv%CkFRfI);rgex z+bl{+Wv?HUv_FIevi$QRzMlX zE}dwYwLMt(C3GFVn=Si0n9q`$tbUOzka^kn5ST9{8u38a?B7NC>zl++5rm~+AlnKA zNx!mLo)PQ3m3J|g2QY;Oy;E-_BZWF>WwGd~Feo`i_ZWBzOp?R_x4EH5vwa3R!N-lv zWChfMohLLHZ34*2wnNoM5U^<=*7D8dh;69nvyaXI%-8;T18|1^d@ZZSw_Zd0`5HTM z@Vv{orn$2Z-JA1^{}^Pw(doTbtq~cxDRjqW(4TUk&Nn2mzf#4*CvNy{sKluBv7=cYpOY#y0H1>0oRZ>SN@jVH5waF<1^5SRQ*_x3f6?D zcY}>5DPNg8MNAMILe5jteYe~2RHtq7Z1Q|hkdw9T{Ul?olQ-kzR*OFHTt*0(dPr?q zELM5H0rf~zMhm~4sFZLcYN{^3ac!QYGLytjkf8*r%#y%#)&AERYeQ?>rY#*-=BGr1 z6I~Ik7=yU|=N51N^l^|)w|IT7FKAU$XyX*B883%Sxz?Lqpn zoR04MI2g>w0QZ(`H5}?skz0LNbWfYSkcx7U5%;A)g=04D>}D^=~1pfLVGGxz|4`n7B|k-6uyDC6Tjl3>_u# z`Sy}IcOSh|mpXLLMQ?hg$JqYYI!wflsKc3T6lAE7baod=ZNrvj?h}ymvANVciodnd z>3?Ip<(DZMtQRu7=#TXEr z3oXd_Ju-Ylk(3Y8U~1R&WdpcTGg028>)w$N^;>NPi?8}l^o?lSiAWjBr`Z44m;Pir zTV+P)pSvZN^Le_3pjCyhq<}B+mU$-g@PfMmy=hCmP|a0T4?4TIaf1ov;Sa-nfQT7` z#1GOAuX1==D)s1{5Og+F53r>I2U)1ya-YM?*y*UVklJ&(9{UDl`o+Y%i8(|(aF7Rj;}5EXAvR9_-Nf$12ts8 zQQtNNF(RM0l8>EsFo-4d4^V)VJDA6nY_6j@7@B-H2>2^xoZA_D!^&(=T5#&~+uj?rW%DaoKkB z$4ioT9ak#4pBE<#=$yPL@WRq~EyHf@!3rroxS)PXr2aS0N9Qt-?QXOw{*k8k)5vc@ z;04NVE$k$TYsE)0sM>h#z1k0t`lY8Ux0epDWtA9z+C&w!1>Ori8~;atqf4@SvI%ao zVS8KeLS3c((2{5!_Vn>lJc zPlrh{vOhNfa}SOTwggdaz$Vwt`WGO~wp^#_6^H{7MU8Pf@hy=0k;G@iC>{xMi-Fno@ihErkS2)k82 zolSwta#eco?t?PAy8HZxifeyv-6<_FGERjB?*dbmofi2w+e>D98oTa(j5j2EU)f3D`2p9U(M|Js zIc*m6`bgH0w%vi){fR=OWycnu+V6pdq)SBWFagv~ceTD_%@uz_E&Zl*pZ~>Lo?Res z+V0h5++413EsK8Xcm<_JhS6}mrYrD`n)?^2^USLpoJGHV99w{)NH!ZN{6tscpntPB_-=t0JFx5&2ZE7#g*J1 z5cmztU5wfo++Z4$Y1kyQmfZ-L5iCP7T?`QVBi1wyM`BxNtXXl-a)mR!bvZ5g6tpQp zQ<7H@pQh8kl-#p?G5`aVL2Mj5fK@22itn#P4q8%4H8%v-UcHmSgrZUTj=vr zI_Sey=h8FM+0AE>n4C5l%BK+{cPEzM)g*+Naux2HWd{9j53*}636Gun%%9y2BPLy9 z{Ca3{fZiUKHnR=gUjh}-tQWdz*8Q~*Ov zZTIpyR7Nx0&5axsVe()A{;O%TO( z#MTyzM^E;(SEUU4LCc%5hczZnEO@l z0^G&=4x_*PQCV?~19r=d6`VC7l^Vc$Fv`B74=_p^2aUm(sp-L`*1=`^!>Ojd9TJ^(W?P5?VrH?{z;%cBh>!d z8^}5!1#OKvdi*1~!arzdo^YI1-^j%OxYKY%Kdr143$ z<5Jk&en;Wm`|FbS*V~`)Z*bl8ADFX08Q9>l=|8scwSM4pI_;WY+q6oc zwY@7J$~fBnZY4d#bLS{e&pgh5!01jx^PPrwcN$vR=fs|mCYeW5hTS%29{pH1+g>+Y zTep{2boERbF8RKGT`p?iO1Z|zqwK@J`~*10{~kv*#>kZ?-8!scxqa3IPhC6tYDv{D zNzQ;z(*d6*17`jnb?Y6zvy8SbT=xj0qzh-A%XpW|D6Gbbjxgf7EbN1b>m%uTu^!{m zN6(gne@A0Dg|rX>#1Su39;-mI1*(WrUB5; zJtl6en=@a|1XK$rX*n!*_}>XA-;<{~x!7?jaNSC#VIaC(W3p!^{!BpH%X0O}>45Y- z_JqgwPLBqI)IsJLEOZHz6O`6#^?D;anw#mW91@}9l^m`3h6qBj$=b@?2%0#mL=(H${=LNl zc8v$57KgEkl5|+PmywH^mQcv(yJG5&**Esh!nLO0NH(r~_ zjJGT^`1{)5WBlOSP|a4%_KIJp<#x>gQtuXtMxy z+YmZiZ1tdNILfB|x()P+=Hu)*rDKRGNvQ59;PbhtqE|d6e51%@k1fU9&i}Bwf_1|x zSQ?&IeLoAbxkKYQ&J>)kp<_>)3LrUM4CSb~AZFt0zS90GVO*)o0M%)(INOKeDPXDm zFp7F0z^*?0WHzj>X9@kKew!SwW!28@v@%hY?UGnDI>>SCj3jsbK~lmkEuXXAI1T~g~*4WTpmu%G~0 zq(qTS*1XXv`%HthS>(b==coHza%^fXC~_=~Y0U>32>+==a!MQkUKAAa!R9$$)DaZs zC!mj)h;U)@JzylxEwQF z_9cPgn_V{BRadsihc8>Gf2!41FOpC|cTwp#+g%mwE*w0JuC#cXwxx_w$UuD18aRNn z9Nz^e_!(IZJ!IO!AA3K%86lu;H^^CKHzp0x#QA!RAB>V*;35@p)!U$wJ7|~e6AzF_ zssIA;?)n`%FL24?P8JALrJS+d)8B-kpb>(2iCnxD$prwB7MfY|@$gkjFpnYSU0780 zCX|=3TWu?E`htF($uSYfK2T^3uttgtssL;)e*)AEJP3esY-~U23lk$qnQ(jCo!E*X z?ncF+u|@xz_ti^50(lG<0wbL^WWLHAj*2NHPlE)4OIuLO}p5!k&l?i zHDdFF1UHF3cZkhn7V5yZDXOrSD8q9`SUuK{69r5s2!}ks@o-ML+oF>66E{ZIie}S7 z15qQFOX}rs)<3cP;71s<_60A#W9hg5zW7DBr&K=#A8j)1#ymtS?cS?>secN1rjfo+ z#UyXr4S;Kh{Ord30_+A3fM5IBeGnjwk^Ow!rw`n<$uKXfcIh9uJ@2iN{99z<<>CHl z{ZbpZU0zBXwP-S}?!y7QLnE-;AB!ms!b^wn%s22Zd5=iO@|(lek6e%%zVhyX4SS`H zO@_T12^g$BByZb9jt$+8a8?G0u|NF$5o0@d^9cUnAqj5nch~WQ02Xe28v)!5u&+r! zAE>-X^@SZeX+TCjEv@p!%&8b)-qsn9gSw6#aKU}?t@hbg$gxTnQi&%ymo3q)DmdH8 zEoi&3vm+0gai#^I`kdD?9VmWSxS@x{Y)V1Sd1--Ls`YBi35-!aUw-py(0bpov}u6= z(pyc|P7^k$tuhA)5y{*FaoTK`Yk9 zTdce2(RmMf50N6^<5*+LmZZ1&&14CdB;1NXS5zT|Ms}fh_6)gLp<^n03g2J zd>A%x?$aF*Tod^4m|V+$_hP)&lCx3vhIeJsbA*BT^m_ zftbtbc^WGlYTF~zw~e80xAY4LiN-o6Ruqge3w@Y#&zCB0+R}RkrBSXR&!JsqLiuFv z$*-*uvyqKFSZCsh>l?@YyLb%zo{D!oZ&@sic9gga2h_K(=PR<>9mZ0w;N1`PESxU% z=g0k{l%ows9~4Lh0=Tnqb4TxebY+ZrQI5gGrS#iCoqmxuHuukrg0aDS6yB=3!+#Dm z2AIx?32bh34ZtvNH7PAnzdq&-^qRm^GvmyB2WRg; zW>JPF)zUaj%gn6us-UzvX@803gJE3&&YYm(a<~@&D;sBZ1LKKPl6LL`F5Nh5l%;_# z%Fze?t0%wuShX&@CeUs+$Z=#Hpi<8SFUAkNB2jD64=r=Bwlff|k)~f7iZ1y*MTZ}9 zdPG{0N?3|Gm`$boO(&Gy$7J$u;ttb1vq@`yaIV+e{$({WL8%U~uv-*DK1FMDV+*Z> zYOm4pE@DgF)qE(>AD6+4O@s;&2wFp8Hh0>i)+3q1*Tb#@z>$t{CL4n>3DB&Q|5CDp zp#_M6%}D`X$bgF?wJF0`ZbR}&bqz(#)@5# zbDMH{)rSy>nLd33zws9099TAYFcVhNZj*pij3SbVU_l%>X^Db2_&CubAmsHiX74C- z2Ra*cBX}cm^y&B0ykTLKTvsa^=(&g6ahH0WU$`~-aBbfk%X z`e(=dT!fXJKq+RoejgBH6h0cY)DH7^ad+(8|$ zwaGA!9c6)%#z0{}Cjj$%-h(0mD#ljUC~HjdyhlnA+x6Y=pY})t2Ds#mcrd1T0lfW} z1CXKEyl7H#84NK*cJHIJDsRfaMN8)%;BP1WyiGb#gr&K_d^aIajg-SE$N~E2uLR$& zu~&{&8_UjcJGWUnm@NB(K5E&TF6#smUhPMV5fs#A>!Cw!Gu=oBxejz z*t{p~j`H82=JOJYW;kk_R4IN$Mk|N+)Pt*}VSSIZ&yR*nY#3T|mQz0+arNg+^C8QN zK9Ov1!M<1DJQbr?aWqFOgFQ6x{xuoqUxamF=KLKODNqa|>#xEA7(5$s9{4Hn4VW|` z;3#lL0%$kf&EjXK&b);xYgbP|#9du8`!xXh3`Jrs@p{%MSA7lQfe^ceY{Eb`s5qSO z$xe%%jp$>$2+pZ>JLA$$TOuhsCCKhL(VNhRY=>YJcRNxoh*D=x<>=eHeW!zGY})Ux zaVyi?z3Ex8^u(L}6`;!O`U>1m&<0~CH#Eanb1XcZrY|<2oDLri_o&v_$ro_5=pS~e zpnw(wNAq7$^c60wG3LiJpqT=H;qQ04C))tgp?|NbH3r6Ej4Dph+<~><`Q>yyUfvlm z%Ki?2j=#xtB4FAq%S5zo`nHK`S{O^rO^c${-)EjT4bl`OI z^jL~7(pU8(?}2zbQfl#u@r4rwd;mua08E=(zzXfeR3#Js)<>S0;R>A?cIYhFlej+9*+u#rE zk3gpa$R1ThHvoCh6T&w=u$m(cB6_Rce1I~M-Qg%#ph*D)+=y0*lc~>O-m2|oXk9Mt zE;0J~a(^|FS*u!ay4+kcUw=s<^6XXb`+t=LiWV&vzx~pAkJoRttAK?pLYiF)m^bX4 z_&Wsr=nz02zymG>dgldN-!T~W!~*D_#vbRuJ?Z@vkB4*1u9z7`sL}oGJXI_G66Dz3 z;y%0tX@58{ATSwmL6JpvrUdNYI2)B*WZxnV*gm@hupLq_FdrWp$r;`aJQTQ}&ODf$ z&oC0*HQbJ6rDnZhJw3_vy1iXfh?r(X?nh*ek7A&9|0!4v8vp-+_`;{vD z$q78#y#FP6O60`L&yYOz6RFHyDXjdd;rv4D(!*F0GKO9`Iqz&#QSD! ze#{;5x!?~q?dSwl=!DLhB>Rnt&Wk04W6i_l5(30cD7)G>Lay zV?+wU=|&QFvq+k%_&@^{vKi=GrykHfVL3G}QW|Vq1SNKp5j9SgB!07k3-k(?-ioQ0 zfHspY(%y;!dG>?lj>y;o{wqB8M<7gys4K7$p!XP59PWCnb4a3*^|df6(aZp)iMdUN zh`0&?Y)v-HrvMW8QiTDCD0l-xqwoPh_rPk{_W;soml_ACr@+z#RzaOlQ>5gSNX5AP z&9NiT+{~v`ADLYr@9)zO<=yEyR-mw6vPgDQwMzSMn?_Tj8W)es?G+LNm^wwiqC=Sj zg92TUm zC8Eey)CPhzuDaYM&Su1Fn1x+*7*UPsCsG=cqxZbvTuikf)qdM_9er&?>psHoK7H)K zBx3x_)6Pag;i!e-`MUY4fl3wI%K~n(SLkHM8EqiH2!uCgcqV5`5v3zsI>*Hxlu> zQ!uGwHO$%%DP2E6@;_LP-P6D6JZy#(_R!=CdSND>xr~wq@1X0cY=;SLn0D& zm{TM~4{Q|gRP=uYvm+Z&bF?d{t`P%RY8)EyHBww4Vg$Ck5+R#R^jmP37_|F%D?u*! z$XWJMUP-oITicxhC0(Q*Gx`d9EmB!F{C!gzS4!J2i02BjdQd^u+0&uYCi!H==gYTy zzHy)Y&$Lj&9sIQKDw)qG_l9@tijW1fLnfFP&s5$@vvDLXVrUF2H@yb3{d3K$Tk37x zwPeKLl(>P-Lme&V8DyN#v}^>l6DTwWmHh#y5~kAIKY3m=hKH@=|B)H^gu{bwy<^9~ zgtMEe*@(oeayu763(7944`%e#n^RQUBVO?dJ8*LWc|o7>GNAxPkS$xFN1X=x)AKjr zJ%Ed{mm-@GNRloPqZ_pdnQF3i`Tyw%asqszAO<|8mo;`%YkYDdb~r9;ODpxT#|Vl+ zGII4FZ&xpP`GirmId@S1e||2-4o^XmjX`V3E@1WfZ?P^wDd0)bP(i%e1moM>VItr* zyP~$t7hJDV!H-9|3bHlF9s>W|*XXhS(Dp1{??b64w6_4Ty?j z*Ir?OqOH&Eg!Mv5Wycua<%i@?y?m6ZIxcQ*oQ|VN@in$wz;w;OF&p@%Z0+FkRy_2( zOTH5~;OM$>ZqC%gi7B+Qjk7VfvZ&;!$IyEU>n`d>-2z#j`wj^f!l+$TuYtBclFgr7ovQa;RHtprswgguxxCc$BB3GS#+3b9i`D2`K~ zA@(011Ll!NaXXD^p%Q$SeQM?UCm5O-eR`>xD<|lBJMIfKvb_bbtNo|B(c^AJ|7kXJ4PT&&_99kbRAl`AeO*_VU9Ov000NC@4B|8iLVV91d6UYGQuZ#Y5 zOx@#eSNf|_isk@7{y*)N0@XSJ&Gd(8%Y%39wk0ZL<6P_Z{II3>+=3VEswn9Hh9tn1 zO3fTOIe<~K+oT=|*%v0(?a20PY+=y;m*hZGSz>{JlATlLsL|5|A?w)<#?Z}t_{^86 zwb}~d1LvO$y(+bFb>DoY{;L2Ep_s`%kS9+YP z;E~}g`1Onb8Bjqrz;!Q)0b%{x4ld{vD1f>5$}u)|-ZO@!M zB2okq(L)O4c^a-5QXJ2M|I49>NZbuY#Sv*Jz9+oXkTYDOh^*e+R{bZ*JGV4Ozck?o zk@hfv#s`|2q({vV8--a3nX&6G!Q_MLv)lO}CH(Eue}zbp5ha0IBO+ys2(qF4y{0sT z%6+vvmG1Wj5`|5_lh=5^x3^>0T)h zQ>}IjFa!y(w4^syQf^kS(lP?BZE+MwYwiYdSCJ5H2iXe&y23^a0y%5NQWL-kNZ<1N zfkCrp>Cn%K`++79TwIZSs|c8=AyOXa=oXb@pp8y3DKq-#HNBh5DlGiGekdr3Ep6U% ze^g<_z4H*o!yB8nS&(?vIqk_aM3pg_tRI^kEHQ=bxcF$IYohjKd=jUs59lMt#AwkC znO5^^4Vd&V+H-GUQ4wNtXP}cH@e(-FU*A8gGzq*@4a~|4pl&uVpRQlcHtnx!T5^R9 zcW6@m?H4q7Zun)TeN%WKWY7}{1`;6u8&SZVLC1y`#NcfA!_dB;$k9(@-S_LqXd?Pl z{RB26nV!;>6?6fN)z=byx;VM*Hm?(PP{?S7Hfh-62}_thFB||+&k}*{0Bq8UcLo0P zGKm0A|K#TLY#7c#b0WbsKUO#SHlg7K-+eH;Y;3GgNF~ji8w?V6g_M;~`t1ty(^$_} z|7>~f`?NPym=Be$6Gcm}2t588xM^zY*#HxPFt_QYN$8X}5I82zw!$vko5F-TrRi)TvZQL9GzlxmgcG{yKa6T z%ooLo-I?b#0KhYk2FTWgh~0W8g(?jH7!oO!3z99?HKslE%!}~hE#`L)_w$^h@zpf+ zNgz76|Ua|b>>{Wd_4AV1#}fhcLg zh{%I>smpt8NMLz{x%TzhLZEU3l%2^G)iSk~udg>_-asKt=J?#Z31jq+X{>%$qT0c0 z7QAnT6)&zIu-wK9Lb_l!>tRr!~w#aT?f#muLGev~UB z6OM&W02(oFFOi(q)0j5*CiWCDlD|VQUp=0z;|r3DV*$As$blb4{(5%+M%nN$&4h|7MY2Oq4iJ~`I zd0eQhM)P)%a83P->iKM_QOSV{LeO=aSnT}zt~I1gvW~De>qG>?8TcAt;wHOBXi-gn zU9UU>aspPEe+)QhnMq?b*iF45#a6pEME3?1^i&eqK*ZtcG|ayoZ*ntP^BeMge*aQ zYW$R=P)+O0t>C=_`rD6wPr`7H8r}W@q5GMIPj~@~{SmdHTa=CYro_GFqxQ2Hh1A{A z8g~tzZtd!ilscU+!^7Ftulo@6Yv`m54^Ae-={g2&y?gHdz0RWk(S-0-J@^C5?2Q z;O|k4gDNy(p$m*&vS8+-jz-{Ks&c7AGwBN~y~jJ>&A#)uQKR<3K5JMiCO2c|p*p_* zqb<$oX~g3N%St*+bFYeS!92be;uS>@%ywuFJ(YJ%<8-F}=(MYu z7cAJcy~^r~0-u@8i)YYEUeHmi_h!8maZOZU2xhNxFd)J_2wzT(EA4mn1&R$;Wj+|= zbI>fx3EbO9$qBRi#!^N2Z5+8zc7~p5QvBKkbV`nbmoATgZS42(S7;bF+GtN z9{D?%d7sYlLp;*=^tx%m7k4wOW5ZvB9v(S5N$pvZ1subX4rUd9esw!RJMy`OS}2XT^A)fnp%?76q8V84RN-xW&1)nT93|c7Obo z^!jmJyMAZWvP;#L(}y6l48n9J$l6=5!D{OV2m1I#RdR&a_3TYFujEjz_$l#(^+0}mm0 zbqw-8v-&ILU-$pvgw6L{xb8{ot|NSgT$|p*A`bDL@|TU9bX7A`^40SKOBg1c2HmP) zl6Pj<$k9{0yY+8k}4TfHq<7t?Z*{}HJ(0iS>|uQtb8uL#!?WY@z-gs@G$*>ASp z6-L*Lb~T~F<2kD^UpK3>U7Pos?Ny$})xWDE&KN9;#OOKeXOC)>B&|IjK8dqEUYxdn zkDvLmgBaBQAbiOsh=zNWLq@_WZE@b7<&D*CiT96W>N_iVN&HbhMthLjg*5zD+o>ZLw$7#P}4Y+5HJ)8s)N zv6@<>!=Ol6YaTGY>jQ^Yav^qayKE>S`|n$8%46nJ4c}(=R!pCLg39)x@|(>B%Ha%H zpXL)JKnT-|YxZq#_s-SOW;h04=sorL`g-7Ot+}68)vhq1--s+wEJ9s>SKrorA#4H9QI^ z#+b~CVHOSxgGd+tSN4A%S`;x|UuZ?^w3*CtN5J^TdCD_&tMlPkr**^-K~9{)G9$P; zgC!%AKl;Dqe@r8Dckj3MODI;AP(ovw>|*>$q31QPGH=}Pth6lpo$0Q&33h7b>iE-ak8TWpX3DDo;j-h@QYlD2I} zdUU#Fgp6=+$KEI4eW91?+?#J{EL6JjXs)BUXB5w4$@2ZSB1egC7`k^*p6QeS$JBR# zHL*qAB7#&Y(gcwvB?(1A5Q5YoEl5ZO3`J=Ip-5GPOOf7dLT>>=2oMydBVMHmsMG)o zsB|fUs9=fh4c_6msiH^jJs&~*^8V0fS^2gqm8VeOasA!vOckiMsI$7C$?6=bqb z3q+QB_>Z``@UFy!{=C&CL?E1r*(0E-8VVss~xT zYR!!w|HXGhX=;OfI2#7SC2h9r+BPN=VfDiiw^@@bhNU|9l|H35Ol0PO58}ka_|4*} zTlsj+V{L%hMBsorp$cSzLHbt%hG2Y&^TG{|2a&!Pub15jcIl~o{W~vRyC3|1O%WBW z3*P4td@+Iq54d(@@LaHeHT@LeGeLv8`4pQf$iGw6E5y3VY5NaD!Ka^r0ionF3(kG> zMH@+KZ4f?N?-J0h%y5|`3LKP{3_!Y{T`@Xsvd5iXTE27RhIuO{h{(-Z=QwUq6W%Ih zbG8p4*)uKdlp{I+bmeJ=pR4{1cj{ z-z9E}({;f2*t$OT+4AXYBRZ**nz*Y2X+Ff0Er5a(z@eqFRjFAFoz$SCGD%!!73YLy z5(B^GQlR6?(=#fY44+u3PR>tGm_*{2XT?xhs1{v2{&%jF@NX!FaugILI6^bYeuj?d zL>AaeRgOgKtYbDCQ58$QwPUAhdNK6*Mt18eJa(^>BfduIV`Qt@=2uyWugR_{srpjgDHAx&=bOqMR)e zD&1c?gyNL$13&$FA5fV8vc>DG|EQs~*sx00u2NEMZzS>qf5Z#zDSVnUBxYxqI7dDT zE$9uLX+L66v8B1Dn_hnf``rsQdlgRLm?bHrr+W=sWJRk42(nU{ilA=dx*>Put%TNF zJ<~4VradQX-o1Wn&wX&bb-X_Cl;WH5Cxu%o^?L*UW~KA9GlxP0!?g&a?mjTVSncoN z=+2t;B0WofO79Zrxon^0ktKM=Ku~`gt*du^>02B$K{|JRBvh5przW67aoTt?X$fE* zIV4ClOG9GqX=`MQDi>qgpgaYFI!6QM|2-hldQ|rGc`;so0iQR#^=&_Tw{(v~q8rCa zHv2`-8d^XI!W7pFB4{&2Imtw|g!jyz3oJn}`}g{J4a63bN-<~o`6+eoz2f@ry;p4; zPXbbJ?q9`p(M_C`Cs1Fp5RSI?+nSv+*ec9vrP*M2T!-dq*PXTV2^SooSWeX-bR zBn%R>+7i`a$~$T*Lc?C8;fVXIA33p7_ZFf2yJ?S2hv_4NK!ytN&t8%6@R9}m+ynBo z^DK1idc7JyO}eV&_=A#=UWvT})cEV`pb^gS0axCnSKRycs-W}9@rL98Yooo~)UZGV z4N^8$3v}Rs(}9?bd~f_E`V@*fbc03ftM|cUVP^PP6sgYaOc)sb*!PsDV^nZi_dk_E z;@QR6-_{5sGyK3w>CUoA5Z^v`(1)X8+;#G37Zav-Ld#9q`smb29jyr7;IN%9wey)4 zOs*U2rw5h5SwXY0Ut*Td4n99R#BW`r zvz)PJCiLaiV^c#pC0b?rC8|?#MpE1Q_cR$~-$sr4PVrc>MU|lnFH`@=Se!{LIW-lhl>4XI@=WG?K5-z~9!c3wcnyNtVkve09G&oWgt&R3 zFwCJq3lo*gyQYjZ8e0{iiPc}XICwK5JO;i*o#A!ZTkQkM^*ps-3|j@u4B1d$P-VX< z@IMBR+?7qA?-uB3?JKfw*+^uvW6#w?_a-+ww_%#8rLCVrVeeX$_?&Z$!jJw)k{!eu zEA-$|L7r>n9JD>u+3^*1oX+P%QBso=Wx7b=Vl~=hnWp=~OWK z&awgm^+fVB31Yiv)c^|4f6rSVMFnP8ipkGqm!ui(1kK8Q{$*juGKVX|$?Tafwa9~! z>jr&!M%sFrEIi+QNzs7}jb14KuYS5ctpBNC;ro2vXe z@~4hJuP1B48C{j4IZrrGV!nAe5vB+)R1!GZ^us4lT{1$ZVXv=nB?shJ>R9|d8;yYO zLG8||f{mq8OmVC%YbmjFI=I=e+S~Q7Ri9W3a7eC)SuRS?mkq+hqdw2+hK*jCUahDETZ}tb>UenEHiGSDY^G-u(UIrJ(&>oNH zSR!4y4~ojwp)#U1PZ0zDr5F}4l;7@gEJ7Wp81r@=cLpFR<(EXfbJ>ua9fIdL>+9|> zn`OUj@3C|1Ys3L50|~_Q(ywwej078K(fK}3{&VVEGHnkC7f6dDp#ZM&u`IHYEktn2ym@>=6U+%N{4hU$><}IW`|qCV~;G3`a>$dtX-l*`OXZk z_azw|y+XEoG}-(Qn$B&n`pn19Ee_*PxlofNg#&$1cGaf$H7pS`;7j%NGn%`6jlaGv zw0@4(gi)`CUd7+o`iJdJ5focv>F0Hv29kaT9$qQa9`)D`qrf>P!4Jm-ku>I0=81}l zisd>H$YMEMbik_Sa=*$)qT0JiRbN=T-gT~MMnRt|h zl`|#!y#uJ*gu;B*Ph}?_k#Vmn1%(QOvJpuxj6J4oy`Xz-7CZ9aqQZZf;@aNSI%uR715?k( z5w()t4={6A(AkOgNfvLgx2v3=9?gLitsBbJ?&Sh%%kXQ~C#N3?;y;qx z+NIaDLh5hjIo&m0;6EYp5|u+4ljw*N^Nv}1@pS6}f6e5Ukz$CVm8UEtw#ph0>+nj8^0oPHKg8!A zGuwRQ+VfNw{-+Aig;%|+42?`qR4**_EQ1MICxPxFdWxzuU(OZ39OzLuIYo`~+u(R` zaKxt8Kb%9Zi7(#`TbAKksFik(DFeyKJ;KR_`jy#X9kO~^21uEyo4uOo0d?+kN}i1T z*w==CGf20(rG%*icWrl8r{7;{y4j9AYP1+J6%ICj+mOC$tKeJ$S`^b#7Y4IUNUE1! zw+F^P(EccZZPvdmI-6n2-!Ir|rzmvsrH-sl? z!aAxPXK`WHF4j>*pU3sPzc8J#kj0jTZH~@NKo_<^i^b0{f3;p|e@`&mkOUPP#iX(# z1vj?LD!D0r_GKApdW4zg>M>8bn4$VzMxC}5W_+~zXkIkDG8z+ode_=DlM`@Z>tm}yi;E5F3RBhRH zs>q07B-iA#Yuc>vC@x*;Vb55re>yay?Fhepd2|6(bhv(Uz-&Wrgd?W&{A7^#;@6+& z0RsZymE7A9J}5dP!uqfd)>a8>k-{na&trtg|F8(rP&_4FeC$SfYze)FMD3J64ty1aT>4&zuu)bZ3n{iB@r1>>j0 zkue*l*|>?uyRb`|B5*KXp>wT}(L_`oBxmx2$KD8)+aKu}XJ+i^L?XE=hedYC#D1A5H8_&jDK9L}p zwG|uH(VgcvD@&V85|^4LzpDLV!RJ=nR)=x&i?DTtJ-^`tuO123fu~lSDcJc8waK6==)@^iRdcB;|644Df ziiM;z=9Ca7li)R1V!gA$7z;J-!u)2Ic~wO}sMCkD$VlmTyflsV4jPa^K-IH`K7)lm zD^Cu7rjlS)d=vY!`0)g{aJY?Ry`ja8ZJz~vnH}cb*vBN@qaTjdlhz8INNbY#!Wcuo zAW-l@sua!m;ljA+xW;yFV;#s2n3^=xLB%lY7?l1aVxAl*Fgmd*mD&?ATe0IwKJ3 zTT*H;$b)7hG_p}DVixp}(o~ix@AsnL865=*=$4<`N`qq9+ zKUgC6OO9H1u;b)bCC02+bD?m;LacRoTAT1@#>zXfQr{%+JC$vA^VEdqV*UQYG)7>; zifbxBQwv9-wLH2C!!qL)f4k+_7*hM95z>kG-!y<@3tyAPz z_rZghOZ>JvT$2%<}_1q__EChQonN;_Kh@0;X=#nNqbBpa95BX;b ztuW;Ymbb5kg;KY=J>ZPJf8?(f2)~h3&?DOtMmXKWF&otyC7Z&V1W%;l1r3M@{Kzvz z>`1a4CJ#ZJGOU0iJmAE4y>H+wO$1?gm?ZmUI(R6d)|4PY8kBrj_Iu}LKId~w{5944 z!gNWFU?8Q{ac>FwsRns4Y@^p+ z3;x&E=+hSYvhp_l@}pOx8}}Y}H@r_QCNg>V35qe;jbqvR7$V5Vmp)`uC6^{sUfAmQ zOP~0l>wfifnb2cVB)v<(j3-G?=qV`5g$F(^$o1Z<`?{O%MsvcBRQewl?5>yg-VkvS zA2$x6JMjp$(n5Zo=cT$Bos-Q0Mx~{7F~Rj4{?l&J)t(=VMcZtjzvfmI@5nTDES(Hs925@z0pJj(G~fr1}G{gom>igr15Y$E$&;rJF)EC()fsBDJ=pTEN9j z%oK{YFAbdn(w^13q&llHDNxPaeBfksJ~OZs zj5BZuq|`N0=*1F1 z#w1(;yYbZGcYOWU>q&t9w~CTCNP6x-vfC47{;wjbTQqnBPqRZwcx&KH~rjnQYw}sW*xbP3?O3g+V=prXdq}zoa+g^v{#bPxS@n(&@PHIkD|}zrl}AI;n-B z&FwWvkUQqhX6>$tlCHq!Eg+E6>D`N$nP(p4zi5B*9^9It7~9ho;ou#KUSB62BbR>u zoh!(-kkFwj|M*R%BQm^n4Ss9uI3Ix^)nWLGc*J|3y8-+#wq9QA#mHujZW*9FYF0xo zvR+KE&B?#gI`WRK-6Q-eL^^S;rmIqVBD?+^5t4u@@vHxEQvU>nzhnZSihzf&X>N;h z3AY_@|AkQET|bq2dx%y$-5U+qNa{bS>M01H`VA!BPS2{kvANZiTj zSJOky6kb$8QkqCT7ye+>%>5n*b$>5ft%MNj}9e z(=nqjRDzzQi&IX)HT1T(Pr7I`Ih4D4Gn6lQ*Nl_dIoXaOto;KTIPLgmFC#tZ%I?z7 z|2>#)_-#B%zFaHdf9W%yvfaM?M}c-DASc}m$JOfu$lOSKb+>=fW$8K*2zCuTC=fnF zA;iCBkRt%3#YA$*ZeVq@B_DuaWRI3gxCh~G+UffQ0X)B4T1F*x&aMp=D%@DEuM8UL#Fr}(+Zv^ z369`gg+keQrkai-yL?k)YDu8rq3heZTcmTvx2yyn`)@ifIhfzJec(U0I@bhFBnL<; zDP~Ij8;dg^p;%dH76Wm%%0D?BwU2x3o^~#C^7Y(TvkRF=wHVjZI&?ro*TV?y=-r>Y zbsUP5$9?c3udNlPx96^+9{S~-#R>)c|f`7!nYABW!sR5XMBzIgoE&E{iuE3$ymj?3s{9yi@YxR7N;kZb?R z<<=wUU!1~*`XhAJWsj4nF4gl}A~!dJ%@u-;7$S{@GmOC^&s2NIuKSZ+r>*Lbz)nYt zk4?6dr5QVt2_pEkR1X8|Yn&e3k=ht#E0U-Gl;h-^PoZsqdBP$Emdyu*BCzmv)J^q- zUY~AutNWMRXXj&Cxz}~x@p;iY6Q(8NA6X0(lVkiQd66WQU@ZI4n4Ob`$=Uuc^&MWZ z3AtdZ&Fxq7<-}Y-VkI!G>LCTw(98Q5&3gw?7esugXc&RZh%w|`6VE0K?|(cK?zc5> zJ2O#adLMA7Mg$IGo&+675#`80*Z`j-56@j6e^48X5ojN9HGW!&l(Y$v`HsXH#A%RW zbStC>OaK%WXv%Tsg&9#_Lu3G|$}I@MGad2uy7CpLm*c9}@PHZbCmKubJ`jZ;2T}o_ z5VJXy0`qQeciDaNrF208dVqWUh93rmBho2UvISgCxJ9Y{7>TsIEzaHo^GoPQ0-WVAK&-$sBMh;A! z8s~#++&0z`$77Wf_4L2m)Xtevrsx~os#zmJvRKK4<_2`a5zES`BQ{2$(V+Ms$;;xv zq_p!R*L4{vOwuE_MU*DXp=yPm{%)#QAUcWXK5i854k-f{iHqC}nQE6_ku=|1Lzw#f zVL1oE(!DyPY9KP6XMg4JtQn25(JeYpH2J%9l)wCX=D~}b$|Ldz`r z+XxgrE51sm=wbj2u!+9puC_7yoldGFo+`r4S-B~pl?}W!-LCTja`}MhO z)`b9pK*S|ES$@pj`rS_WQ6@~N*MkGyd2Q*|C=g1JKn7+m5izw%K=L3{nCfK;|77)^ z#k>AU))>_H(Q`FK4mQxf3-DgejDDMXns~K5Ms4fB-&yy`HGEc)Oj;FFwP0XI+7!%E z`#u(TPzH2CXqnBR*usUDYNo|=s?0An231LTyNqpF)3_lus`#Mc*!$0?QrvFtgD3nI zY%t)d<5b(s?=xR`YxV{J2RiWU|ANw&qv+Zi23;yWE3UyGpIhr+GI~^ z*cuA8rP249{7(aMy!niPo8!xm1mTGjGyUQ={2w4?qLaQr0FXG5Hy|HAE}HOMxvLdF zqLcU5;Y8zny8oNy%#HjRAmPL$8H-aG?4(_}w~v>&!yPH~ErB1HeyTVU(~+s-z%SNv zf$$TLp(1qfCl_h}@hF6tu5g;`>Q9t?>3oPnwu zxyBE(t>HH`e$SBhpd+8qG$=ms49&)Jb}PF{TZF=Ho43!Y4t+Bq6U&O>gzz%$cUK%B z)U&j6UCtZB+dK%yHO<}mDab57vzIEBgG^!z^hRyN5~LY_SaugiZYc3-Wa6&Ypy+fs zajQ~smM9I35DjB7He1pO;>55s%)r@?o}VOz!lQUbGR+FX`=;8h{QV>vTtQI5`GUjc zsGPwdA6p^^8Mim53giK8dOPI<=T*9IB7Fv{C~gYp`&~&5al6K+V^b?tk)%H6{|tRy z)@+U7H`M%n_1^;=RP-HZeBS_6kQDmpb!WSB*t^;ZJgds&9U{jPajoHJhG81i ze5vB8Ve0L=pRq}2dvC|pqab%LjDP1?>P-M9D#RT%FdS!%hfDp>wT?`_Q$pX45U+uy zCzAj_d%K&PZ_j)0-%g;bf4Z}j^_&FiVm*v9b==!W<1`qoNu zgj`#KmNsx5OLLq-$FPdSGFk5&4CA)Ez3Fw6dUt-;gg>!8J7qi;99EiiSC8Y<(9aXe zBwe1j-1I(sI)0I3wKvRq1w3la&JJ|*AX0CLWEw1{-&2{YLEHR+dpQZoJE56jicp!ltTJ2F0u1`MMuwp-lB6?cF>X#Dg=w>E8KxR3E(uRhhD?SM~KH`N#SQxCCqTP01oqrdB z$MDk;&1TgQ?*3>KiuY(pX}$1M*{AmK8*sYyI?K=RPC<36>yWR{+wVsDE*Lv>wqW`Q zkeijK=Ph)cI37JZ4X5Z`bkbN@8IQ--GZJi<=T!sddq{pe3bnTHRTr-72d&Lc=_wU` zWj&uj3BhE)*oZdMp@I)Q3@PWIeNGj(IRe<2t~{azZ=zq{u>^FaiqE7B_~1KI8{b*? zw7tKxX=A`DN-?;@U70;{q}`Vw87Q3_P5!zMSAcH){@rvyYQBRWd^KG zMtyvrjBGEaS8{4zHm+b_A=GBGC0~E>eHnhVv$@)|@*Eqt*HEiLI?n5W^O9WCzc1;q zJa7y!%^uGUaP@s41s^0x>vk-4B7H>Ra=8$ZTrxh2C4X<;D1VeT@Lh{GlS~r+t;ck)!rd}4CFB>Vo?5dkMlNM-lnqr0NLZ}gLZ*7!j6yr^ z1`VX3w5}$klFB6)-zE`p(jItZ`wiPoyIQ2M4!4l>kE>FWXXr=b7rxjm5 zi@hono&+*U{`rJ}zklo8X)7MW;>G0}<>=OFy%Kyf2KJyc!_TB5PA(i`FWj zCs(oc)4wL1Ly2+R{*k8dygA`@ba2RePd@hcG4ijA@s)0Jyq%+5Squ&yW);qGEWIq- zbi9EO>XVNxQ9;MO{4w)zoJx+uqS-NWr$@c*PS4;HmTL2!p~y%r7G;%o=8st_LmX!WgE6Nz$@lNQ)jj5EO(~BoN(QIo2nB%j+tzFze)i7 zOSL}f7zvtN{=QeO3p$oS0Wd&jND&=+Uar!I)0)POT!w^s*sxUv0diVITf>M2g1y%B_Me8) zW8(Z9gf5oND@=#Z24t8AYlX+<3%#Qy4mW(k`kl}^4bfbo%SpL!pGP z^jf-i&t`r5rq5|y>q*ZUk{(-CllKjY`4*d{onfHDkxhn@ zC5JUn6)h2QpQFu*yT6l5MsmHG1B)#p7NAhcfYJZ8fhdhpSxYkL)0OYwPJubXD z1t^xH-GfsKu<~UATiT8B4TtK6x0C{o*Vaqc1PI ze-WvtG^r?X$sJj*(1dQ7@l#~nZ-_{JP_Q3Q-Xb_*qhVN@ea4h3?ZR%6+x87*-YVM- z1Q8mrA2tez8j_+NQDi%mUz5SpMjJ2bB*qhEXV?0<1@!gI@ZR87x-_L?MF!AStPJyj zgQ~tcjN5+n!B0RiOuyhvdVUMd9{rDE7jh}m@3l=INzZ&DNID*YSg+FIsJm2`&Fa(eHDJoH+^#!Mck5jaGESBqM6@{PTVPQwGvxzV(>S12_rF9280p(LI7u*p~;Lk0hK0E zr_J|Iv;yu9SabZ~p51cW%hQ!)f;dI{oZu2@#Enq}7p#?BcfZZcCIRe;6E$?}hugBIyFNT1}(A=DtFJvemJ zG`Z#8RFkM|^3;8*$w|tUa;W7r2uVX%ZUAnVh5H)H3;!&zRiR`xD@%o(fXQ|6(ygeE zwxrYZAq`!|*wv9+4$o@4iS@*|fzK=#E~m=U9;Gt}LQ&h>=oD*a+|2hBkg7%biTFy5 zZ6ZJ}8a_>|PW^7^B|wzTO4S%)IzvY>zJKj~Obqiv_zsl{(ZJ4hAhJGu$IpJpXQ;HX4=r%o%OHl#2%a@FZHg(HO~6Oaed)@O;) z_9$FMN8M=v zu8P}jQ8PUe0521bCUNk?LDUn_wDqkz6u8X17OM);=L9+>Yl9}@v49pfGol7C;8<)f zg_Ql`HRLK5%~x$l)Xb`Fu-1%Cb6(H@#m1mp<<`*mygK_uNP)(rdW}}y*I(Xjy%p;=*5Hcaeh5ahvxaT(7vjdzqRtH`_FWj%aLnD3DYc z>6Vu+;~CEc6vH)bX+x7CSqQTAM%iRudZtFn*dF93FuSzP-GDKkQ}O5b%zqo$KxisVh={@Y#2qN%;I=X|B6`VQwbsyOfdddhBe2S*T1#aYcUi%ms&9z=*< zpM3kz#lFUEtsk>ST04HPA_L|jmmN0+miL$Ov(dSJ zLULfwR9GJV$I_J)6b$N$ijMrm5rT^_2?0xumI(VAnOSM8CmtC zLgzG$<&2Q1Yc6MC0fFaDvj5Dna2^4UGd!}Tp2Cd$e6rxa!xFONUr&v^BFpDB>*!!G zl$e#A(Z3UPxK%oW@K8GZC{h78G*bKO^*l^Qa0W}{0=1+OkTB&WZy5C z)SbOU#JSD79iHw{Ec5XDu}pJp7CKWg2L)8+GOacbPr@^Mr&9}odQSQ88vh-cGkej+ z)b;}~m$NT6e)aDSIVY}$6F(Y^y8gWzS7`G3nWFuBuZEwSt4sC=i%mSgYOWqBIX1p^ zgYC5%>iI9A($M_;KCsXR7G`$?Q>HDSN1Lnvl6<-!EIo0!S@2Lo1b3zg^}Oy;6*Fz? zY+V;M?h+%dL9MatzK>#+&-VG~@PS8P z;{`Jlfl_F8BE<|?dFNU>amka$OCRJ~<)5&WU!Hr}BiZkmmU5|T)zQ%gum9O~OD*=~ z0Q{1GF`Ix^lQ2g2KIbI?z|-9#T!R#RDE}mYPZQW+N=T2_Spd>^3_t^pAfd?ZHF<@g zs`^)|Y&!w6+{*yscP9uC>IQ+mAuc}?uVSt$3Iv>)n0VZ?2liA|R8Ak)2NEPxRnOM(N`=z6q1`oP<*z+TQEDu=KzKT+ z)OpBhf3?1Yz;_zm90mVzWy#G68L&nVbklLr^Py2WYuo0AQGFkVasH8V!A)8sB5n@e zL@0iFulk-n4p&#(MLn}n-SJ5U5>Cmjs_Mxu7x6`}xuF)9zRTyXlvn^G{<8}5b- zZlAr6o^eSJw$!0tG%OY8^WGPh3xANCKfkTI7qJ?mze>@1q~dWpO{}?ZXMp4%@NlKS zb~U^WWIJ_o`o35sRC!m203P_RyyKaW;dfgHuLDGs-+V~08vX$ay5|P^ptnr3G6tbC zOq7*47FNm629|7G5F*NeRjcb&4vy=KBC(nZ(7 zfFJh28IEBCempHFmm0fpWjBXmUdjdII?V%qq`W|(`d@J^mvg%5L{umd_Wu8pXN!iTIh=1;;B2V z-GbLM#mxmxTg8x0#-Z#CG2~U_@Q(>1_Dgwvim_fwiHEiO-%jQF1v#0Tf2Qk!>{l#0=9pbSs7BE~1)BxYx?(M?@2_7K}<_-|48JMkR=_y{*9G;3qsu)6Sf=6{>#3r9Un55%S?(j|o5Z%@wFKd=lu>k@HE zugk60`Z(vA+iw{%{$taR}saCHWQo>j@;on_cDMoNkARDv&G_D@c%O30WL~{Rgo4G~{M)v4@rF ze}LPsm0(a`z0uMPptOCOz$Q~7?)l%%Lu@|7ZvM&@>a~Urns{cK95N_OQ&CYdO-OF$ zt7sH$;<4hxL%l^sWydeMcmtoWogrr8MY#BqgJaS5ki+k*m{i*9S_!&BLrS}>1PQ0r zV=;%Yem|FS2=8f!!2eaXrd7}q(7LRnUCZ@qtEdv>oXGA9j2C2E(ak>VIss3AQO-V5 zYG=$Fap3&)i_twy(6m;{9z|D)9lqwrHB}W^KiFs^<=%j zZy*N?P7^nG?KMRcOtCbfPWTf5q@&w3g2BTmoABZ1u;4Z=U#uR1j>g~{B!H$L=qKBX z_zqmYe>*NfNsgx9T<0nD_?|yQN$0^6b8o9B9nc2IuJVEnM}KT_(;zwP4~x3G`c}ZF zi3QuqFw1_|d5N}@-5Qujs*w`kzbTd7;?6^dV{x3Xy~j;XGPB%%s9foT%g;61k3`s> z_fvUKL{<^+c{r3x@1(sXQ)=Ans6A4F)~cft2^pp)gY?u3gN-_oRlO7i$j&GIQR%={ z(+J`B0UsL=Z)*7_@#gXvyMBV4htq)1B&rP_k*O4L4~HKS?U*?a2pnRT`T z0580grYHQR*#-S63xmYe8kjEFy^aMhloG`Mu#71fx~Db@ng{mp3Oz~5OK{+IDr8WE zALi^e$6az^BREWWDE?wo|Bxia^?4#f`c;TH%G`An!0)T`WV_h%{o7+Gj~}NT_HFt9aS`a!@{M-aXYx{;J%QJ7b|*eB|L{LiYPFz1OehWlQ3HyS z!s+;2%fP^!{6Rp^wD<7g-*X3k$+b#LOR#UyKK!{FrpPT3Se*^~LSRT;?(*^y7V`AV z`6#JEi z-4X7*#wb8LS%xYAE1cc<=xJ||$N#HwOrlxa;%K4r0>=eGf()&4aHi&!`%iRu=OV#03FBYoe4GE&kK}@H81i z19$@Dzx}=8r99YKRITu|x8C`0!%G!|HoRT22aCTl5guoQ06=G|Rc{<3JMB~san?Wm zFW+XKrEVOetkv)QQ&4DyCqS47_MIPPx-y|@8|zUS&MP8_yrM&F{hvkF_N#wAkX`R% zIWF^5-n(A7;q|w`h5@u>6HGs3u>V2l$tV9?BbxJHy#;`#XXO7LVj&X`)6_it5}8~p z;ld^X6i*^t!6it3O8ze4;LKHz_WL-U%+x2T@c{b%)j$Ops(l(*#gv#?3aZ^Xq`oqM zj}&=BAvhL*PeE4|)8q#mZXyDB{Gmp&s);=Yk^78CkO0tN$rfEVZfw5jVc{!$LU_i* z!*|8S#YHfIl8}&4aldzBau06BhlYA8e+hcVqkgYtt@@B6y9ma6D^yom8G0lnR9CY% zKx(hJ0B8#UHbd*ASwh|Y+z|j<4;lI)E6*XQI?ouWU)#f8q}K0MH+x0^468koyUnmi zpSun6^5ewP&bSAFm5ByPu^C-brVIL?8RwZPOLFx=6P>!t+a4F)P^e$wxLN;!oq~c3 ze^`##8+41C6NZW-T)!wUEct)Bihw7)x$m4(N&rrm=FLS->q~00n!#Vp(z(nv3|>J# z&4CUtR$o^+6^kBTrFKND1ckdddIGrTqHsKHV99JHd_^1rF}!P8^A#94U@43W(%);i z4z_brC$Rrr=A&zB2#rUgO)Px4&vF<+GSd?A4YU7v0o1w!TkQ)}68ft$(b7d&? z2izwvDZ|v>CyPfZHgiT+(uN&CKaKLGuWmNM!^o#ERc!IvkliuhhXq#npdcaNaS2yy z&-tGRfI)?Tpt56Io6frSjEa6xc(KXOJ%=77->M$fe4)Vg{2>9N=Y}5|M73#wPa`M- zXtL|b#iU0fzL;iMI^pC;S_`uHtx|0H4B8SUN`nP8d*}~1z1%9Yy77X@ zgwq2~{kgiW0OFv2nNAc6T`q47sd)UTy7!^s#yn)t@dcw)yC@xaSpcA@zBPWYwy*#B zeq*jk`{UqOC?E4Tva-FW$O?xo=;#IGX$mhyQSCXn~?IYci@or%t? z!Sba-LV_sdkATu#%QJ5Ra^>*`d9LO4vu_{eK3O5CV&qk1{b6U$13ld67YOh&nfxxd z$nQMN(?c|$nR_b?4D{!Q^=2+VT0R7-esDcJQ9tFBVnOGo`^qAi zW8kT}SOIsHyN6Spe6A(94u=4^?pF;gur9m3_P6v~MDx z^yMeRZ7eqxM6epE3=@5&n*BqNvU$1Xe6Ff)qC9TglnoiqZy5>-PnlaShNv#(?>!51 z4;Wf1kJml}rfbnQ6ywGL6w~Cp%fk(6?bGLNPc-Mu6ql6Kai3azJ+vQWE#hy^`?tdL zW1i7MdTk34dx&xYSu2L=0HZVz74petI=}l^@pM00Tu&p+gYiw#4Aq~&3*gwHDdB&t z2Z%#M0A@cJ%!Cs6S58}2q&l(3NyrKqa{^2*mQ(f2$-k8DT1!hy?Bt|6umU#Jfz8QD z&Osil!xH#+Z*o%OkO7|T&(8*cEqC(p+k=OT{}9YO-G%e-=>I<0QP5EcS!6Gu?X027CnCZ2@-&*X8?#4_V(omJ+&GHmj8OHJOBXdIOcC9Fx-D?1v`;D13(sl zX5MY0r4RBAq~EUrD_u8SEE@0d1F#SL$~N})27U@VrAG9}$zDsLuj;Y0um1IReo6v{ zH9J>c*}mf$gH@I9CYWsK=|qG`t?9q!9#p;WSgW!3(O*l%a<%T09)b2;E|n^FuMIT6 zbS-x3Mlh?B$Yj(cJz?R#WBk;-s7B_%ctdc)JB4ipQ~g&$xq)@JPgxWVEcMNcr#Du2 z%LQFtL5^)_c=x1UO@FB85ZAepkl|2np5Y)|XaC@d+1rGsyti|><~8@GX_~3}Aq^aC zUE`U>{v8em-n|6Da+zuIbh=ZgK-C{4l5!vIAf&!M)QNEGS1Z0&?d!az|8ln3Dt>io z<71=Sj+jZ(>XkR|avSk^zE>8jZ}iSuen{UwN|S|dPXpJO*>tTBJRH$YZ{4i;{*WYj zR)<01YN>SaNfQei#*1q4D}-y5IWF%l?PLiwRVZ9^{R-aITXLpYq3)sw>4^ptAv#gv zBL24_gK$WKeEgz&z*>?4Zr=iNw9-%;j|MrDwfQ zDodySJ4LN`T(EH!`SW?7O!ek!2) zS(;{f-XT~3b&0EmK~C1@HK`Ug`{j8s>P22kZL3`F9FLb=+@$rH`a|rn)})6pITVRsrx*SuC*7 zTrm0dfAD5(#W3Ue-IEwx&A2t&_}>vLK%>|he-~otzvEzFIBfI0sT3d~qK6F|APdIc z>@{vc^YHdQ`GlQ9q5R(+?~`+fpLykrLHyq z{viHi3$Wt3;v+-Qy#br-odIYNUh}u?)>+@Zr|!%aSC5PiTzzF7 zG>Gf$`~0z$*Fglw&a||)^8^}tQ%zxm)N4a!1q6GOmfuII$ZpV&O#`4o-hk0ZrB={a zimQL9C4PVLiZONBO6 z{tsbq9T!#e_K$ljC=$|L0!u9*C7@E$EZs;e-CdHmbi>k(uyjg?fFd0d3rHi9g0ytq zzgfILpXYgAzdydSGiTyVopWa1*EQE%dydc%4Ut_|5|!`;F_zmXj2;gq>xYa)|7zt& z@?n1&zH6$DBNyw7=SjX)Q%9a&Abvp?qM`FY5p`+{Q!FC9o_Nv zt&?Ns53Sy$;O-5sR?U=%noS$f%5C4(XJ1rs-b$Mv8FSL&izyYCJqX_5Caq14F{gP% zGsD?;DzrM9HYh#A>Cbghbw9tB?K`HRtV^y9c&uj7`{3@+m&lHDa2OY2YP=lT=k`9EN?Dq>anX!5LuWs1{dRV#4_Rrv_DXZU;*fd9wp{3oEw_dkiKQ(H`V*1!r&PI$GC zsBQ@xS)kfU&;|{IDToJ6 zK-X?YjAnja3j14B?UBU(37SrwDEgaDot_c+FR31i{-!>7OM^8BfEO%TQNVfY|38n< z`XN95pP5h@t_<@j&Af4~z)O7K$Wy*7oEdV`3v3|^oEt+_Taw_9q>Kb0B=0xG=k%@|<{!r<<^&j9l`|`t9FFXqrFUo8GRdsn_!G?QuK5z5i{>QdL>VQV zUj_RqZyciHN3o)txc*70%n#Ko-IdQ|a+0iNH)VjIY@+ru1-uyV#-Gk8;ZvXX>fSXS zP{kKTg$}~6IAKEr_4I;%Iwn%f~!iv%Wd|fZeN}%uq=tN zXLh3j804S&q_I}M;VK`P3*%M4i~P=OpD()^+qoL==jWbc`SluMhJ=QXJl}Z8Og<>g3W;}&ckV}xKrj?jNMrJr50~YGbv%DPF`a-$ zMz%-JI61w2>&^K*xO;7_uPO8QXeK0+`C*S!JXjhjw+4=g`v$6(3eXF8e<^NiZ98WB0lm9>t$V0B<)^wM2cDy9iBjETsS$OhjNtyiTm>M;fcWmZqk=!Pe}3BNmFhOX5RAe zR6|2UeZ#@XZk@Nlc$S{n%1=9d%QW|jS0Yf-VJ80q>v4kra4em=4|4)iDs1HnYBC1M z51Jkq*t2;UWIcWv_60V404~fd40R)xVNW*M_Z0Bayh;2!_K@c7URCJhG5NoeF-}LN z2?JsoRK#dzghuW5Aeps0hBu2CKkLM(U!AC?J;{HAhQE_OF&!{7YNv5@veR)P#V2kR z78{ZC2F=G6e`mU`M+86Tlvue*@26z5sjJ1qK{P)zG#}mt9GhoPl^embgr|m*Ac|p8 zSn<Xg($6wVqTO!)+)*`LI40-Pk8qoWL%h)?OU&fRE4h;}lhm)c*W;x*V~x*P zzv1)+jHz#%^er^8!;@q7GQF|hxUJxPFiw}RV_qd|$HXA5Eh@TOQgoocOGu_^`5H#k0IuI>_pWJ=d|^bZx>wpc9jr45hmN#IZg_1$J`#=` z{^--5>?0e;zauuQ+}k2q=k_f>?ngSt`|<73`{IgpTv2&TE|&;g@%iYxl73e1{1lhd z1vT#WrF5hH;7=9eFdFOEz4xj{8mEJnSZlf_tK~%`#Akfl78lKmQjpcl#C?d3~^Zz zB(;qsY1j2qv00j8YCorcv8`ur?6QMEKfuol*JU46hTCAZzN{PPIg{B=BVQBJ$_c68 z$C|YXy1~flQ2HSA!@nvxC+VGceaZ9J1=2ae?IAZEe)Qj!UK#&I_k&Wvs!jTP5pIAQXUP33k9e^9M7MnYvoG4-~J+nVs`%EzLm`TkT& zO!kRsmOcx!SaE>MPk}b!!D`1j*G;Owg4#lKU;1=s=WRr^kaiq*GxTUoPdYc1lL-|G zIDFj-f^7XzItWR$3XsCTT3A%j=y11{3ik^Ib%<{y<#8jZ3wY9ZP zZ%GwN!^F4Q1W)rqU>Y)vhjfVX`b>GKf27uuNN}68hx+P7a+_m!qoUfVBghfSZ7LO? zaNorIOq`K6ptZuLMEs@v{Lw^-Nl6U~q)oq; zdR`YNn+~NV@R%zojRz#~n-9$-@S59dikuX9@Y;S&t3{<`Q0{WbXgrfv0PzWn;-Q(u zGJyzbbK9@k2w`cjD9sahT~<`!n0$+O!rXSq-i@SOlLnkamF4BpWO1J!##PNmfQr9H zZej1xJ4S7x!fCZk0`VjToT}eOR6)%kCE^pqSO&dLmb4rBgoSFB_(c-2b%dM6cnU+j zHDi3gX|*WBycfjg?MWFNe|sqaJy)Cikh)}5jlR9nRV#M^<06A?TuGrPz990GlWRMs z)wo(ct`g>pxQncxYQB;QE%?U*+IeZztTt|KIbyR%qX^Yv^?~k zuHW1gL8MnrBTA?jHsjkWgS1PsHTALl zJIWe}W5{;qE=(w;`>y<(F6w;0S-4X!v75*PSNgUThNc#>hSv5+-PE#IS>x{-H8<-G zWKAi_1uM2M&_A3dcT?slznRsk*lb)6wWR;$6#KrYC>quFsSl1$n0J!#cAkJ_6=>$LG5{mwh@2W3ltUXyv9}v3IR~C7BuxaN@ zd9xv4T^_rBd_+5L4~dICwpAyBKeni8)qzh9s!=Des1*M47I|kYs4lxVjd0ntv&7fj z6wFDl!?29x=n;vJ$#@yjIun^+tQlne;d++EK>VQPW3O|zKag6?K5(F;{uYu8?L$0|hZb!ChG@>O3-M30M;zawM zW!(~ZP7&_(WBG_J_fMvvZxcOY(t(>xQU-FP5Zm3x+MfiY0UX-slsWRoPkKTk+#A;` z9+*hgr_C4pGE%by+<*F1BN#kFtizNJshA?&@;Q zVDqyHxgU&x(!!1|RMGH^U&z6)SmrN;l|%5~&q~Z!nX(PkK4r7oR`JD*PaG;aRAkaP zXNtEv6o6^Uxzef{_GvQ7F}`IaXiZQ~fcm7VnZc5nTo0JTG=--6-1A*M|AXMg9pNt{N(KR3-(s{00Dk_cL^2H%)o61*vTtux1L-k+ zxX09{dul0$Y`DvT7lvOg+ z&*?uwv{xgpQa>@xVU#ARC%#4nIr}3IjjvMuWeX_NHQsk#yt)1_wIFIVajBMnH!eTX z@s`U_fiwYq6$FaG$9@wuW(B;EJ{&{#4d=2&hlH5Gol(%^NC)H?@^&SI8z`4NX~PX zhGFbd-r`}z3qU-IHEY^d4W$fJEVL7#tqH10plji41r3h!jPYMpD-a2ACewb>Fx@n| zeY)SQj+$SBWgFR6#YlZZt)+%`IdT|@kK3OzY}qMO2<8N?j(wt|9yrxc8Sm30vR4s- z2=}%?_1&n`l;&N-3``{Pv^%s-4p?>Zt@$ZJy(C52AJi||q(RaMw|~wTzbk{V;I&5g zoj;6wG4Xk8r_a48vq8{6WqLCPQ?6xU_Fb_fAB7G^)dBUs5A{fQR@L7V4jt0VEroI= z!LAMQR5?(aoUYYich#pe@vYw14AQwl!2u4KO!e7 z!S{;o%9VQ@kSM$dlf5{jnbLGOv1vq0*t4WuzLL?vB-><&i`YzoJUw0&R3FyEvZ1ef zuu^e};cI`8h`Sr2wO(YZ=BwQ0E~U{}g}XF7_1(D9Rkf)~Y0W`z++rfsS|3-56v+{y z-mJCep+~gqE3#70sJDRYawtItm!Bv-w)JDb@MUOpE)vKGs!p4pKZ>uEi;!ZZk#x9K zONhA8&-LVrDt7XD8} zn>9Rb!h$jBH37XO>)rB;xIOjp;G@Vlkxuy<>%WYnM+)XmSfz9qaEpCqO|iPO8_P^; zMRF8p&~)M8{p3eWhc73~=0Oj}KaY`!(;95f`}tAN+GYo(le_Z-nKrW}?kEZvSkm&9eV2!^WU|Ss3j|pPu8F(8G^=E(aW6+hCq(jr zXiE%@*w)=*d=n)NduK_XbCVqKXgdIdO_an}Bpw4ghGYGd&s5}-jmX7YS)UYZDJ#=< zwV6m`4m5l(nQwiM1D@dsN#>Z1fJT9s3+K(KX~n^zIji`=Zk6ARlE{2a8hWeBuDpRtJ&{;ZFE z6ew$l;p?}Czl|S`-9XY!BdsX7&1G2} z&P)!y1>2iYkb$!;xfI)`$mAOLNObfPWQvZEkxv8kP2x7?pF84g^a6FJVO9M%*~&+U z{zVCUhf;pBzS9E=wB{i2Kk(a{fnlX#qMbm|3fE2`UF|;S_LDW3o(~7?x__bQIPGTa zQRjtmYkYjL1+hI~&#M4?9&u>gnmWEQllNeob)wAAi?FWm+!8R=e@sdTlVp6qx#S|i zw)mm&)-(bblR~@$lVln>IpJF#9!LU{;%WjD*tP9I$vO4`=JuPS!hj$51*z)l>T2ZJ zlLRQ3wU*#E72FyES~5Kdk~b)@y(TO$PnzgN`I5CB2;#)ck% zsX3jzymT5_p9;-U2%FP6!Am7v!DW7HOtTtUsi(xe7Q zvnvq2Ax}&D=MK7#)`k_ae^g>vHqy80_42Tj853Lm@X5MsOL|j znxJp5d`jP*)Tb9&aEPz8vT#tQITwE+J04B6*ALh}TFPkyc0DH~!|z+bl9JdI#J5$| zIxR9+Z$3XPE>uw2+5AUsvD8*VewL>Z+!VY~u2v?nVwzb;GHMc0D}8>B`D9 zTL3s>UnzA;uc%09=NCCSbl`W)m$_<`C&E9Uh>~zrQJ=Bmwe*@sRH67PVTQ+30a1I6Ns%W2f6Sg^uVe%SH=gceTaYW*i+p9DzkZwVhXXnAfgOFwNU7?TE_{aNH>Z{>AHv$B9~Cdl1>MIGCnc(hp2#EP%{s<&I^Le9(c z+fcrNM~v|*Qo_b4+NabrK0}5AUt4oCC|^^)g_UhnpDeE^AsZdpT|b+saTI*p*1KPB z){PSN>_PR=@9&NxZ8!!ZDkD5dP79c{c4I(W?{KyF{CbxMygZ>WLpWAd07lL2wL!hMVv@Fos!Cvd;=Oug5ODu zcgs)pLqy=VV&Q1`rs_>q*rBd|GOHCpG(VZzq5}vB-cvqnN%bj9{Pr%x+@nO-IH~uR zmU={yRUrV^tlU~i?bW0d-I)`Ep2>f|9RRunLupTt@+;!dtvbhwF729DUCwy}_97dw z!R4g{AkZ8kX|P)n9v&Vx+6b6Q85lq{hWyghdcnk9vHN@+X{UJ@wOh1mj{~q3WL}P` zwCEgN$N~4&AC@ts@w{& z=yC*Pz}I|YM(cE%^A=cQ{B;z1Ehy>y!h)qxZ{AnLy>!$SL++=v^x6ARez4Rmmw;o@Cyl{pq_Q8>;XNb`x|B zs<=Vnqr_F}e53)oqP($RQNp!Q&gSYrca%|V=Eqt|pxiS_HUYRyQ6}6!T;?Q7GU2CS z1c0vt0N-0HID`UEW&kxa`%P8`c79 zq>j(9>p07*oWSApl6;aon;emXu9svRQeMxm8}rYdaG2uH`I29*B6*eH3)(eB#+#a0 zlQlNp5xWT@FL8HJ7-g8%s}65oy7HumuOgLV_8za+nv!v@?5s!gMQVR~u*^{*`>jCF z0K-6HSqjwLOdTQjq;5$9tZ_FTk`z~(v8|u~a z+?TG5&^g7n_FzgIsEQ+=#H<;FT4P#67q4Tx^mO6R36Vd=f00uuJ69(4ZL6sOPDhf*Y0Wq78b@DQs5uw&oCwh|w@%8Fp+L~@*Nr*W1s2$J#M@zy zV>r7Lx57Xf_6Ru-6fQ)z4SduiBqStYAMU0EJWy&(JQ{s=4F)&&c9aYZPk5j~Tnk`{ zbWArh5`c<~?9azQBvVZqsK$Zf(+qVI7%z`Y(7Z!Y%T19UKC*wsW5qoH5mF~^DfNW$ zkTth?H$wqpS7c=u40`Oac`4VyL@7y)KUQMTyhdg|w&@bsu;Nf_AAC*7grey;MT>dg zu9qR{&QWDN`x+7?Vg1tE4S5k;eWwLR0S|jcGgU?{rEa4yn=(J>woOO z>>Js{8|_b$a-v~=lRzf0ngFIbYWAam;MOpm6PN-NNE1k4_%m4n+XJHT1g3LxasUhT z4ouS0fCEB-2r%JwfEfaY5MW6-q%Wf%0wxt)00k2;@Mj1Vfc$Ii+jgEv_gsHm z`VtuEZRc-Gl0W>~pmsYi&CN{n`)&<5Yd&WU0~iH|qmT-~0SX~_46#!J8F)p15K$GyIz$k$r%ZMY8r*A9gY}ZW*znNAr=a@K3&l&d&A1LQWW=d{{@{6@pd~u9JjbI zGl~yd`0SEs_X)8=c4{NdRla&Xk|MI=hR#;gw=IGs54u(>HMN#?R?xVSx~g#LuKi^=9YQ?N12q-4?kbWGuP*^IunF{Q^EI8WaWrE&{f^s2`VN>Ge#MBkNNvWP-0P{(R;I&B zjyN%*R9dFhW~+uNQ_!r!0=c-j%favSP>;%v8# z$*twJrSu@_j-5Xe5gpR3%s6cctHPwnON7x^kF*m;4VA@nnDbh*0}eHhs?~w~lyNAP zc?HgFBcfK%Bm|wbzpYw>j@UIE7FNQ|O%YjU{QA~N$MjVd++IXtDjCXP#xlxot2QfE zb8$=GCjmTQ`zqu6y|XaMcQcMVZLTr7?C|xKmEiD1Q z)PPi5QUU`mWPNMAuuxi}xDSv8Ho#yW0CA1f6YO{%@e*Du$RZXv!z|_4vyamlqWUN@T><4x)=-@vwJ{nceJ8a;<4JMO-GbL*e|7v z1OrwXZPpHwPN0~7J9H%h-w*c5!8l$KXB$f+X8`R)jm7iu2omjbR&SZ2!eB}aG68}E zgMiG;maG#>4<$k2NcCnlkOs)-^b;@Xd0w-79x-ZUSvjoax3$2*CNX_6&{0N>NZT|P z5b^}!x-_NrpgipWRhFbB)Gq`2KG8QHsVMT$@S)`R5>S=OQ_W@TU`h8Ogq@s;j7#i@ zjEk%0<^qLF!-x6lJ_N0V%Kt6=rwUXe1wnX&B&hb!&k8i<$-@NW&FTlW=4l5qF7@Ng zXvm@F7%B^h5A=7$dnYIdSnY$Ri#nd3G3B7mXx`U2V~Rx^TS^xqlnJmr*e2y8kUbmI z=EF^$x@ulT3(aMUiln1wzrMngLPT(&8DiAociw7xh@t8Js~7^-qaX#SPqy9${yqEu z3zPgWb&|7Y*}lvwF4^Qf>({1__HMXB_if&(K|d0t^GoiFp8skMFZ0K?s}_YO#(|3Q zQHx_^F|bVzh(iEsyBw^+!FG8BH}{_n@(6B8dRA^}mLPHZ1R@g{Mz=df>?D3JX(Ssg zWk=IS;VR#Um_xklT6!?`A4cursN=%>*bf@M5H)XIQ!jkH}#pFq8ef^-L~ z;3^-!ryk&G5$%U;F&d~kOP{$aLmvEd2TxBlQ6FPGK=g-=1NcgZUmjm73W_G{g?BKx{BsAlXf3H|ZgZP-ht1(a zO7tkgU)QKn{SxasI|oF|Ms}axfDH}$=bR^Aepa8TR6E0V2*MaLAS1g(S6AVcXal8? zfY)Usdq+26O%}79vzyn{gxl}msabxqDOx@`JF-shN{y2NpYl|KNF)4d;^wzuLb#wV zW~cF7(sy)KzH;!*e!Yhtbk&nf--B;Pl)$T^<)F?mID;`oz%zI_n#)q8*j-)R`0#g= z{L?lkEq+Y!^Ce?*$w<}WnuagMeQqu)X}Qw@94U0T@-lZP{Oi@5Zs>TO9~9j!^rXOB zso%)C|1BQ2S||Y7Z!EfZot@Qtz>}17HydYcZl*ZaxSqQ;-EUI4E0?!;_NPO@TgnXQ zscI>P@snAZPhCj0wMV}_TQL&|NiFFp)C8e2eT5IUNrE;@KJC=S`iH9o-Z4jFYL>{AjBAPM|7axx%ufDs@Ry-HV!VWzDxkNldZ>pQEGzu>pnNb}Df{Aze} z6hSnUn;UOFj`3|R^CE&S_GDG~hV^00KrT*SI8$GS=(~7-*%$?|#WtsgqbO^7yGK{| zXQvLeO{b{X&bPpg8(9C*d-c;w35@FLPd6N?e7FR3jnneISDeffiz62L1J7#$ARZFv zipbpWClEE?5egq28cgglhmNsr5X9P#;UA9EPa{V{T$kUm&|HN}$B8K0^wyftq9s_h z&ME?yT%HN3n4gS-8TNRc682)Vlu|BZ!TGg844BTDvIcwb*UMQ z1b|?Qj3$H2oE_{ec6EL31}s`F;MeYAFdUKB0XbFcti^PmEBr;C6qhacp*Hlv%b#4} zk5zz>-5PE#@B=ZF502cABgMe#F+37v;Wod|bpL=~~hQ=8)T*2|^ZU;)$x}k3q@m{@57P@Pr~FI0QE{HS?#f!r>IQ zlj2e!0mdKjLL-Y4LpcCq&_aF8peP0WO#~q!0a%R!#KA!I4-J3N@K(ngwTe|x1zje2Mj-^T#7e>t83^{<&14YGI*qSmL zz7=z{)7SM|@&X<`B%aw-st~DIh*ZYD#8{W9zrBjp+&SlGtdclAArdazRu(r|%|x}v z$^J`M5xIw^N+T(tpg_E*AfY*G^Z+a5thoNWgFoJm-V}z0)85q&IfP`BGY2@J>4MXq z`8nY}k48DLL$_CL6$Ycpd6G>{m7$VL4yk9R*<_RucnvBS(?d{GUBb4iqI9`E+GX6P z?xH7iz}v(vTaALki178V8$a^n?I2f}8_L|zDA(w=cwlF8s`YS9gB1R^vUbvlvd`Zc z8B7{~J(NA?jBhfcN+Zar%`py%a8pp0g%nl}by*9Sr-?xpJyluB36!_w!*LUY5=C$- zL%aD$ZI%Ry+Iv>oD3g5961qYMvn6jzzh$b(Kz%RXMJIHKthtO%RU4HJXS!Ifje3yFGA*ja23+km`4h&`Vljfb@`O=@hO_t)xgxs8TSn;Jf)riV@$#Uh+5 zZXR+xN?_%omi#^TAz5!`A0|X>_gi+{$$B87dAEc~k*w=kv5neOkSW zl5kVwj+b*1{Eq1^-^ODaEX6BS%q=6t*pzD#7ye6$#Li3JI5YOb_wyr1zVu`^D5H5d z7Bxxf6*_d4hwg7)TEh$?qQ|HTKBvJ&apmB}8_MlIPTR?iP96M`R~nZubc$?j2f`Q< zD7^EdJk1aKi+nLpH}>|7NEW|R^ZPT?3@C(jb4dqEI!P5R0{Q0nl1((7rHHR6QuYi@ zxz1}GZ9nh91yQ_gnszq~a=^$a$4Qm?7&$o2A(JZ<#LZ(7&p5P*;@il4sZ96WtTG)hqeZLZ4FmHsUK-LhYsx!qPb+M7b*y}f51J|udiA)6&$gxug6l@s$G{GQ=3OshBA#W3F+&C=3t z`=e8you8}#YibjZC`n`88t=YUzc0Ld13RQ2yYQmr!%ZNCSKI74J1P$|h`$ZT-&2bX5|=oM z6Qh!w=oGjX-t6;oQL8n=Me}~OWH`pkKb^`WF=hCia9qMpR53t}rq#0Kw>oWSzOBaf zz=`FBDPO_(s*2`-H23eYNXS!h37@RyYO6ETuz)Jpwme5+RjtfYvg0-8doCjdrlOJJ zL=0lpn)h73guaKzy2G@eKzf%|o3$Giy!J&c=MUj=r|Zfbc+8k0Jc%DObl*PoF!57( zpCAu*mLRVIBe)c1L=1^tl<5__w=79`pr*`(E^jUZqh-XHE{H1+x#7T_EBiwGxzgJ7 zkQCcTKjBL_r9KQZm0lIgxxlVG*kk8A-EX|>HUX3Kg2SvP)dwc^r?oYWlhYU8xU>J< z0SZr=cz6D~dlwxY8yyqv{+;`Oq22rI&fR-}3q`3s@jL}8PxM^8FIC<+IlI8)(OlphXVH^-h3{f|6+`rGr|2Lw>l}QqL5cFmFRx#?YJNHHJuBpeTvcfwf zQ-{P&8C>Evs@XMvd50OWJ(3-Uk^DtiN0_TdADQ3$?mHD6c%U1`4NK%A1i0?-Y(KTp_P*y!6P!3tLRrcB`-C#egjTm8j zR=!*tG5o*0pm#3y%t(evZiKHvKm&5AVLTnO#Lp4=tQ`Dn={DzDrk3+C+j9%{wQwlW zt09g&@%Df2lox2oDihiSCFHi{*LVmU|H&%XRSv9@bW6}PJbjBQwfWk-meZEDwXw8x zRa4JLCJp5zSm*&AuRrJ%m?x!cz1i2-+1D2L2>2_}GKTqFot5a|O+~BtY(7^xlI4r4 z631-*1(h;%jQ$*R%=lamIIVEffjZfCsXXaJ4g$_3OSn&&MC4N4vv$qg?tNW|GJ2jc z&M5yq_xP~T_OR`Z^wlhqh@G#!B$>pcfi0O$aiS$oioc(*wwrY~4%JeKmb@O}xuH$- z#~rH8Ju5%+V~Yx1u8PD>N@KNFCY;FL#EOc`5pLSTT-A4Y{_60)vyM~AUjvcIvy3Qg!#%b~tVC>w62K(zo}uk>pG5&WWRH+7aFK^!ePa{>)I8OUhG$ZKwyr|N;5<(<#AX3}mP-1tlCP;SpX$p=~ z$DJp{$x^Sql_nHvxPz|>kHL8>j(uHupE8(3l9l3N1#7!ze&ax0;kj1gcPKJrO;_Eg zOwJ+6NRCDW+jAuHDsL%>1+R-AN4y4;lQ8JILT^?Zd@|}xE9DGz{9_Yt1M#3v5zPWx zC^BnJ8#why8iorlEz8kB{wxdnAp|JbQx1C0`MR)ntk|%o zv(mEzzqMZJ%M@zLU42)FW~b`*H31RUlah|VIed!@ojEbbm-^>knN~-xzv}+YZhW4M zFEsezJlR&6=L>U|dDrjq>1?y~f9~)sFRbh9wRRib^`Fp~lYHYoWtcxk7bhwG;9haC zFFvAlEiaa!PHb5#LP%~*w@#}9KT;y3?wKE!L@1aHzzbeeJE!8LPt6m5@PR0GbZji4 z{k8BL%hA?%z(t0IuIWn%m4%0{VS(VS3%5veQry>ng|a$<9p5Trkp(EkI$3S9t_v@iMSPX{H}O{ zo5_K4P|`FjmDV3tjy^w>C}$6@ikR#jEjQK96XR}Ji| zU=)yJ5l8fLq9C1w?cda}S5d71Wy4+ptiMgd!mAB2bOeRbGf5l|bUM2&Vu`bbw7h-( zYc+=-%q6vZI_T)z2n|MCq&baV4aSD9W1(D3=(@hED9=jTAMeLGs^eApUw#L-hE1I) zfjyt^YVTtjgTZb2uPc1{JGk&O%ALNm`XIcdYm4hil5ubNKaQx~;K6W(c-VCG?6^=) zPV6I6JEYpVD<^T`mjhi};Bi}(>)P@acIujtWZ+mu0WbCY-O55-8@a~&Y{rc?N{T7S z^bK3{bEm3PGbgz4jJ~O|yOyhwyB4?ajQaVW`={95Z?^vKA5fmlb$y!bsZB)wWqrG? zr-V&z1u~YnTBp9~ad*CyEsKd>Vy;9iZ9zq|<%sQJ`@CKWB;IdXQ)}AgAEkWHgBd@X7zQ3g2omP`$ z>dQgfRaH5bDHbM#b5Er~Tr#&h<`^ja+}KCruW+xUA z-V9hoO#;Fny=)qzWXyvMXc+QbiPLK}@;~U5p{IWUa~1t{(EJGMjJ&!N)j z$4#828S|FV>x%`{JxyaB{aN+YlcIcC1IX{={WHr-xP8{Zd!f2fPJWH^H*vmbSEcX` z^Rd47RkzcDklf0$XHsw10rczux32Q}j~5(|60JDQUCB}kk#zcwE+YNq2KIu#UqvE% z-Ln4fw-DUZH~Hp0MRuHhoV^CzagXTDr8bDJd1nUYiO|Y2ik<5lC8^MpV|>>M!}sKC zk9_GHQnMjt581|Cpl8Sv+Tndg96JAjS9fwJ{VSX9=W^VDZ|yao3wOD{%Y>JFS@oq= z5?#Lo-qf>&xvXYF&K9El?*3NsnCmu)%2A|`(FajSe_>PI(bgp_&!1tpT^sD z%c*o{o3pHBXa8J#cL*!eAz(SZsnGKYYh;8)z;fEsZ8#hAjD26FWM&3VTe-O|l&|$r=+e*4! zn~eBIOo`+Yu^d)?#TPWy_~79rMcg*oOcByJt!;Pzr3C+r6pB6K9>H#>7y$CE+GHT| z{7+cRyH#_KO?VMl=s1mdAWT5*^~YzvuTu|>gSKL6_4J%M-!dLx_rEHa>o(zweE{84 zo7);b?jK#e<2aq~w8p*)FF3B02?;sbQ88TjWzwKTyz8>qNs#i0u6=(Vnfrh|)tS%cD=Y)z!^QtL&EIJ7dyJIU~VVE!a%Lnxkee-i;`bIn1-k@=+ui5kzpK{Hu+fUw!%w4R#d<4A5dFI zoNfqs`X;I62|VNK=f9z_8LJD-?0siI@nF)WP`%0gBf4CuU|wgvzW*u#)pHJ>)cE{P zQ8m_5h6c1lzuEyR_Vqjx*))9m;@~+(P1a6Ibl$2sm3jYhVl}S>uhAdwED_;C9^4vl zZJPNEUM;ooa7mdAR<06|xtQTP@L?B`-5r$)vCuv8t~Z~OJu&<8 z_c#OoH+8zyjEZWmmxF>#Ig9BfzM0kc(RlGUR9+%W$^&-7x)I%WtbyChVFJW&<0{oo z3QL(5Mty8-!drQMB=?z+lA}u_NE;XsKOocGG*h#7B~gfpHs)D}4GqJ7nF{P;Cld*d zJf&;N;_CM`lX!-F@Z`A$sDS(TU=4fZbGr8mnRgciqL}u-I2bYc@Jx@_nu#rBPuT}< zZ4EqJ7`rMf9Nby!48%~}P7}E1~nCCC>y|+FG zQ7v`7Oq-65PD2C2h6!lyrFzXjcYyw+Ab+{M*2_6P)m=r`W;+RviJ!ZUF_Lq&;H|2A zrI9mi7d1=b`Qcxj5|dINhbi0k=!J^sxzR>zl--n?c{mc1nvQ;~)_BX5t8qvt#?Mbb+%qJP=lcY7Oza*IM8KoNjWf*HlB^Pb8!G5e?GQr@O7tMjS#l7v0OdEy>MgMy;yWK#G_4DuJ>USvDhC zqbZ-uKWDfUqe5&_IN5ZcQr*qMWO&_mV>BkUutpLueSEPomV{fOqV!R-_r%PhO5@2s zRMdq1IPvD_MpNyQ+%Gn2;j}*+YxV^ZrcQpH>ffzU!Ug>DJk?X=tSSx~58emncw!Qx zE!=2F_vu$=Vg(|Q;$ge8zov^Pn&6@XgIh-B6YB0x^5VFx%ue?AFOo?AJZY-9^|WW?0fFpay!RI{+C<{c zyNZxE%y<5dRx+{TZ2#g*!#8Z-*34lvBbwy8dw`uh`!#C|9rJHLQ@Kgx35!OEue#?f z5zB=B^1^vy4VWwe!LuGcr50}lpo~xAI3xo9-c&+Jz7CagKaNqv9{t|_WIE@5xF8Pc zcfrHrtU@|XEVG|$vSWiK-e$4&KCOf9$(tN%*_cMm^vQ06xc2Jzq1P&ku8S>|&pCc+ z$Y3*xkV}_(_-feq8%?zEysxwI)R6VH;J5_yue8KB375?c(K0wi@!dq5MkrS=ziWZ& z$v}MWsd4dQSJ&KOajmG4Zpf%b>tynELZ@ak@i?fjj(F{lLxT(nfMC`5r%tF=}P3%-lPSA3UgMCXQ&BJ&0_i` zkZaDqmZ$h&_J9JTYpIQ46Jrm@R{3w$mDe=O>R^54$r>shvdU_g^mlp7l^W@+Hbi z4|4bg*oM^Csk~N=LU(O+yi@xH8N+L*?%ZgOf3$QQ!@cg1*l7XJnZKf$C<0r;Y^2o{ z(cDh6Cbazig8QgUBfei**XLQtWW{fTMtWH&>)oBPcbLV#Qi7bT-nEUmwaF9S2m^tt zzaKS>5#SM`{Tg5?_3*xZt|6ss22?%XZ#u2&1s>Bq6OQXDoNrv%(a8fQHE3BsrOiO;L{V?k*RB_RBnyz>S-BvfeK4Sf)$d%67 zqhFER>`-m7<75_m`lW=hGD`mRn zSn|<|`Ar*D^813R(`lb=28HF3i1R=2n=&irmqw^^YFQa8L-t`g=ac6<;cebzv(e(V zIX_}mZ_73N2^X`&q#q(rWeD-X2hID`GAV=4@-_%Is9^mTy04#MFts??p(za$|8wV5 z&BAjF+>`nDOR@~Fv_y0W3~XHMJYd5Uds-{q+(tpenq>Y>UC`WPB2V+G?exto8^jYG zPpH2)3O^@Wh|Zn_X%5imU0N5q6F!sw<&2sq3vam5qmOBDmW+$Drb`80DX!|CQQ1RB zSG$LM|4#CFyz#=s7+>Dr9`i>cGhBqk%~ewbKc%lsT`KzPw+#Hs^I9isyzaGu1FxQ= zv4Py-h8oD&KA@pokNkIQ0&c8T6bvlH^LVa-yAU4q;|Vi+rnFGRxM)b zGUK3Ig+wu8)qiWxi#JsU>e%j0f2f(H8?^5Xw5c!CT@BxqRFcCoe(_qrl0cYcjvvP7O^ZXdBz&AM;m=>C zhf)w}i;NdAB%i05?5ldumSjaWybPLCc6JynaW|%kyXyuG`%Qw^8W>d*kcS`glPz#z zt!Z|Csgbk@<7tg?bjd!$L*b@1!5ZxXLB*@B@EMkqvLx;A>Tf zZ}zt)VGD|UTTw-p24fhX>S&KYNTQG#2wZhctjRsjWeZugDLgssB;$Ct0J%_vimXwn z<4jJb5UdU#pjfDS*H!*|d0{WZzgm5AZb>`Sc%@Y_?OMoF+<877tRZUcOfHxx82Zh< zyE$EFp)I0I@wbvqPitY-cG8QIHn#=%VLFWCU8(NayN5dqpOQ{1`sda&W_OzD$V*+T z7py_I-OFZP-92nq-y|3ARj+5Jz{6V>*j`x9!C4+mHFeH|ayFtkhBWwiN6WDUcuv>h zSvKafq39tD!|&@{mPw-ah>hX6F76Dhzcma$mqH!>ayw@a#%aiV)`#}Gm&8D8_X2lE30T8^+}JLM|A~!q(64l`xVe^pDglb zil{FxZ_mIdj!=84IzD34$cE+(CGOop*cWV`69Ixu3cjU6E``EJ&!~tf(s<}wy5fe$w-sKfS@tp9Q(VOV9g(9S_$wz$$K!hJ9t0Vm6uCgV@ucDN3`^a|3lDAANMHLrvLNs z0RT-ea{oW7z5=YOX8#&dy1PL@^3dH#hje#$gOsGw-JR0ihZdw8l5sqS?YW0$8nQgxzV$lo#^7zgE||_YlZ}6ftra0)XW;f{c#?h zbTv<5jazcG!;X5 zG6WmUEwS)s9_vnY%O6T?_k`jt9yC?bwZt)EelcSfkx{pO30bR1lr@8^BtgoXA(l-0 zQ!5oW*39nnK@^u6UA?tmyWqjo>j5yf@ zb2XYN1D_@>w&vs*v`2#j0yqUz8-Lwad4zqTgAOvyQW%zMOd3MQQ}l6%L7KdRM`KLN z(&41(a%H2`a=($Tr~E+5NnBh`1DSFeEb+=0F08(TZ{R>1UTn3S%ak`-2gALFj8)lN zIj6m?%&to<&~XFBbUA3RGMUi2zR-c>Ffnw69aN|PDJhzMjguM()lOMF-dmXx)@z~@ zN2B?k$EAG53u<}hF`kwYJOayR?o z7zYgmW4en$F1FBAeD?PWpd(qzU63;6A%lHcUF}%b@m=x4mb9cX)H&u%S@P!v)?5Bws6{YUB3$ zUCB5Wo=@x>KIk)en2=n5$B)}nr-FFRQ0Ahk^H6S#W3{H%({@eYC9F0^-FB01^PUCa zL?e1vI})M#Kqc7ekrvOT$?ZbwCKL@Acfctg={e-3qJgnd7w*izS4p1P6y2bYn89XJ z;*kc?=0w|SnOjWqP#pBq5UXY!MasoUtI0Pl-)q9zM>TZ^&2h2dAE7F(CVENGSD^nbGz2z0tm!z4s{du{g%GvX>IDEUMzT^uV!QX(r^1>3q0Y_@HUD6`O zGoW_?J;co?kg~#BiFx-@qUdxhQ;J7g?`bPjwjF8F?Q|#$ekUyR4eH}TX4F`)QwVJ{ z;><^-o2%Vdqcd)eC_~7$GBecdH`Kt%_|GIz#JZI9j=2! zv#e)@!<9F(^YyP_HpO?0=50T-#800DNsS-3_$zdZGMb2ezk6oO_YZA%w!12 zB+^0LZ$_dG?WpbWeG#|f&T}p(@<;NgtNP3@hofjg7lSCqc;e1s+u@uB$~cjy3T!iF z&nL=!sn^&T3B43s8qfW!G}I5dbERwR(Q2JNmIivWz_!XZ{bI3*PNUJM4)l{$r_X=@NvYk zR)(gpykZ&4MO6;CLUyFATo`U;wxZG+N;$gHUD#+K%Xi_73UHy;3#C%U{%+0>A@X`UZ_D}#d_I&_ z>si9@Uey&P^pkBeLH@iyn}kO`6zh;#5!BS7aC@Kv%kLC z3{<*s<F*@pVH3Ox64o!W zuA_D*N1eFwN)#E$&j%y@s>GlG=1$UKcFgXO!5ZJt+Hwm z;5)${6pto1BuIWft6rq(j#y^0-W(TVH=7F=%eF77#ax-MXfZ~`XYxKx_yQF!q>(+Z zCT$6aKpSW9PDiapcLp#1l7)MqNrfmIX`_1cix9$OJ`yV(>`cr^L{txV;gW*_z;x)P zM66Y^`q|Bu?XO}Bi-KAH*8bGujtKy2K>Z^dwIe-MH$>Qu(tUQBAmqv^p+bFM`rW@M z&Oa;(0M3DD&7WR%dZoO-CxFmiU0NH1YL_I<+yX9>H=^$i{EVcq3BT-Ixquap=7ZbY zV^XI=C4LoTOo`<+QNAc=YC%O)1|Iq^KLv=W^qZu;9iJRdG)qUA?{~l7E6&dsfBnmZ zVBAqI<&*ZVWpa+j{~ogHMwyAP7oPir~vNW8v zlEWdZ=O!ABsP8n=Ts4UFtzHEySa*ffw6L@R(1BA69UF%8K0;EPEdy(FVb)lVA1M0Ctxt%6 z$Bo4rD!H)AQP1zcsKR7UWcs8T+zBsB+?G1_eL<~&>nVB)=ZD5cI3`r zcSLFFVbd;5xqy2PC}ZV}lPg1TAk4EsUaUg6Z-A#Y;{^3>ks}E!WUgtLWAS9P8L_4! zO+s#MIs*RQ1L0v9Zt7@VR55(gND-)zB~>p%`{-7##_4V$IBY@KgOKTtmYy@Lo zZC{)%-4=`9lT+}Qk#T1mW3s(j%}2PM2MLU;IU}P(8a4}fDh+w1O{Lz8QvELQEwLF| zZyFsyL;AJN+FNAVtNnA%l`|Wy-%{+iR$Jq<{v?lZLA>HuujbwF9*;mZs=| zQsya7whT^bw4D*pKkgL}PsScK&!0x`{LG((A=DOQw6R)CNZ6qLON$*6YFAz|{tlU3 z5@lpur}g~CeOQJJN)n;5aWZ7P>2s7HjdV=604^l>r5%0AFmRt@>odyP31aOuPg`dj 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