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 anyHtmlRegex = 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 )
- -- cannot delete new active user
-
- 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
}