From ce9218b186f1d53ee8dff66c5b3cef53309ef76c Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 18 Dec 2023 22:04:49 +0400 Subject: [PATCH 1/5] ios: rework authentication (#3556) --- apps/ios/Shared/ContentView.swift | 157 +++++++++++------- apps/ios/Shared/Model/ChatModel.swift | 2 + apps/ios/Shared/SimpleXApp.swift | 52 ++---- .../Views/UserSettings/PrivacySettings.swift | 2 + 4 files changed, 118 insertions(+), 95 deletions(-) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index b69ccbb7c7..d7b9fef218 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -14,11 +14,14 @@ struct ContentView: View { @ObservedObject var alertManager = AlertManager.shared @ObservedObject var callController = CallController.shared @Environment(\.colorScheme) var colorScheme - @Binding var doAuthenticate: Bool - @Binding var userAuthorized: Bool? - @Binding var canConnectCall: Bool - @Binding var lastSuccessfulUnlock: TimeInterval? - @Binding var showInitializationView: Bool + + var contentAccessAuthenticationExtended: Bool + + @Environment(\.scenePhase) var scenePhase + @State private var automaticAuthenticationAttempted = false + @State private var canConnectViewCall = false + @State private var lastSuccessfulUnlock: TimeInterval? = nil + @AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false @AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @@ -40,9 +43,19 @@ struct ContentView: View { } } + private var accessAuthenticated: Bool { + chatModel.contentViewAccessAuthenticated || contentAccessAuthenticationExtended + } + var body: some View { ZStack { - contentView() + // contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings. + // i.e. with separate branches like this settings are closed: `if prefPerformLA { ... contentView() ... } else { contentView() } + if !prefPerformLA || accessAuthenticated { + contentView() + } else { + lockButton() + } if chatModel.showCallView, let call = chatModel.activeCall { callView(call) } @@ -50,6 +63,7 @@ struct ContentView: View { LocalAuthView(authRequest: la) } else if showSetPasscode { SetAppPasscodeView { + chatModel.contentViewAccessAuthenticated = true prefPerformLA = true showSetPasscode = false privacyLocalAuthModeDefault.set(.passcode) @@ -60,13 +74,9 @@ struct ContentView: View { alertManager.showAlert(laPasscodeNotSetAlert()) } } - } - .onAppear { - if prefPerformLA { requestNtfAuthorization() } - initAuthenticate() - } - .onChange(of: doAuthenticate) { _ in - initAuthenticate() + if chatModel.chatDbStatus == nil { + initializationView() + } } .alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! } .sheet(isPresented: $showSettings) { @@ -76,14 +86,44 @@ struct ContentView: View { Button("System authentication") { initialEnableLA() } Button("Passcode entry") { showSetPasscode = true } } + .onChange(of: scenePhase) { phase in + logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))") + switch (phase) { + case .background: + // also see .onChange(of: scenePhase) in SimpleXApp: on entering background + // it remembers enteredBackgroundAuthenticated and sets chatModel.contentViewAccessAuthenticated to false + automaticAuthenticationAttempted = false + canConnectViewCall = false + case .active: + canConnectViewCall = !prefPerformLA || contentAccessAuthenticationExtended || unlockedRecently() + + // condition `!chatModel.contentViewAccessAuthenticated` is required for when authentication is enabled in settings or on initial notice + if prefPerformLA && !chatModel.contentViewAccessAuthenticated { + if AppChatState.shared.value != .stopped { + if contentAccessAuthenticationExtended { + chatModel.contentViewAccessAuthenticated = true + } else { + if !automaticAuthenticationAttempted { + automaticAuthenticationAttempted = true + // authenticate if call kit call is not in progress + if !(CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil) { + authenticateContentViewAccess() + } + } + } + } else { + // when app is stopped automatic authentication is not attempted + chatModel.contentViewAccessAuthenticated = contentAccessAuthenticationExtended + } + } + default: + break + } + } } @ViewBuilder private func contentView() -> some View { - if prefPerformLA && userAuthorized != true { - lockButton() - } else if chatModel.chatDbStatus == nil && showInitializationView { - initializationView() - } else if let status = chatModel.chatDbStatus, status != .ok { + if let status = chatModel.chatDbStatus, status != .ok { DatabaseErrorView(status: status) } else if !chatModel.v3DBMigration.startChat { MigrateToAppGroupView() @@ -106,11 +146,11 @@ struct ContentView: View { if CallController.useCallKit() { ActiveCallView(call: call, canConnectCall: Binding.constant(true)) .onDisappear { - if userAuthorized == false && doAuthenticate { runAuthenticate() } + if prefPerformLA && !accessAuthenticated { authenticateContentViewAccess() } } } else { - ActiveCallView(call: call, canConnectCall: $canConnectCall) - if prefPerformLA && userAuthorized != true { + ActiveCallView(call: call, canConnectCall: $canConnectViewCall) + if prefPerformLA && !accessAuthenticated { Rectangle() .fill(colorScheme == .dark ? .black : .white) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -120,22 +160,27 @@ struct ContentView: View { } private func lockButton() -> some View { - Button(action: runAuthenticate) { Label("Unlock", systemImage: "lock") } + Button(action: authenticateContentViewAccess) { Label("Unlock", systemImage: "lock") } } private func initializationView() -> some View { VStack { ProgressView().scaleEffect(2) - Text("Opening database…") + Text("Opening app…") .padding() } + .frame(maxWidth: .infinity, maxHeight: .infinity ) + .background( + Rectangle() + .fill(.background) + ) } private func mainView() -> some View { ZStack(alignment: .top) { ChatListView(showSettings: $showSettings).privacySensitive(protectScreen) .onAppear { - if !prefPerformLA { requestNtfAuthorization() } + requestNtfAuthorization() // Local Authentication notice is to be shown on next start after onboarding is complete if (!prefLANoticeShown && prefShowLANotice && !chatModel.chats.isEmpty) { prefLANoticeShown = true @@ -187,48 +232,37 @@ struct ContentView: View { } } - private func initAuthenticate() { - logger.debug("initAuthenticate") - if CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil { - userAuthorized = false - } else if doAuthenticate { - runAuthenticate() - } - } - - private func runAuthenticate() { - logger.debug("DEBUGGING: runAuthenticate") - if !prefPerformLA { - userAuthorized = true + private func unlockedRecently() -> Bool { + if let lastSuccessfulUnlock = lastSuccessfulUnlock { + return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2 } else { - logger.debug("DEBUGGING: before dismissAllSheets") - dismissAllSheets(animated: false) { - logger.debug("DEBUGGING: in dismissAllSheets callback") - chatModel.chatId = nil - justAuthenticate() - } + return false } } - private func justAuthenticate() { - userAuthorized = false - let laMode = privacyLocalAuthModeDefault.get() - authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in - logger.debug("DEBUGGING: authenticate callback: \(String(describing: laResult))") - switch (laResult) { - case .success: - userAuthorized = true - canConnectCall = true - lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime - case .failed: - if laMode == .passcode { - AlertManager.shared.showAlert(laFailedAlert()) + private func authenticateContentViewAccess() { + logger.debug("DEBUGGING: authenticateContentViewAccess") + dismissAllSheets(animated: false) { + logger.debug("DEBUGGING: authenticateContentViewAccess, in dismissAllSheets callback") + chatModel.chatId = nil + + authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in + logger.debug("DEBUGGING: authenticate callback: \(String(describing: laResult))") + switch (laResult) { + case .success: + chatModel.contentViewAccessAuthenticated = true + canConnectViewCall = true + lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime + case .failed: + chatModel.contentViewAccessAuthenticated = false + if privacyLocalAuthModeDefault.get() == .passcode { + AlertManager.shared.showAlert(laFailedAlert()) + } + case .unavailable: + prefPerformLA = false + canConnectViewCall = true + AlertManager.shared.showAlert(laUnavailableTurningOffAlert()) } - case .unavailable: - userAuthorized = true - prefPerformLA = false - canConnectCall = true - AlertManager.shared.showAlert(laUnavailableTurningOffAlert()) } } } @@ -259,6 +293,7 @@ struct ContentView: View { authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in switch laResult { case .success: + chatModel.contentViewAccessAuthenticated = true prefPerformLA = true alertManager.showAlert(laTurnedOnAlert()) case .failed: diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index e7932f2d92..0cc281fda9 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -54,6 +54,8 @@ final class ChatModel: ObservableObject { @Published var chatDbChanged = false @Published var chatDbEncrypted: Bool? @Published var chatDbStatus: DBMigrationResult? + // local authentication + @Published var contentViewAccessAuthenticated: Bool = false @Published var laRequest: LocalAuthRequest? // list of chat "previews" @Published var chats: [Chat] = [] diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 057188c37c..c023f375d3 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -16,18 +16,13 @@ struct SimpleXApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var chatModel = ChatModel.shared @ObservedObject var alertManager = AlertManager.shared + @Environment(\.scenePhase) var scenePhase - @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false - @State private var userAuthorized: Bool? - @State private var doAuthenticate = false - @State private var enteredBackground: TimeInterval? = nil - @State private var canConnectCall = false - @State private var lastSuccessfulUnlock: TimeInterval? = nil - @State private var showInitializationView = false + @State private var enteredBackgroundAuthenticated: TimeInterval? = nil init() { // DispatchQueue.global(qos: .background).sync { - haskell_init() + haskell_init() // hs_init(0, nil) // } UserDefaults.standard.register(defaults: appDefaults) @@ -39,21 +34,16 @@ struct SimpleXApp: App { } var body: some Scene { - return WindowGroup { - ContentView( - doAuthenticate: $doAuthenticate, - userAuthorized: $userAuthorized, - canConnectCall: $canConnectCall, - lastSuccessfulUnlock: $lastSuccessfulUnlock, - showInitializationView: $showInitializationView - ) + WindowGroup { + // contentAccessAuthenticationExtended has to be passed to ContentView on view initialization, + // so that it's computed by the time view renders, and not on event after rendering + ContentView(contentAccessAuthenticationExtended: !authenticationExpired()) .environmentObject(chatModel) .onOpenURL { url in logger.debug("ContentView.onOpenURL: \(url)") chatModel.appOpenUrl = url } .onAppear() { - showInitializationView = true DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { initChatAndMigrate() } @@ -62,21 +52,25 @@ struct SimpleXApp: App { logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))") switch (phase) { case .background: + // --- authentication + // see ContentView .onChange(of: scenePhase) for remaining authentication logic + if chatModel.contentViewAccessAuthenticated { + enteredBackgroundAuthenticated = ProcessInfo.processInfo.systemUptime + } + chatModel.contentViewAccessAuthenticated = false + // authentication --- + if CallController.useCallKit() && chatModel.activeCall != nil { CallController.shared.shouldSuspendChat = true } else { suspendChat() BGManager.shared.schedule() } - if userAuthorized == true { - enteredBackground = ProcessInfo.processInfo.systemUptime - } - doAuthenticate = false - canConnectCall = false NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers()) case .active: CallController.shared.shouldSuspendChat = false let appState = AppChatState.shared.value + if appState != .stopped { startChatAndActivate { if appState.inactive && chatModel.chatRunning == true { @@ -85,8 +79,6 @@ struct SimpleXApp: App { updateCallInvitations() } } - doAuthenticate = authenticationExpired() - canConnectCall = !(doAuthenticate && prefPerformLA) || unlockedRecently() } } default: @@ -121,22 +113,14 @@ struct SimpleXApp: App { } private func authenticationExpired() -> Bool { - if let enteredBackground = enteredBackground { + if let enteredBackgroundAuthenticated = enteredBackgroundAuthenticated { let delay = Double(UserDefaults.standard.integer(forKey: DEFAULT_LA_LOCK_DELAY)) - return ProcessInfo.processInfo.systemUptime - enteredBackground >= delay + return ProcessInfo.processInfo.systemUptime - enteredBackgroundAuthenticated >= delay } else { return true } } - private func unlockedRecently() -> Bool { - if let lastSuccessfulUnlock = lastSuccessfulUnlock { - return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2 - } else { - return false - } - } - private func updateChats() { do { let chats = try apiGetChats() diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 90b83fa4f3..d8ff2c2f89 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -467,6 +467,7 @@ struct SimplexLockView: View { switch a { case .enableAuth: SetAppPasscodeView { + m.contentViewAccessAuthenticated = true laLockDelay = 30 prefPerformLA = true showChangePassword = true @@ -619,6 +620,7 @@ struct SimplexLockView: View { authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in switch laResult { case .success: + m.contentViewAccessAuthenticated = true prefPerformLA = true laAlert = .laTurnedOnAlert case .failed: From 26a189917bb7a93b704dda09be2298d7c6e593e3 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 19 Dec 2023 05:37:10 +0800 Subject: [PATCH 2/5] sctipt: check string formatting (#3570) * sctipt: check string formatting * all --- apps/multiplatform/common/build.gradle.kts | 64 +++++++++++++++++-- .../commonMain/resources/MR/ar/strings.xml | 1 - .../commonMain/resources/MR/el/strings.xml | 2 +- .../commonMain/resources/MR/es/strings.xml | 2 +- .../commonMain/resources/MR/fi/strings.xml | 1 - .../commonMain/resources/MR/ja/strings.xml | 2 - .../commonMain/resources/MR/tr/strings.xml | 6 +- .../resources/MR/zh-rCN/strings.xml | 3 +- .../resources/MR/zh-rTW/strings.xml | 2 +- 9 files changed, 67 insertions(+), 16 deletions(-) diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 4b0e38d8a0..32bfadd37e 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -155,6 +155,34 @@ afterEvaluate { val endTagRegex = Regex("]*>.*(<|>).*|[^>]*>.*(<|>).*") val correctHtmlRegex = Regex("[^>]*>.*.*.*|[^>]*>.*.*.*|[^>]*>.*.*.*|[^>]*>.*]*>.*.*") + val possibleFormat = listOf("s", "d", "1\$s", "1\$d", "2s", "f") + + fun String.id(): String = replace(" { + if (!contains("%")) return emptyList() + val value = substringAfter("\">").substringBeforeLast("") + + val formats = ArrayList() + var substring = value.substringAfter("%") + while (true) { + var foundFormat = false + for (format in possibleFormat) { + if (substring.startsWith(format)) { + formats.add(format) + foundFormat = true + break + } + } + if (!foundFormat) { + throw Exception("Unknown formatting in string. Add it to 'possibleFormat' in common/build.gradle.kts if needed: $this \nin $filepath") + } + val was = substring + substring = substring.substringAfter("%") + if (was.length == substring.length) break + } + return formats + } fun String.removeCDATA(): String = if (contains(" + val tree = kotlin.sourceSets["commonMain"].resources.filter { fileRegex.containsMatchIn(it.absolutePath) }.asFileTree + val baseStringsFile = tree.first { it.absolutePath.endsWith("base/strings.xml") } ?: throw Exception("No base/strings.xml found") + val treeList = ArrayList(tree.toList()) + treeList.remove(baseStringsFile) + treeList.add(0, baseStringsFile) + val baseFormatting = mutableMapOf>() + treeList.forEachIndexed { index, file -> + val isBase = index == 0 val initialLines = ArrayList() val finalLines = ArrayList() + val errors = ArrayList() + file.useLines { lines -> val multiline = ArrayList() lines.forEach { line -> initialLines.add(line) if (stringRegex.matches(line)) { - finalLines.add(line.removeCDATA().addCDATA(file.absolutePath)) + val fixedLine = line.removeCDATA().addCDATA(file.absolutePath) + val lineId = fixedLine.id() + if (isBase) { + baseFormatting[lineId] = fixedLine.formatting(file.absolutePath) + } else if (baseFormatting[lineId] != fixedLine.formatting(file.absolutePath)) { + errors.add("Incorrect formatting in string: $fixedLine \nin ${file.absolutePath}") + } + finalLines.add(fixedLine) } else if (multiline.isEmpty() && startStringRegex.containsMatchIn(line)) { multiline.add(line) } else if (multiline.isNotEmpty() && endStringRegex.containsMatchIn(line)) { multiline.add(line) - finalLines.addAll(multiline.joinToString("\n").removeCDATA().addCDATA(file.absolutePath).split("\n")) + val fixedLines = multiline.joinToString("\n").removeCDATA().addCDATA(file.absolutePath).split("\n") + val fixedLinesJoined = fixedLines.joinToString("") + val lineId = fixedLinesJoined.id() + if (isBase) { + baseFormatting[lineId] = fixedLinesJoined.formatting(file.absolutePath) + } else if (baseFormatting[lineId] != fixedLinesJoined.formatting(file.absolutePath)) { + errors.add("Incorrect formatting in string: $fixedLinesJoined \nin ${file.absolutePath}") + } + finalLines.addAll(fixedLines) multiline.clear() } else if (multiline.isNotEmpty()) { multiline.add(line) @@ -217,10 +269,14 @@ afterEvaluate { } } if (multiline.isNotEmpty()) { - throw Exception("Unclosed string tag: ${multiline.joinToString("\n")} \nin ${file.absolutePath}") + errors.add("Unclosed string tag: ${multiline.joinToString("\n")} \nin ${file.absolutePath}") } } + if (errors.isNotEmpty()) { + throw Exception("Found errors: \n\n${errors.joinToString("\n\n")}") + } + if (!debug && finalLines != initialLines) { file.writer().use { finalLines.forEachIndexed { index, line -> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 098c748355..fd5a827ba2 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -150,7 +150,6 @@ إضافة جهة اتصال جديدة : لإنشاء رمز الاستجابة السريعة الخاص بك لمرة واحدة لجهة اتصالك.]]> امسح رمز الاستجابة السريعة : للاتصال بجهة الاتصال التي تعرض لك رمز الاستجابة السريعة.]]> مكالمتك تحت الإجراء - انتهت المكالمة تغيير عبارة مرور قاعدة البيانات؟ لا يمكن الوصول إلى Keystore لحفظ كلمة مرور قاعدة البيانات إلغاء معاينة الملف diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml index 714f31732c..7063eb9007 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml @@ -41,7 +41,7 @@ Αποδοχή Αποδοχή αιτήματος σύνδεσης; αποδεκτή κλήση - Πρόσβαση στους διακομιστές μέσω SOCKS proxy στην πόρτα 9050; Ο διακομιστής μεσολάβησης (proxy server) πρέπει να είναι ενεργός πριν ενεργοποιηθεί αυτή η ρύθμιση. + Πρόσβαση στους διακομιστές μέσω SOCKS proxy στην πόρτα %d; Ο διακομιστής μεσολάβησης (proxy server) πρέπει να είναι ενεργός πριν ενεργοποιηθεί αυτή η ρύθμιση. Προσθήκη διακομιστή… Προχωρημένες ρυθμίσεις δικτύου Προσθήκη διακομιστών μέσω σάρωσης QR κωδικών. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index 08cc7f9820..381d28afa6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -298,7 +298,7 @@ Cancelar mensaje en directo Confirmar Vaciar - Build de la aplicación + Build de la aplicación: %s ¡La llamada ha terminado! el servidor de envío ha cambiado para tí cancelar vista previa del enlace diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml index ce8692130f..5410778c44 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml @@ -171,7 +171,6 @@ Arkisto Poista keskusteluarkisto\? Luotu %1$s - %s:n rooli muutettu %s:ksi poistettu ryhmä yhdistää yhdistäminen (hyväksytty) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml index 94a35dbd8b..fb03dd1f28 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -687,7 +687,6 @@ ファイル送信が中止されました。 送信元が繋がりリクエストを削除したかもしれません。 このサーバで待ち行列を作るには認証が必要です。パスワードをご確認ください。 - アプリが定期的に新しいメッセージを受信します。一日の電池使用量が約3%で、プッシュ通知に頼らずに、あなたの端末のデータをサーバに送ることはありません。 SimpleXロック 通知を受けるには、データベースの暗証フレーズを入力してください。 SimpleX Chat サービス @@ -904,7 +903,6 @@ SIMPLEX CHATを支援 テストサーバ 受信アドレスは別のサーバーに変更されます。アドレス変更は送信者がオンラインになった後に完了します。 - SimpleX バックグラウンド・サービス を使ってます。一日の電池使用量は約3%です。]]> あなたのプライバシーを守るために、他のアプリと違って、ユーザーIDの変わりに SimpleX メッセージ束毎にIDを配布し、各連絡先が別々と扱います。 あなたのチャットプロフィールが他のグループメンバーに送られます。 エンドツーエンド暗号化を確認するには、ご自分の端末と連絡先の端末のコードを比べます (スキャンします)。 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml index 9824b0d8f0..d7df9655d3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -337,7 +337,7 @@ Mesaj gönderilirken hata oluştu Adres oluştururken hata oluştu Adres değiştirirken hata oluştu - 1$s sizinle şu yolla bağlantı kurmak istiyor + %1$s sizinle şu yolla bağlantı kurmak istiyor Ayarları değiştirirken hata oluştu Toplu konuşma bağlantısı oluştururken hata oluştu Yetki değiştirirken hata oluştu @@ -747,9 +747,9 @@ Aklınızda bulunsun: kaybederseniz, parolayı kurtaramaz veya değiştiremezsiniz.]]> Sohbet arşivi SOHBET ARŞİVİ - 1$s grubuna davet + %1$s grubuna davet Gruba katıl\? - 1$s davet edildi + %1$s davet edildi grup bağlantınız üzerinden davet edildi davet edildi Gruba davet edin diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index ed2b9986c0..31be2f187c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -1344,7 +1344,7 @@ 我们错过的第二个\"√\"!✅ 设定数据库密码 为群组禁用回执吗? - %s、%s 和 %d 已连接 + %s、%s 和 %s 已连接 修复群组成员不支持的问题 已为 %d 组启用送达回执功能 重新协商 @@ -1427,7 +1427,6 @@ 通过链接进行连接吗? 已经加入了该群组! %s、 %s 和 %d 名成员 - %s 审核了 %d 条消息 解封成员 连接到你自己? 轻按连接 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml index b1d988e465..9caf45dcc1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml @@ -11,7 +11,7 @@ 關於 SimpleX Chat 接受連接請求? 已接受通話 - 要在端口啟用 SOCKS 代理伺服器嗎?在啟用這個選項之前,必須先啟用代理伺服器。 + 要在端口啟用 SOCKS 代理伺服器嗎 %d?在啟用這個選項之前,必須先啟用代理伺服器。 管理員 然後,選按: 新增預設伺服器 From 5e042d222eeb54b570946499f2381d142d9f3672 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 19 Dec 2023 18:24:13 +0800 Subject: [PATCH 3/5] desktop: saving qr code as an image (#3572) --- .../chat/simplex/common/views/newchat/QRCode.kt | 2 +- .../simplex/common/views/helpers/Utils.desktop.kt | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt index 763addae66..7f9fae60a3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt @@ -67,7 +67,7 @@ fun QRCode( scope.launch { val image = qrCodeBitmap(connReq, 1024).replaceColor(Color.Black.toArgb(), tintColor.toArgb()) .let { if (withLogo) it.addLogo() else it } - val file = saveTempImageUncompressed(image, false) + val file = saveTempImageUncompressed(image, true) if (file != null) { shareFile("", CryptoFile.plain(file.absolutePath)) } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt index eb1792474a..19c9fc0fd7 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt @@ -5,11 +5,11 @@ import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Density -import chat.simplex.common.model.* +import chat.simplex.common.model.CIFile +import chat.simplex.common.model.readCryptoFile import chat.simplex.common.platform.* import chat.simplex.common.simplexWindowState -import java.io.ByteArrayInputStream -import java.io.File +import java.io.* import java.net.URI import javax.imageio.ImageIO import kotlin.io.encoding.Base64 @@ -148,9 +148,8 @@ actual suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean) return if (file != null) { try { val ext = if (asPng) "png" else "jpg" - val newFile = File(file.absolutePath + File.separator + generateNewFileName("IMG", ext, File(getAppFilePath("")))) - // LALAL FILE IS EMPTY - ImageIO.write(image.toAwtImage(), ext.uppercase(), newFile.outputStream()) + val newFile = File(file.absolutePath + File.separator + generateNewFileName("IMG", ext, File(file.absolutePath))) + ImageIO.write(image.toAwtImage(), ext, newFile.outputStream()) newFile } catch (e: Exception) { Log.e(TAG, "Util.kt saveTempImageUncompressed error: ${e.message}") From 7b073ba9f83e808bc19cff24bbe2732dfd630bd9 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 19 Dec 2023 10:26:01 +0000 Subject: [PATCH 4/5] core: allow deleting last user (#3567) * core: allow deleting last user (tests fail) * tests, allow activating the hidden user when there is no active user * hide logs Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> * comment Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> * comment Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- src/Simplex/Chat.hs | 25 +++++++++------ src/Simplex/Chat/View.hs | 4 ++- tests/ChatClient.hs | 4 +-- tests/ChatTests/Direct.hs | 67 ++++++++++++++++++++++++--------------- 4 files changed, 61 insertions(+), 39 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index dbccfbdfcf..6b619c5bd6 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -473,12 +473,14 @@ processChatCommand = \case coupleDaysAgo t = (`addUTCTime` t) . fromInteger . negate . (+ (2 * day)) <$> randomRIO (0, day) day = 86400 ListUsers -> CRUsersList <$> withStoreCtx' (Just "ListUsers, getUsersInfo") getUsersInfo - APISetActiveUser userId' viewPwd_ -> withUser $ \user -> do + APISetActiveUser userId' viewPwd_ -> do + unlessM chatStarted $ throwChatError CEChatNotStarted + user_ <- chatReadVar currentUser user' <- privateGetUser userId' - validateUserPassword user user' viewPwd_ + validateUserPassword_ user_ user' viewPwd_ withStoreCtx' (Just "APISetActiveUser, setActiveUser") $ \db -> setActiveUser db userId' let user'' = user' {activeUser = True} - asks currentUser >>= atomically . (`writeTVar` Just user'') + chatWriteVar currentUser $ Just user'' pure $ CRActiveUser user'' SetActiveUser uName viewPwd_ -> do tryChatError (withStore (`getUserIdByName` uName)) >>= \case @@ -2300,11 +2302,14 @@ processChatCommand = \case tryChatError (withStore (`getUser` userId)) >>= \case Left _ -> throwChatError CEUserUnknown Right user -> pure user - validateUserPassword :: User -> User -> Maybe UserPwd -> m () - validateUserPassword User {userId} User {userId = userId', viewPwdHash} viewPwd_ = + validateUserPassword :: User -> User -> Maybe UserPwd -> m () + validateUserPassword = validateUserPassword_ . Just + validateUserPassword_ :: Maybe User -> User -> Maybe UserPwd -> m () + validateUserPassword_ user_ User {userId = userId', viewPwdHash} viewPwd_ = forM_ viewPwdHash $ \pwdHash -> - let pwdOk = case viewPwd_ of - Nothing -> userId == userId' + let userId_ = (\User {userId} -> userId) <$> user_ + pwdOk = case viewPwd_ of + Nothing -> userId_ == Just userId' Just (UserPwd viewPwd) -> validPassword viewPwd pwdHash in unless pwdOk $ throwChatError CEUserUnknown validPassword :: Text -> UserPwdHash -> Bool @@ -2327,16 +2332,16 @@ processChatCommand = \case pure $ CRUserPrivacy {user, updatedUser = user'} checkDeleteChatUser :: User -> m () checkDeleteChatUser user@User {userId} = do - when (activeUser user) $ throwChatError (CECantDeleteActiveUser userId) users <- withStore' getUsers - unless (length users > 1 && (isJust (viewPwdHash user) || length (filter (isNothing . viewPwdHash) users) > 1)) $ - throwChatError (CECantDeleteLastUser userId) + let otherVisible = filter (\User {userId = userId', viewPwdHash} -> userId /= userId' && isNothing viewPwdHash) users + when (activeUser user && length otherVisible > 0) $ throwChatError (CECantDeleteActiveUser userId) deleteChatUser :: User -> Bool -> m ChatResponse deleteChatUser user delSMPQueues = do filesInfo <- withStore' (`getUserFileInfo` user) forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo withAgent $ \a -> deleteUser a (aUserId user) delSMPQueues withStore' (`deleteUserRecord` user) + when (activeUser user) $ chatWriteVar currentUser Nothing ok_ updateChatSettings :: ChatName -> (ChatSettings -> ChatSettings) -> m ChatResponse updateChatSettings (ChatName cType name) updateSettings = withUser $ \user -> do diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 4f7c8698bf..b0408690ae 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -474,7 +474,9 @@ chatItemDeletedText ChatItem {meta = CIMeta {itemDeleted}, content} membership_ _ -> "" viewUsersList :: [UserInfo] -> [StyledString] -viewUsersList = mapMaybe userInfo . sortOn ldn +viewUsersList us = + let ss = mapMaybe userInfo $ sortOn ldn us + in if null ss then ["no users"] else ss where ldn (UserInfo User {localDisplayName = n} _) = T.toLower n userInfo (UserInfo User {localDisplayName = n, profile = LocalProfile {fullName}, activeUser, showNtfs, viewPwdHash} count) diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 74e29cb0b1..821d7b032b 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -18,7 +18,7 @@ import Control.Monad.Except import Data.ByteArray (ScrubbedBytes) import Data.Functor (($>)) import Data.List (dropWhileEnd, find) -import Data.Maybe (fromJust, isNothing) +import Data.Maybe (isNothing) import qualified Data.Text as T import Network.Socket import Simplex.Chat @@ -284,7 +284,7 @@ getTermLine cc = _ -> error "no output for 5 seconds" userName :: TestCC -> IO [Char] -userName (TestCC ChatController {currentUser} _ _ _ _ _) = T.unpack . localDisplayName . fromJust <$> readTVarIO currentUser +userName (TestCC ChatController {currentUser} _ _ _ _ _) = maybe "no current user" (T.unpack . localDisplayName) <$> readTVarIO currentUser testChat2 :: HasCallStack => Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> FilePath -> IO () testChat2 = testChatCfgOpts2 testCfg testOpts diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 7d299e296d..64fa6ff3bf 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -1492,16 +1492,16 @@ testDeleteUser = \alice bob cath dan -> do connectUsers alice bob - -- cannot delete active user + alice ##> "/create user alisa" + showActiveUser alice "alisa" - alice ##> "/_delete user 1 del_smp=off" + -- cannot delete active user when there is another user + + alice ##> "/_delete user 2 del_smp=off" alice <## "cannot delete active user" -- delete user without deleting SMP queues - alice ##> "/create user alisa" - showActiveUser alice "alisa" - connectUsers alice cath alice <##> cath @@ -1519,17 +1519,7 @@ testDeleteUser = -- no connection authorization error - connection wasn't deleted (alice "/delete user alisa" - alice <## "cannot delete active user" - - alice ##> "/users" - alice <## "alisa (active)" - - alice <##> cath - - -- delete user deleting SMP queues + -- cannot delete active user when there is another user alice ##> "/create user alisa2" showActiveUser alice "alisa2" @@ -1537,10 +1527,17 @@ testDeleteUser = connectUsers alice dan alice <##> dan + alice ##> "/delete user alisa2" + alice <## "cannot delete active user" + alice ##> "/users" alice <## "alisa" alice <## "alisa2 (active)" + alice <##> dan + + -- delete user deleting SMP queues + alice ##> "/delete user alisa" alice <### ["ok", "completed deleting user"] @@ -1553,6 +1550,16 @@ testDeleteUser = alice <##> dan + -- delete last active user + + alice ##> "/delete user alisa2 del_smp=off" + alice <### ["ok", "completed deleting user"] + alice ##> "/users" + alice <## "no users" + + alice ##> "/create user alisa3" + showActiveUser alice "alisa3" + testUsersDifferentCIExpirationTTL :: HasCallStack => FilePath -> IO () testUsersDifferentCIExpirationTTL tmp = do withNewTestChat tmp "bob" bobProfile $ \bob -> do @@ -2047,12 +2054,23 @@ testUserPrivacy = userVisible alice "current " alice ##> "/hide user new_password" userHidden alice "current " - alice ##> "/_delete user 1 del_smp=on" - alice <## "cannot delete last user" - alice ##> "/_hide user 1 \"password\"" - alice <## "cannot hide the only not hidden user" alice ##> "/user alice" showActiveUser alice "alice (Alice)" + -- delete last visible active user + alice ##> "/_delete user 1 del_smp=on" + alice <### ["ok", "completed deleting user"] + -- hidden user is not shown + alice ##> "/users" + alice <## "no users" + -- but it is still possible to switch to it + alice ##> "/user alisa wrong_password" + alice <## "user does not exist or incorrect password" + alice ##> "/user alisa new_password" + showActiveUser alice "alisa" + alice ##> "/create user alisa2" + showActiveUser alice "alisa2" + alice ##> "/_hide user 3 \"password2\"" + alice <## "cannot hide the only not hidden user" -- change profile privacy for inactive user via API requires correct password alice ##> "/_unmute user 2" alice <## "hidden user always muted when inactive" @@ -2064,17 +2082,14 @@ testUserPrivacy = userVisible alice "" alice ##> "/_hide user 2 \"another_password\"" userHidden alice "" - alice ##> "/user alisa another_password" - showActiveUser alice "alisa" - alice ##> "/user alice" - showActiveUser alice "alice (Alice)" alice ##> "/_delete user 2 del_smp=on" alice <## "user does not exist or incorrect password" alice ##> "/_delete user 2 del_smp=on \"wrong_password\"" alice <## "user does not exist or incorrect password" alice ##> "/_delete user 2 del_smp=on \"another_password\"" - alice <## "ok" - alice <## "completed deleting user" + alice <### ["ok", "completed deleting user"] + alice ##> "/_delete user 3 del_smp=on" + alice <### ["ok", "completed deleting user"] where userHidden alice current = do alice <## (current <> "user alisa:") From 6ba3100d348e23549245e2d435fa9815108584e4 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 20 Dec 2023 06:38:39 +0000 Subject: [PATCH 5/5] core: batch sending messages (#3566) * core: batch sending messages * batch without iorefs (#3573) * one-pass * simplexmq * simplexmq * simplexmq * simplexmq * revert change to ios project file * refactor * simplify --------- Co-authored-by: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> --- cabal.project | 2 +- package.yaml | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 14 ++--- src/Simplex/Chat.hs | 96 +++++++++++++++++++++------------- src/Simplex/Chat/Controller.hs | 15 ++++++ tests/ChatClient.hs | 1 + 7 files changed, 87 insertions(+), 45 deletions(-) diff --git a/cabal.project b/cabal.project index 873035d7ab..e81c21c990 100644 --- a/cabal.project +++ b/cabal.project @@ -14,7 +14,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 18be2709f59a4cb20fe9758b899622092dba062e + tag: 8c250ebe19f56dd7d53572d984e8016cb0e4d658 source-repository-package type: git diff --git a/package.yaml b/package.yaml index af58ce6729..65f99a7a78 100644 --- a/package.yaml +++ b/package.yaml @@ -45,7 +45,7 @@ dependencies: - sqlcipher-simple == 0.4.* - stm == 2.5.* - terminal == 0.2.* - - time == 1.9.* + - time == 1.12.* - tls >= 1.7.0 && < 1.8 - unliftio == 0.2.* - unliftio-core == 0.2.* diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 3733163f49..9f06b66101 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."18be2709f59a4cb20fe9758b899622092dba062e" = "08dr4vyg1wz2z768iikg8fks5zqf4dw5myr87hbpv964idda3pmj"; + "https://github.com/simplex-chat/simplexmq.git"."8c250ebe19f56dd7d53572d984e8016cb0e4d658" = "080rw86yncf1h3zr5a8y65cndihq6f3ji43vxrdhr2mrb75vmw8m"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index f3918dfecd..6462d26008 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -199,7 +199,7 @@ library , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* - , time ==1.9.* + , time ==1.12.* , tls >=1.7.0 && <1.8 , unliftio ==0.2.* , unliftio-core ==0.2.* @@ -259,7 +259,7 @@ executable simplex-bot , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* - , time ==1.9.* + , time ==1.12.* , tls >=1.7.0 && <1.8 , unliftio ==0.2.* , unliftio-core ==0.2.* @@ -319,7 +319,7 @@ executable simplex-bot-advanced , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* - , time ==1.9.* + , time ==1.12.* , tls >=1.7.0 && <1.8 , unliftio ==0.2.* , unliftio-core ==0.2.* @@ -381,7 +381,7 @@ executable simplex-broadcast-bot , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* - , time ==1.9.* + , time ==1.12.* , tls >=1.7.0 && <1.8 , unliftio ==0.2.* , unliftio-core ==0.2.* @@ -442,7 +442,7 @@ executable simplex-chat , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* - , time ==1.9.* + , time ==1.12.* , tls >=1.7.0 && <1.8 , unliftio ==0.2.* , unliftio-core ==0.2.* @@ -508,7 +508,7 @@ executable simplex-directory-service , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* - , time ==1.9.* + , time ==1.12.* , tls >=1.7.0 && <1.8 , unliftio ==0.2.* , unliftio-core ==0.2.* @@ -602,7 +602,7 @@ test-suite simplex-chat-test , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* - , time ==1.9.* + , time ==1.12.* , tls >=1.7.0 && <1.8 , unliftio ==0.2.* , unliftio-core ==0.2.* diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 6b619c5bd6..4e7a1cab9a 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -35,7 +35,7 @@ import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char import Data.Constraint (Dict (..)) -import Data.Either (fromRight, partitionEithers, rights) +import Data.Either (fromRight, lefts, partitionEithers, rights) import Data.Fixed (div') import Data.Functor (($>)) import Data.Int (Int64) @@ -5002,7 +5002,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do Left _ -> messageError "x.grp.mem.inv error: referenced member does not exist" Right reMember -> do GroupMemberIntro {introId} <- withStore $ \db -> saveIntroInvitation db reMember m introInv - void . sendGroupMessage' user [reMember] (XGrpMemFwd (memberInfo m) introInv) groupId (Just introId) $ + sendGroupMemberMessage user reMember (XGrpMemFwd (memberInfo m) introInv) groupId (Just introId) $ withStore' $ \db -> updateIntroStatus db introId GMIntroInvForwarded _ -> messageError "x.grp.mem.inv can be only sent by invitee member" @@ -5529,46 +5529,62 @@ directMessage chatMsgEvent = do pure $ strEncode ChatMessage {chatVRange, msgId = Nothing, chatMsgEvent} deliverMessage :: ChatMonad m => Connection -> CMEventTag e -> MsgBody -> MessageId -> m Int64 -deliverMessage conn@Connection {connId} cmEventTag msgBody msgId = do - let msgFlags = MsgFlags {notification = hasNotification cmEventTag} - agentMsgId <- withAgent $ \a -> sendMessage a (aConnId conn) msgFlags msgBody - let sndMsgDelivery = SndMsgDelivery {connId, agentMsgId} - withStore' $ \db -> createSndMsgDelivery db sndMsgDelivery msgId +deliverMessage conn cmEventTag msgBody msgId = + deliverMessages [(conn, cmEventTag, msgBody, msgId)] >>= \case + [r] -> liftEither r + rs -> throwChatError $ CEInternalError $ "deliverMessage: expected 1 result, got " <> show (length rs) + +deliverMessages :: ChatMonad' m => [(Connection, CMEventTag e, MsgBody, MessageId)] -> m [Either ChatError Int64] +deliverMessages msgReqs = do + sent <- zipWith prepareBatch msgReqs <$> withAgent' (`sendMessages` aReqs) + withStoreBatch $ \db -> map (bindRight $ createDelivery db) sent + where + aReqs = map (\(conn, cmEvTag, msgBody, _msgId) -> (aConnId conn, msgFlags cmEvTag, msgBody)) msgReqs + msgFlags cmEvTag = MsgFlags {notification = hasNotification cmEvTag} + prepareBatch req = bimap (`ChatErrorAgent` Nothing) (req,) + createDelivery :: DB.Connection -> ((Connection, CMEventTag e, MsgBody, MessageId), AgentMsgId) -> IO (Either ChatError Int64) + createDelivery db ((Connection {connId}, _, _, msgId), agentMsgId) = + Right <$> createSndMsgDelivery db (SndMsgDelivery {connId, agentMsgId}) msgId sendGroupMessage :: (MsgEncodingI e, ChatMonad m) => User -> GroupInfo -> [GroupMember] -> ChatMsgEvent e -> m (SndMessage, [GroupMember]) -sendGroupMessage user GroupInfo {groupId} members chatMsgEvent = - sendGroupMessage' user members chatMsgEvent groupId Nothing $ pure () - -sendGroupMessage' :: forall e m. (MsgEncodingI e, ChatMonad m) => User -> [GroupMember] -> ChatMsgEvent e -> Int64 -> Maybe Int64 -> m () -> m (SndMessage, [GroupMember]) -sendGroupMessage' user members chatMsgEvent groupId introId_ postDeliver = do - msg <- createSndMessage chatMsgEvent (GroupId groupId) - -- TODO collect failed deliveries into a single error +sendGroupMessage user GroupInfo {groupId} members chatMsgEvent = do + msg@SndMessage {msgId, msgBody} <- createSndMessage chatMsgEvent (GroupId groupId) recipientMembers <- liftIO $ shuffleMembers (filter memberCurrent members) $ \GroupMember {memberRole} -> memberRole - rs <- forM recipientMembers $ \m -> - messageMember m msg `catchChatError` (\e -> toView (CRChatError (Just user) e) $> Nothing) - let sentToMembers = catMaybes rs + let tag = toCMEventTag chatMsgEvent + (toSend, pending) = foldr addMember ([], []) recipientMembers + msgReqs = map (\(_, conn) -> (conn, tag, msgBody, msgId)) toSend + delivered <- deliverMessages msgReqs + let errors = lefts delivered + unless (null errors) $ toView $ CRChatErrors (Just user) errors + stored <- withStoreBatch' $ \db -> map (\m -> createPendingGroupMessage db (groupMemberId' m) msgId Nothing) pending + let sentToMembers = filterSent delivered toSend fst <> filterSent stored pending id pure (msg, sentToMembers) where - messageMember :: GroupMember -> SndMessage -> m (Maybe GroupMember) - messageMember m@GroupMember {groupMemberId} SndMessage {msgId, msgBody} = case memberConn m of - Nothing -> pendingOrForwarded - Just conn@Connection {connStatus} - | connDisabled conn || connStatus == ConnDeleted -> pure Nothing - | connStatus == ConnSndReady || connStatus == ConnReady -> do - let tag = toCMEventTag chatMsgEvent - deliverMessage conn tag msgBody msgId >> postDeliver - pure $ Just m - | otherwise -> pendingOrForwarded + addMember m (toSend, pending) = case memberSendAction chatMsgEvent members m of + Just (MSASend conn) -> ((m, conn) : toSend, pending) + Just MSAPending -> (toSend, m : pending) + Nothing -> (toSend, pending) + filterSent :: [Either ChatError a] -> [mem] -> (mem -> GroupMember) -> [GroupMember] + filterSent rs ms mem = [mem m | (Right _, m) <- zip rs ms] + +data MemberSendAction = MSASend Connection | MSAPending + +memberSendAction :: ChatMsgEvent e -> [GroupMember] -> GroupMember -> Maybe MemberSendAction +memberSendAction chatMsgEvent members m = case memberConn m of + Nothing -> pendingOrForwarded + Just conn@Connection {connStatus} + | connDisabled conn || connStatus == ConnDeleted -> Nothing + | connStatus == ConnSndReady || connStatus == ConnReady -> Just (MSASend conn) + | otherwise -> pendingOrForwarded + where + pendingOrForwarded + | forwardSupported && isForwardedGroupMsg chatMsgEvent = Nothing + | isXGrpMsgForward chatMsgEvent = Nothing + | otherwise = Just MSAPending where - pendingOrForwarded - | forwardSupported && isForwardedGroupMsg chatMsgEvent = pure Nothing - | isXGrpMsgForward chatMsgEvent = pure Nothing - | otherwise = do - withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_ - pure $ Just m - forwardSupported = do + forwardSupported = let mcvr = memberChatVRange' m - isCompatibleRange mcvr groupForwardVRange && invitingMemberSupportsForward + in isCompatibleRange mcvr groupForwardVRange && invitingMemberSupportsForward invitingMemberSupportsForward = case m.invitedByGroupMemberId of Just invMemberId -> -- can be optimized for large groups by replacing [GroupMember] with Map GroupMemberId GroupMember @@ -5582,6 +5598,16 @@ sendGroupMessage' user members chatMsgEvent groupId introId_ postDeliver = do XGrpMsgForward {} -> True _ -> False +sendGroupMemberMessage :: forall e m. (MsgEncodingI e, ChatMonad m) => User -> GroupMember -> ChatMsgEvent e -> Int64 -> Maybe Int64 -> m () -> m () +sendGroupMemberMessage user m@GroupMember {groupMemberId} chatMsgEvent groupId introId_ postDeliver = do + msg <- createSndMessage chatMsgEvent (GroupId groupId) + messageMember msg `catchChatError` (\e -> toView (CRChatError (Just user) e)) + where + messageMember :: SndMessage -> m () + messageMember SndMessage {msgId, msgBody} = forM_ (memberSendAction chatMsgEvent [m] m) $ \case + MSASend conn -> deliverMessage conn (toCMEventTag chatMsgEvent) msgBody msgId >> postDeliver + MSAPending -> withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_ + shuffleMembers :: [a] -> (a -> GroupMemberRole) -> IO [a] shuffleMembers ms role = do let (adminMs, otherMs) = partition ((GRAdmin <=) . role) ms diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 8446c15a81..70e0cc64fc 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -84,6 +84,7 @@ import Simplex.RemoteControl.Invitation (RCSignedInvitation, RCVerifiedInvitatio import Simplex.RemoteControl.Types import System.IO (Handle) import System.Mem.Weak (Weak) +import qualified UnliftIO.Exception as E import UnliftIO.STM versionNumber :: String @@ -1287,12 +1288,26 @@ withStoreCtx ctx_ action = do handleInternal :: String -> SomeException -> IO (Either StoreError a) handleInternal ctxStr e = pure . Left . SEInternalError $ show e <> ctxStr +withStoreBatch :: (ChatMonad' m, Traversable t) => (DB.Connection -> t (IO (Either ChatError a))) -> m (t (Either ChatError a)) +withStoreBatch actions = do + ChatController {chatStore} <- ask + liftIO $ withTransaction chatStore $ mapM (`E.catch` handleInternal) . actions + where + handleInternal :: E.SomeException -> IO (Either ChatError a) + handleInternal = pure . Left . ChatError . CEInternalError . show + +withStoreBatch' :: (ChatMonad' m, Traversable t) => (DB.Connection -> t (IO a)) -> m (t (Either ChatError a)) +withStoreBatch' actions = withStoreBatch $ fmap (fmap Right) . actions + withAgent :: ChatMonad m => (AgentClient -> ExceptT AgentErrorType m a) -> m a withAgent action = asks smpAgent >>= runExceptT . action >>= liftEither . first (`ChatErrorAgent` Nothing) +withAgent' :: ChatMonad' m => (AgentClient -> m a) -> m a +withAgent' action = asks smpAgent >>= action + $(JQ.deriveJSON (enumJSON $ dropPrefix "HS") ''HelpSection) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CLQ") ''ChatListQuery) diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 821d7b032b..c32d8002b9 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -353,6 +353,7 @@ serverCfg = serverStatsBackupFile = Nothing, smpServerVRange = supportedSMPServerVRange, transportConfig = defaultTransportServerConfig, + smpHandshakeTimeout = 1000000, controlPort = Nothing }