diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index f20882383a..48fe8ee377 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -14,9 +14,28 @@ class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { logger.debug("AppDelegate: didFinishLaunchingWithOptions") application.registerForRemoteNotifications() + if #available(iOS 17.0, *) { trackKeyboard() } return true } + @available(iOS 17.0, *) + private func trackKeyboard() { + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil) + } + + @available(iOS 17.0, *) + @objc func keyboardWillShow(_ notification: Notification) { + if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue { + ChatModel.shared.keyboardHeight = keyboardFrame.cgRectValue.height + } + } + + @available(iOS 17.0, *) + @objc func keyboardWillHide(_ notification: Notification) { + ChatModel.shared.keyboardHeight = 0 + } + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { let token = deviceToken.map { String(format: "%02hhx", $0) }.joined() logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)") diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 7ce430ca5e..f166cfbff3 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -57,6 +57,8 @@ final class ChatModel: ObservableObject { @Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source @Published var draft: ComposeState? @Published var draftChatId: String? + // tracks keyboard height via subscription in AppDelegate + @Published var keyboardHeight: CGFloat = 0 var messageDelivery: Dictionary Void> = [:] @@ -133,6 +135,14 @@ final class ChatModel: ObservableObject { updateChat(.direct(contact: contact), addMissing: contact.directOrUsed) } + func updateContactConnectionStats(_ contact: Contact, _ connectionStats: ConnectionStats) { + var updatedConn = contact.activeConn + updatedConn.connectionStats = connectionStats + var updatedContact = contact + updatedContact.activeConn = updatedConn + updateContact(updatedContact) + } + func updateGroup(_ groupInfo: GroupInfo) { updateChat(.group(groupInfo: groupInfo)) } @@ -521,6 +531,16 @@ final class ChatModel: ObservableObject { } } + func updateGroupMemberConnectionStats(_ groupInfo: GroupInfo, _ member: GroupMember, _ connectionStats: ConnectionStats) { + if let conn = member.activeConn { + var updatedConn = conn + updatedConn.connectionStats = connectionStats + var updatedMember = member + updatedMember.activeConn = updatedConn + _ = upsertGroupMember(groupInfo, updatedMember) + } + } + func unreadChatItemCounts(itemsInView: Set) -> UnreadChatItemCounts { var i = 0 var totalBelow = 0 diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 10073fa8a6..1e73abf393 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -478,9 +478,9 @@ func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profi throw r } -func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) throws -> (ConnectionStats?) { +func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) throws -> (GroupMember, ConnectionStats?) { let r = chatSendCmdSync(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId)) - if case let .groupMemberInfo(_, _, _, connStats_) = r { return (connStats_) } + if case let .groupMemberInfo(_, _, member, connStats_) = r { return (member, connStats_) } throw r } @@ -508,6 +508,18 @@ func apiAbortSwitchGroupMember(_ groupId: Int64, _ groupMemberId: Int64) throws throw r } +func apiSyncContactRatchet(_ contactId: Int64, _ force: Bool) throws -> ConnectionStats { + let r = chatSendCmdSync(.apiSyncContactRatchet(contactId: contactId, force: force)) + if case let .contactRatchetSyncStarted(_, _, connectionStats) = r { return connectionStats } + throw r +} + +func apiSyncGroupMemberRatchet(_ groupId: Int64, _ groupMemberId: Int64, _ force: Bool) throws -> (GroupMember, ConnectionStats) { + let r = chatSendCmdSync(.apiSyncGroupMemberRatchet(groupId: groupId, groupMemberId: groupMemberId, force: force)) + if case let .groupMemberRatchetSyncStarted(_, _, member, connectionStats) = r { return (member, connectionStats) } + throw r +} + func apiGetContactCode(_ contactId: Int64) async throws -> (Contact, String) { let r = await chatSendCmd(.apiGetContactCode(contactId: contactId)) if case let .contactCode(_, contact, connectionCode) = r { return (contact, connectionCode) } @@ -1453,6 +1465,14 @@ func processReceivedMsg(_ res: ChatResponse) async { } case .chatSuspended: chatSuspended() + case let .contactSwitch(_, contact, switchProgress): + m.updateContactConnectionStats(contact, switchProgress.connectionStats) + case let .groupMemberSwitch(_, groupInfo, member, switchProgress): + m.updateGroupMemberConnectionStats(groupInfo, member, switchProgress.connectionStats) + case let .contactRatchetSync(_, contact, ratchetSyncProgress): + m.updateContactConnectionStats(contact, ratchetSyncProgress.connectionStats) + case let .groupMemberRatchetSync(_, groupInfo, member, ratchetSyncProgress): + m.updateGroupMemberConnectionStats(groupInfo, member, ratchetSyncProgress.connectionStats) default: logger.debug("unsupported event: \(res.responseType)") } diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index a76ed8aa9a..6d9b50d031 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -76,6 +76,7 @@ struct ChatInfoView: View { case networkStatusAlert case switchAddressAlert case abortSwitchAddressAlert + case syncConnectionForceAlert case error(title: LocalizedStringKey, error: LocalizedStringKey = "") var id: String { @@ -85,6 +86,7 @@ struct ChatInfoView: View { case .networkStatusAlert: return "networkStatusAlert" case .switchAddressAlert: return "switchAddressAlert" case .abortSwitchAddressAlert: return "abortSwitchAddressAlert" + case .syncConnectionForceAlert: return "syncConnectionForceAlert" case let .error(title, _): return "error \(title)" } } @@ -115,6 +117,12 @@ struct ChatInfoView: View { Section { if let code = connectionCode { verifyCodeButton(code) } contactPreferencesButton() + if let connStats = connectionStats, + connStats.ratchetSyncAllowed { + synchronizeConnectionButton() + } else if developerTools { + synchronizeConnectionButtonForce() + } } if let contactLink = contact.contactLink { @@ -141,12 +149,18 @@ struct ChatInfoView: View { Button("Change receiving address") { alert = .switchAddressAlert } - .disabled(connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }) + .disabled( + connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } + || connStats.ratchetSyncSendProhibited + ) if connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } { Button("Abort changing address") { alert = .abortSwitchAddressAlert } - .disabled(connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }) + .disabled( + connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch } + || connStats.ratchetSyncSendProhibited + ) } smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }) smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }) @@ -175,6 +189,7 @@ struct ChatInfoView: View { case .networkStatusAlert: return networkStatusAlert() case .switchAddressAlert: return switchAddressAlert(switchContactAddress) case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress) + case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) }) case let .error(title, error): return mkAlert(title: title, message: error) } } @@ -280,6 +295,24 @@ struct ChatInfoView: View { } } + private func synchronizeConnectionButton() -> some View { + Button { + syncContactConnection(force: false) + } label: { + Label("Fix connection", systemImage: "exclamationmark.arrow.triangle.2.circlepath") + .foregroundColor(.orange) + } + } + + private func synchronizeConnectionButtonForce() -> some View { + Button { + alert = .syncConnectionForceAlert + } label: { + Label("Renegotiate encryption", systemImage: "exclamationmark.triangle") + .foregroundColor(.red) + } + } + private func networkStatusRow() -> some View { HStack { Text("Network status") @@ -370,6 +403,10 @@ struct ChatInfoView: View { do { let stats = try apiSwitchContact(contactId: contact.apiId) connectionStats = stats + await MainActor.run { + chatModel.updateContactConnectionStats(contact, stats) + dismiss() + } } catch let error { logger.error("switchContactAddress apiSwitchContact error: \(responseError(error))") let a = getErrorAlert(error, "Error changing address") @@ -385,6 +422,9 @@ struct ChatInfoView: View { do { let stats = try apiAbortSwitchContact(contact.apiId) connectionStats = stats + await MainActor.run { + chatModel.updateContactConnectionStats(contact, stats) + } } catch let error { logger.error("abortSwitchContactAddress apiAbortSwitchContact error: \(responseError(error))") let a = getErrorAlert(error, "Error aborting address change") @@ -394,6 +434,25 @@ struct ChatInfoView: View { } } } + + private func syncContactConnection(force: Bool) { + Task { + do { + let stats = try apiSyncContactRatchet(contact.apiId, force) + connectionStats = stats + await MainActor.run { + chatModel.updateContactConnectionStats(contact, stats) + dismiss() + } + } catch let error { + logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))") + let a = getErrorAlert(error, "Error synchronizing connection") + await MainActor.run { + alert = .error(title: a.title, error: a.message) + } + } + } + } } func switchAddressAlert(_ switchAddress: @escaping () -> Void) -> Alert { @@ -414,6 +473,15 @@ func abortSwitchAddressAlert(_ abortSwitchAddress: @escaping () -> Void) -> Aler ) } +func syncConnectionForceAlert(_ syncConnectionForce: @escaping () -> Void) -> Alert { + Alert( + title: Text("Renegotiate encryption?"), + message: Text("The encryption is working and the new encryption agreement is not required. It may result in connection errors!"), + primaryButton: .destructive(Text("Renegotiate"), action: syncConnectionForce), + secondaryButton: .cancel() + ) +} + struct ChatInfoView_Previews: PreviewProvider { static var previews: some View { ChatInfoView( diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift index 928773023e..f4f8a52eb2 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift @@ -12,25 +12,215 @@ import SimpleXChat let decryptErrorReason: LocalizedStringKey = "It can happen when you or your connection used the old database backup." struct CIRcvDecryptionError: View { + @EnvironmentObject var chat: Chat var msgDecryptError: MsgDecryptError var msgCount: UInt32 var chatItem: ChatItem var showMember = false + @State private var alert: CIRcvDecryptionErrorAlert? + + enum CIRcvDecryptionErrorAlert: Identifiable { + case syncAllowedAlert(_ syncConnection: () -> Void) + case syncNotSupportedContactAlert + case syncNotSupportedMemberAlert + case decryptionErrorAlert + case error(title: LocalizedStringKey, error: LocalizedStringKey) + + var id: String { + switch self { + case .syncAllowedAlert: return "syncAllowedAlert" + case .syncNotSupportedContactAlert: return "syncNotSupportedContactAlert" + case .syncNotSupportedMemberAlert: return "syncNotSupportedMemberAlert" + case .decryptionErrorAlert: return "decryptionErrorAlert" + case let .error(title, _): return "error \(title)" + } + } + } var body: some View { - CIMsgError(chatItem: chatItem, showMember: showMember) { - var message: Text - let why = Text(decryptErrorReason) - let permanent = Text("This error is permanent for this connection, please re-connect.") - switch msgDecryptError { - case .ratchetHeader: - message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why + Text("\n") + permanent - case .tooManySkipped: - message = Text("\(msgCount) messages skipped.") + Text("\n") + why + Text("\n") + permanent + viewBody() + .onAppear { + // for direct chat ConnectionStats are populated on opening chat, see ChatView onAppear + if case let .group(groupInfo) = chat.chatInfo, + case let .groupRcv(groupMember) = chatItem.chatDir { + do { + let (member, stats) = try apiGroupMemberInfo(groupInfo.apiId, groupMember.groupMemberId) + if let s = stats { + ChatModel.shared.updateGroupMemberConnectionStats(groupInfo, member, s) + } + } catch let error { + logger.error("apiGroupMemberInfo error: \(responseError(error))") + } + } } - AlertManager.shared.showAlert(Alert(title: Text("Decryption error"), message: message)) + .alert(item: $alert) { alertItem in + switch(alertItem) { + case let .syncAllowedAlert(syncConnection): return syncAllowedAlert(syncConnection) + case .syncNotSupportedContactAlert: return Alert(title: Text("Fix not supported by contact"), message: message()) + case .syncNotSupportedMemberAlert: return Alert(title: Text("Fix not supported by group member"), message: message()) + case .decryptionErrorAlert: return Alert(title: Text("Decryption error"), message: message()) + case let .error(title, error): return Alert(title: Text(title), message: Text(error)) + } + } + } + + @ViewBuilder private func viewBody() -> some View { + if case let .direct(contact) = chat.chatInfo, + let contactStats = contact.activeConn.connectionStats { + if contactStats.ratchetSyncAllowed { + decryptionErrorItemFixButton(syncSupported: true) { + alert = .syncAllowedAlert { syncContactConnection(contact) } + } + } else if !contactStats.ratchetSyncSupported { + decryptionErrorItemFixButton(syncSupported: false) { + alert = .syncNotSupportedContactAlert + } + } else { + basicDecryptionErrorItem() + } + } else if case let .group(groupInfo) = chat.chatInfo, + case let .groupRcv(groupMember) = chatItem.chatDir, + let modelMember = ChatModel.shared.groupMembers.first(where: { $0.id == groupMember.id }), + let memberStats = modelMember.activeConn?.connectionStats { + if memberStats.ratchetSyncAllowed { + decryptionErrorItemFixButton(syncSupported: true) { + alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) } + } + } else if !memberStats.ratchetSyncSupported { + decryptionErrorItemFixButton(syncSupported: false) { + alert = .syncNotSupportedMemberAlert + } + } else { + basicDecryptionErrorItem() + } + } else { + basicDecryptionErrorItem() } } + + private func basicDecryptionErrorItem() -> some View { + decryptionErrorItem { alert = .decryptionErrorAlert } + } + + private func decryptionErrorItemFixButton(syncSupported: Bool, _ onClick: @escaping (() -> Void)) -> some View { + ZStack(alignment: .bottomTrailing) { + VStack(alignment: .leading, spacing: 2) { + HStack { + if showMember, let member = chatItem.memberDisplayName { + Text(member).fontWeight(.medium) + Text(": ") + } + Text(chatItem.content.text) + .foregroundColor(.red) + .italic() + } + ( + Text(Image(systemName: "exclamationmark.arrow.triangle.2.circlepath")) + .foregroundColor(syncSupported ? .accentColor : .secondary) + .font(.callout) + + Text(" ") + + Text("Fix connection") + .foregroundColor(syncSupported ? .accentColor : .secondary) + .font(.callout) + + Text(" ") + + ciMetaText(chatItem.meta, chatTTL: nil, transparent: true) + ) + } + .padding(.horizontal, 12) + CIMetaView(chatItem: chatItem) + .padding(.horizontal, 12) + } + .onTapGesture(perform: { onClick() }) + .padding(.vertical, 6) + .background(Color(uiColor: .tertiarySystemGroupedBackground)) + .cornerRadius(18) + .textSelection(.disabled) + } + + private func decryptionErrorItem(_ onClick: @escaping (() -> Void)) -> some View { + func text() -> Text { + Text(chatItem.content.text) + .foregroundColor(.red) + .italic() + + Text(" ") + + ciMetaText(chatItem.meta, chatTTL: nil, transparent: true) + } + return ZStack(alignment: .bottomTrailing) { + HStack { + if showMember, let member = chatItem.memberDisplayName { + Text(member).fontWeight(.medium) + Text(": ") + text() + } else { + text() + } + } + .padding(.horizontal, 12) + CIMetaView(chatItem: chatItem) + .padding(.horizontal, 12) + } + .onTapGesture(perform: { onClick() }) + .padding(.vertical, 6) + .background(Color(uiColor: .tertiarySystemGroupedBackground)) + .cornerRadius(18) + .textSelection(.disabled) + } + + private func message() -> Text { + var message: Text + let why = Text(decryptErrorReason) + switch msgDecryptError { + case .ratchetHeader: + message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why + case .tooManySkipped: + message = Text("\(msgCount) messages skipped.") + Text("\n") + why + case .ratchetEarlier: + message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why + case .other: + message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why + } + return message + } + + private func syncMemberConnection(_ groupInfo: GroupInfo, _ member: GroupMember) { + Task { + do { + let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, false) + await MainActor.run { + ChatModel.shared.updateGroupMemberConnectionStats(groupInfo, mem, stats) + } + } catch let error { + logger.error("syncMemberConnection apiSyncGroupMemberRatchet error: \(responseError(error))") + let a = getErrorAlert(error, "Error synchronizing connection") + await MainActor.run { + alert = .error(title: a.title, error: a.message) + } + } + } + } + + private func syncContactConnection(_ contact: Contact) { + Task { + do { + let stats = try apiSyncContactRatchet(contact.apiId, false) + await MainActor.run { + ChatModel.shared.updateContactConnectionStats(contact, stats) + } + } catch let error { + logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))") + let a = getErrorAlert(error, "Error synchronizing connection") + await MainActor.run { + alert = .error(title: a.title, error: a.message) + } + } + } + } + + private func syncAllowedAlert(_ syncConnection: @escaping () -> Void) -> Alert { + Alert( + title: Text("Fix connection?"), + message: message(), + primaryButton: .default(Text("Fix"), action: syncConnection), + secondaryButton: .cancel() + ) + } } //struct CIRcvDecryptionError_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 6e05e645ed..a8416af27b 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -22,7 +22,7 @@ struct ChatView: View { @State private var showAddMembersSheet: Bool = false @State private var composeState = ComposeState() @State private var deletingItem: ChatItem? = nil - @FocusState private var keyboardVisible: Bool + @State private var keyboardVisible = false @State private var showDeleteMessage = false @State private var connectionStats: ConnectionStats? @State private var customUserProfile: Profile? @@ -39,6 +39,16 @@ struct ChatView: View { @State private var selectedMember: GroupMember? = nil var body: some View { + if #available(iOS 16.0, *) { + viewBody + .scrollDismissesKeyboard(.immediately) + .keyboardPadding() + } else { + viewBody + } + } + + private var viewBody: some View { let cInfo = chat.chatInfo return VStack(spacing: 0) { if searchMode { @@ -65,17 +75,14 @@ struct ChatView: View { .navigationTitle(cInfo.chatViewName) .navigationBarTitleDisplayMode(.inline) .onAppear { - if chatModel.draftChatId == cInfo.id, let draft = chatModel.draft { - composeState = draft - } - if chat.chatStats.unreadChat { - Task { - await markChatUnread(chat, unreadChat: false) - } - } + initChatView() } - .onChange(of: chatModel.chatId) { _ in - if chatModel.chatId == nil { dismiss() } + .onChange(of: chatModel.chatId) { cId in + if cId != nil { + initChatView() + } else { + dismiss() + } } .onDisappear { VideoPlayerView.players.removeAll() @@ -185,6 +192,32 @@ struct ChatView: View { } } + private func initChatView() { + let cInfo = chat.chatInfo + if case let .direct(contact) = cInfo { + Task { + do { + let (stats, _) = try await apiContactInfo(chat.chatInfo.apiId) + await MainActor.run { + if let s = stats { + chatModel.updateContactConnectionStats(contact, s) + } + } + } catch let error { + logger.error("apiContactInfo error: \(responseError(error))") + } + } + } + if chatModel.draftChatId == cInfo.id, let draft = chatModel.draft { + composeState = draft + } + if chat.chatStats.unreadChat { + Task { + await markChatUnread(chat, unreadChat: false) + } + } + } + private func searchToolbar() -> some View { HStack { HStack { @@ -616,7 +649,7 @@ struct ChatView: View { private func reactionUIMenuPreiOS16(_ rs: [UIAction]) -> UIMenu { UIMenu( - title: NSLocalizedString("React...", comment: "chat item menu"), + title: NSLocalizedString("React…", comment: "chat item menu"), image: UIImage(systemName: "face.smiling"), children: rs ) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 7f58d90b87..cb49eced17 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -234,7 +234,7 @@ struct ComposeView: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var chat: Chat @Binding var composeState: ComposeState - @FocusState.Binding var keyboardVisible: Bool + @Binding var keyboardVisible: Bool @State var linkUrl: URL? = nil @State var prevLinkUrl: URL? = nil @@ -943,19 +943,18 @@ struct ComposeView_Previews: PreviewProvider { static var previews: some View { let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) @State var composeState = ComposeState(message: "hello") - @FocusState var keyboardVisible: Bool return Group { ComposeView( chat: chat, composeState: $composeState, - keyboardVisible: $keyboardVisible + keyboardVisible: Binding.constant(true) ) .environmentObject(ChatModel()) ComposeView( chat: chat, composeState: $composeState, - keyboardVisible: $keyboardVisible + keyboardVisible: Binding.constant(true) ) .environmentObject(ChatModel()) } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift index 711ae826c6..51deced72c 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift @@ -16,7 +16,7 @@ struct NativeTextEditor: UIViewRepresentable { @Binding var disableEditing: Bool let height: CGFloat let font: UIFont - @FocusState.Binding var focused: Bool + @Binding var focused: Bool let alignment: TextAlignment let onImagesAdded: ([UploadContent]) -> Void @@ -144,13 +144,12 @@ private class CustomUITextField: UITextView, UITextViewDelegate { struct NativeTextEditor_Previews: PreviewProvider{ static var previews: some View { - @FocusState var keyboardVisible: Bool return NativeTextEditor( text: Binding.constant("Hello, world!"), disableEditing: Binding.constant(false), height: 100, font: UIFont.preferredFont(forTextStyle: .body), - focused: $keyboardVisible, + focused: Binding.constant(false), alignment: TextAlignment.leading, onImagesAdded: { _ in } ) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index fb31eec456..f90784234c 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -27,7 +27,7 @@ struct SendMessageView: View { var onMediaAdded: ([UploadContent]) -> Void @State private var holdingVMR = false @Namespace var namespace - @FocusState.Binding var keyboardVisible: Bool + @Binding var keyboardVisible: Bool @State private var teHeight: CGFloat = 42 @State private var teFont: Font = .body @State private var teUiFont: UIFont = UIFont.preferredFont(forTextStyle: .body) @@ -401,7 +401,6 @@ struct SendMessageView_Previews: PreviewProvider { @State var composeStateNew = ComposeState() let ci = ChatItem.getSample(1, .directSnd, .now, "hello") @State var composeStateEditing = ComposeState(editingItem: ci) - @FocusState var keyboardVisible: Bool @State var sendEnabled: Bool = true return Group { @@ -412,7 +411,7 @@ struct SendMessageView_Previews: PreviewProvider { composeState: $composeStateNew, sendMessage: { _ in }, onMediaAdded: { _ in }, - keyboardVisible: $keyboardVisible + keyboardVisible: Binding.constant(true) ) } VStack { @@ -422,7 +421,7 @@ struct SendMessageView_Previews: PreviewProvider { composeState: $composeStateEditing, sendMessage: { _ in }, onMediaAdded: { _ in }, - keyboardVisible: $keyboardVisible + keyboardVisible: Binding.constant(true) ) } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index a9e7e8fed9..c6aaf1c8ba 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -126,6 +126,7 @@ struct GroupChatInfoView: View { logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))") } } + .keyboardPadding() } private func groupInfoHeader() -> some View { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 1ff145b08c..b059d195f2 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -27,6 +27,7 @@ struct GroupMemberInfoView: View { case changeMemberRoleAlert(mem: GroupMember, role: GroupMemberRole) case switchAddressAlert case abortSwitchAddressAlert + case syncConnectionForceAlert case connRequestSentAlert(type: ConnReqType) case error(title: LocalizedStringKey, error: LocalizedStringKey) case other(alert: Alert) @@ -38,6 +39,7 @@ struct GroupMemberInfoView: View { case .switchAddressAlert: return "switchAddressAlert" case .abortSwitchAddressAlert: return "abortSwitchAddressAlert" case .connRequestSentAlert: return "connRequestSentAlert" + case .syncConnectionForceAlert: return "syncConnectionForceAlert" case let .error(title, _): return "error \(title)" case let .other(alert): return "other \(alert)" } @@ -77,6 +79,12 @@ struct GroupMemberInfoView: View { } } if let code = connectionCode { verifyCodeButton(code) } + if let connStats = connectionStats, + connStats.ratchetSyncAllowed { + synchronizeConnectionButton() + } else if developerTools { + synchronizeConnectionButtonForce() + } } } @@ -129,12 +137,18 @@ struct GroupMemberInfoView: View { Button("Change receiving address") { alert = .switchAddressAlert } - .disabled(connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }) + .disabled( + connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } + || connStats.ratchetSyncSendProhibited + ) if connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } { Button("Abort changing address") { alert = .abortSwitchAddressAlert } - .disabled(connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }) + .disabled( + connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch } + || connStats.ratchetSyncSendProhibited + ) } smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }) smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }) @@ -162,7 +176,7 @@ struct GroupMemberInfoView: View { } newRole = member.memberRole do { - let stats = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId) + let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId) let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil) member = mem connectionStats = stats @@ -185,6 +199,7 @@ struct GroupMemberInfoView: View { case let .changeMemberRoleAlert(mem, _): return changeMemberRoleAlert(mem) case .switchAddressAlert: return switchAddressAlert(switchMemberAddress) case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress) + case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) }) case let .connRequestSentAlert(type): return connReqSentAlert(type) case let .error(title, error): return Alert(title: Text(title), message: Text(error)) case let .other(alert): return alert @@ -291,7 +306,24 @@ struct GroupMemberInfoView: View { systemImage: member.verified ? "checkmark.shield" : "shield" ) } + } + private func synchronizeConnectionButton() -> some View { + Button { + syncMemberConnection(force: false) + } label: { + Label("Fix connection", systemImage: "exclamationmark.arrow.triangle.2.circlepath") + .foregroundColor(.orange) + } + } + + private func synchronizeConnectionButtonForce() -> some View { + Button { + alert = .syncConnectionForceAlert + } label: { + Label("Renegotiate encryption", systemImage: "exclamationmark.triangle") + .foregroundColor(.red) + } } private func removeMemberButton(_ mem: GroupMember) -> some View { @@ -357,7 +389,11 @@ struct GroupMemberInfoView: View { Task { do { let stats = try apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId) - connectionStats = stats + connectionStats = stats + await MainActor.run { + chatModel.updateGroupMemberConnectionStats(groupInfo, member, stats) + dismiss() + } } catch let error { logger.error("switchMemberAddress apiSwitchGroupMember error: \(responseError(error))") let a = getErrorAlert(error, "Error changing address") @@ -373,6 +409,9 @@ struct GroupMemberInfoView: View { do { let stats = try apiAbortSwitchGroupMember(groupInfo.apiId, member.groupMemberId) connectionStats = stats + await MainActor.run { + chatModel.updateGroupMemberConnectionStats(groupInfo, member, stats) + } } catch let error { logger.error("abortSwitchMemberAddress apiAbortSwitchGroupMember error: \(responseError(error))") let a = getErrorAlert(error, "Error aborting address change") @@ -382,6 +421,25 @@ struct GroupMemberInfoView: View { } } } + + private func syncMemberConnection(force: Bool) { + Task { + do { + let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, force) + connectionStats = stats + await MainActor.run { + chatModel.updateGroupMemberConnectionStats(groupInfo, mem, stats) + dismiss() + } + } catch let error { + logger.error("syncMemberConnection apiSyncGroupMemberRatchet error: \(responseError(error))") + let a = getErrorAlert(error, "Error synchronizing connection") + await MainActor.run { + alert = .error(title: a.title, error: a.message) + } + } + } + } } struct GroupMemberInfoView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 9e85f0d467..03dd241087 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -18,6 +18,14 @@ struct ChatListView: View { @AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false var body: some View { + if #available(iOS 16.0, *) { + viewBody.scrollDismissesKeyboard(.immediately) + } else { + viewBody + } + } + + private var viewBody: some View { ZStack(alignment: .topLeading) { NavStackCompat( isActive: Binding( @@ -76,7 +84,19 @@ struct ChatListView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { - Button { + let user = chatModel.currentUser ?? User.sampleData + ZStack(alignment: .topTrailing) { + ProfileImage(imageStr: user.image, color: Color(uiColor: .quaternaryLabel)) + .frame(width: 32, height: 32) + .padding(.trailing, 4) + let allRead = chatModel.users + .filter { u in !u.user.activeUser && !u.user.hidden } + .allSatisfy { u in u.unreadCount == 0 } + if !allRead { + unreadBadge(size: 12) + } + } + .onTapGesture { if chatModel.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 { withAnimation { userPickerVisible.toggle() @@ -84,19 +104,6 @@ struct ChatListView: View { } else { showSettings = true } - } label: { - let user = chatModel.currentUser ?? User.sampleData - ZStack(alignment: .topTrailing) { - ProfileImage(imageStr: user.image, color: Color(uiColor: .quaternaryLabel)) - .frame(width: 32, height: 32) - .padding(.trailing, 4) - let allRead = chatModel.users - .filter { u in !u.user.activeUser && !u.user.hidden } - .allSatisfy { u in u.unreadCount == 0 } - if !allRead { - unreadBadge(size: 12) - } - } } } ToolbarItem(placement: .principal) { diff --git a/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift b/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift index 17b8faacb0..046929a9d0 100644 --- a/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift +++ b/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift @@ -65,7 +65,7 @@ struct MigrateToAppGroupView: View { case .exporting: center { ProgressView(value: 0.33) - Text("Exporting database archive...") + Text("Exporting database archive…") } migrationProgress() case .export_error: @@ -82,7 +82,7 @@ struct MigrateToAppGroupView: View { case .migrating: center { ProgressView(value: 0.67) - Text("Migrating database archive...") + Text("Migrating database archive…") } migrationProgress() case .migration_error: diff --git a/apps/ios/Shared/Views/Helpers/Keyboard.swift b/apps/ios/Shared/Views/Helpers/Keyboard.swift new file mode 100644 index 0000000000..76f47089ea --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/Keyboard.swift @@ -0,0 +1,9 @@ +// +// Keyboard.swift +// SimpleX (iOS) +// +// Created by Evgeny on 10/07/2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +import Foundation diff --git a/apps/ios/Shared/Views/Helpers/KeyboardPadding.swift b/apps/ios/Shared/Views/Helpers/KeyboardPadding.swift new file mode 100644 index 0000000000..45d766ddfd --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/KeyboardPadding.swift @@ -0,0 +1,21 @@ +// +// KeyboardPadding.swift +// SimpleX (iOS) +// +// Created by Evgeny on 10/07/2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +extension View { + @ViewBuilder func keyboardPadding() -> some View { + if #available(iOS 17.0, *) { + GeometryReader { g in + self.padding(.bottom, max(0, ChatModel.shared.keyboardHeight - g.safeAreaInsets.bottom)) + } + } else { + self + } + } +} diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 5b6c9b5dc3..247b91a04a 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -36,7 +36,7 @@ struct AddGroupView: View { } } } else { - createGroupView() + createGroupView().keyboardPadding() } } diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift index 0066022098..d05ac44588 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -104,6 +104,7 @@ struct CreateProfile: View { } } .padding() + .keyboardPadding() } func textField(_ placeholder: LocalizedStringKey, text: Binding) -> some View { diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index f2c20a2ca8..be6ccfd3df 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -18,7 +18,7 @@ struct TerminalView: View { @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @State var composeState: ComposeState = ComposeState() - @FocusState private var keyboardVisible: Bool + @State private var keyboardVisible = false @State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) @State private var terminalItem: TerminalItem? @State private var scrolled = false diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 32f9613688..19ad6687c6 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -115,11 +115,6 @@ 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */; }; 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */; }; 5CC868F329EB540C0017BBFD /* CIRcvDecryptionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */; }; - 5CCAA6DF2A53713A00BAF93B /* libHSsimplex-chat-5.2.0.0-H74s0RJkRXv7ArDExYHa6i-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCAA6DA2A53713A00BAF93B /* libHSsimplex-chat-5.2.0.0-H74s0RJkRXv7ArDExYHa6i-ghc8.10.7.a */; }; - 5CCAA6E02A53713A00BAF93B /* libHSsimplex-chat-5.2.0.0-H74s0RJkRXv7ArDExYHa6i.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCAA6DB2A53713A00BAF93B /* libHSsimplex-chat-5.2.0.0-H74s0RJkRXv7ArDExYHa6i.a */; }; - 5CCAA6E12A53713A00BAF93B /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCAA6DC2A53713A00BAF93B /* libgmpxx.a */; }; - 5CCAA6E22A53713A00BAF93B /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCAA6DD2A53713A00BAF93B /* libgmp.a */; }; - 5CCAA6E32A53713A00BAF93B /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCAA6DE2A53713A00BAF93B /* libffi.a */; }; 5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; }; 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */; }; @@ -146,6 +141,7 @@ 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; }; 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; }; 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; }; + 5CEBD7462A5C0A8F00665FE2 /* KeyboardPadding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */; }; 5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */; }; 5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */; }; 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; @@ -163,6 +159,11 @@ 644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */; }; 644EFFE2292D089800525D5B /* FramedCIVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */; }; 644EFFE42937BE9700525D5B /* MarkedDeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFE32937BE9700525D5B /* MarkedDeletedItemView.swift */; }; + 645041592A5C5749000221AD /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 645041542A5C5748000221AD /* libffi.a */; }; + 6450415A2A5C5749000221AD /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 645041552A5C5748000221AD /* libgmp.a */; }; + 6450415B2A5C5749000221AD /* libHSsimplex-chat-5.2.0.1-EEhQOsrCplxKU03XLccWe7-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 645041562A5C5748000221AD /* libHSsimplex-chat-5.2.0.1-EEhQOsrCplxKU03XLccWe7-ghc8.10.7.a */; }; + 6450415C2A5C5749000221AD /* libHSsimplex-chat-5.2.0.1-EEhQOsrCplxKU03XLccWe7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 645041572A5C5748000221AD /* libHSsimplex-chat-5.2.0.1-EEhQOsrCplxKU03XLccWe7.a */; }; + 6450415D2A5C5749000221AD /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 645041582A5C5748000221AD /* libgmpxx.a */; }; 6454036F2822A9750090DDFF /* ComposeFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6454036E2822A9750090DDFF /* ComposeFileView.swift */; }; 646BB38C283BEEB9001CE359 /* LocalAuthentication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */; }; 646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */; }; @@ -392,11 +393,6 @@ 5CC2C0FB2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 5CC2C0FE2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = "ru.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIRcvDecryptionError.swift; sourceTree = ""; }; - 5CCAA6DA2A53713A00BAF93B /* libHSsimplex-chat-5.2.0.0-H74s0RJkRXv7ArDExYHa6i-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.2.0.0-H74s0RJkRXv7ArDExYHa6i-ghc8.10.7.a"; sourceTree = ""; }; - 5CCAA6DB2A53713A00BAF93B /* libHSsimplex-chat-5.2.0.0-H74s0RJkRXv7ArDExYHa6i.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.2.0.0-H74s0RJkRXv7ArDExYHa6i.a"; sourceTree = ""; }; - 5CCAA6DC2A53713A00BAF93B /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5CCAA6DD2A53713A00BAF93B /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CCAA6DE2A53713A00BAF93B /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = ""; }; 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = ""; }; 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToConnectView.swift; sourceTree = ""; }; @@ -422,6 +418,7 @@ 5CE4407827ADB701007B033A /* EmojiItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiItemView.swift; sourceTree = ""; }; 5CEACCE227DE9246000BD591 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = ""; }; + 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardPadding.swift; sourceTree = ""; }; 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToAppGroupView.swift; sourceTree = ""; }; 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveView.swift; sourceTree = ""; }; 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; }; @@ -438,6 +435,11 @@ 644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIVoiceView.swift; sourceTree = ""; }; 644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FramedCIVoiceView.swift; sourceTree = ""; }; 644EFFE32937BE9700525D5B /* MarkedDeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkedDeletedItemView.swift; sourceTree = ""; }; + 645041542A5C5748000221AD /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 645041552A5C5748000221AD /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 645041562A5C5748000221AD /* libHSsimplex-chat-5.2.0.1-EEhQOsrCplxKU03XLccWe7-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.2.0.1-EEhQOsrCplxKU03XLccWe7-ghc8.10.7.a"; sourceTree = ""; }; + 645041572A5C5748000221AD /* libHSsimplex-chat-5.2.0.1-EEhQOsrCplxKU03XLccWe7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.2.0.1-EEhQOsrCplxKU03XLccWe7.a"; sourceTree = ""; }; + 645041582A5C5748000221AD /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 6454036E2822A9750090DDFF /* ComposeFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeFileView.swift; sourceTree = ""; }; 646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = LocalAuthentication.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.4.sdk/System/Library/Frameworks/LocalAuthentication.framework; sourceTree = DEVELOPER_DIR; }; 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthenticationUtils.swift; sourceTree = ""; }; @@ -498,12 +500,12 @@ buildActionMask = 2147483647; files = ( 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5CCAA6E12A53713A00BAF93B /* libgmpxx.a in Frameworks */, - 5CCAA6DF2A53713A00BAF93B /* libHSsimplex-chat-5.2.0.0-H74s0RJkRXv7ArDExYHa6i-ghc8.10.7.a in Frameworks */, - 5CCAA6E02A53713A00BAF93B /* libHSsimplex-chat-5.2.0.0-H74s0RJkRXv7ArDExYHa6i.a in Frameworks */, - 5CCAA6E32A53713A00BAF93B /* libffi.a in Frameworks */, - 5CCAA6E22A53713A00BAF93B /* libgmp.a in Frameworks */, + 645041592A5C5749000221AD /* libffi.a in Frameworks */, + 6450415B2A5C5749000221AD /* libHSsimplex-chat-5.2.0.1-EEhQOsrCplxKU03XLccWe7-ghc8.10.7.a in Frameworks */, + 6450415A2A5C5749000221AD /* libgmp.a in Frameworks */, + 6450415C2A5C5749000221AD /* libHSsimplex-chat-5.2.0.1-EEhQOsrCplxKU03XLccWe7.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, + 6450415D2A5C5749000221AD /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -564,11 +566,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CCAA6DE2A53713A00BAF93B /* libffi.a */, - 5CCAA6DD2A53713A00BAF93B /* libgmp.a */, - 5CCAA6DC2A53713A00BAF93B /* libgmpxx.a */, - 5CCAA6DA2A53713A00BAF93B /* libHSsimplex-chat-5.2.0.0-H74s0RJkRXv7ArDExYHa6i-ghc8.10.7.a */, - 5CCAA6DB2A53713A00BAF93B /* libHSsimplex-chat-5.2.0.0-H74s0RJkRXv7ArDExYHa6i.a */, + 645041542A5C5748000221AD /* libffi.a */, + 645041552A5C5748000221AD /* libgmp.a */, + 645041582A5C5748000221AD /* libgmpxx.a */, + 645041562A5C5748000221AD /* libHSsimplex-chat-5.2.0.1-EEhQOsrCplxKU03XLccWe7-ghc8.10.7.a */, + 645041572A5C5748000221AD /* libHSsimplex-chat-5.2.0.1-EEhQOsrCplxKU03XLccWe7.a */, ); path = Libraries; sourceTree = ""; @@ -619,6 +621,7 @@ 18415DAAAD1ADBEDB0EDA852 /* VideoPlayerView.swift */, 64466DCB29FFE3E800E3D48D /* MailView.swift */, 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */, + 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */, ); path = Helpers; sourceTree = ""; @@ -1142,6 +1145,7 @@ 647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */, 646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */, 5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */, + 5CEBD7462A5C0A8F00665FE2 /* KeyboardPadding.swift in Sources */, 5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */, 5CB634B129E5EFEA0066AD6B /* PasscodeView.swift in Sources */, 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */, @@ -1470,7 +1474,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 151; + CURRENT_PROJECT_VERSION = 153; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1512,7 +1516,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 151; + CURRENT_PROJECT_VERSION = 153; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1592,7 +1596,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 151; + CURRENT_PROJECT_VERSION = 153; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1624,7 +1628,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 151; + CURRENT_PROJECT_VERSION = 153; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index a6b9b1a2ec..e11e620b11 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -74,6 +74,8 @@ public enum ChatCommand { case apiSwitchGroupMember(groupId: Int64, groupMemberId: Int64) case apiAbortSwitchContact(contactId: Int64) case apiAbortSwitchGroupMember(groupId: Int64, groupMemberId: Int64) + case apiSyncContactRatchet(contactId: Int64, force: Bool) + case apiSyncGroupMemberRatchet(groupId: Int64, groupMemberId: Int64, force: Bool) case apiGetContactCode(contactId: Int64) case apiGetGroupMemberCode(groupId: Int64, groupMemberId: Int64) case apiVerifyContact(contactId: Int64, connectionCode: String?) @@ -185,6 +187,16 @@ public enum ChatCommand { case let .apiSwitchGroupMember(groupId, groupMemberId): return "/_switch #\(groupId) \(groupMemberId)" case let .apiAbortSwitchContact(contactId): return "/_abort switch @\(contactId)" case let .apiAbortSwitchGroupMember(groupId, groupMemberId): return "/_abort switch #\(groupId) \(groupMemberId)" + case let .apiSyncContactRatchet(contactId, force): if force { + return "/_sync @\(contactId) force=on" + } else { + return "/_sync @\(contactId)" + } + case let .apiSyncGroupMemberRatchet(groupId, groupMemberId, force): if force { + return "/_sync #\(groupId) \(groupMemberId) force=on" + } else { + return "/_sync #\(groupId) \(groupMemberId)" + } case let .apiGetContactCode(contactId): return "/_get code @\(contactId)" case let .apiGetGroupMemberCode(groupId, groupMemberId): return "/_get code #\(groupId) \(groupMemberId)" case let .apiVerifyContact(contactId, .some(connectionCode)): return "/_verify code @\(contactId) \(connectionCode)" @@ -294,6 +306,8 @@ public enum ChatCommand { case .apiSwitchGroupMember: return "apiSwitchGroupMember" case .apiAbortSwitchContact: return "apiAbortSwitchContact" case .apiAbortSwitchGroupMember: return "apiAbortSwitchGroupMember" + case .apiSyncContactRatchet: return "apiSyncContactRatchet" + case .apiSyncGroupMemberRatchet: return "apiSyncGroupMemberRatchet" case .apiGetContactCode: return "apiGetContactCode" case .apiGetGroupMemberCode: return "apiGetGroupMemberCode" case .apiVerifyContact: return "apiVerifyContact" @@ -410,6 +424,14 @@ public enum ChatResponse: Decodable, Error { case groupMemberSwitchStarted(user: User, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) case contactSwitchAborted(user: User, contact: Contact, connectionStats: ConnectionStats) case groupMemberSwitchAborted(user: User, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) + case contactSwitch(user: User, contact: Contact, switchProgress: SwitchProgress) + case groupMemberSwitch(user: User, groupInfo: GroupInfo, member: GroupMember, switchProgress: SwitchProgress) + case contactRatchetSyncStarted(user: User, contact: Contact, connectionStats: ConnectionStats) + case groupMemberRatchetSyncStarted(user: User, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) + case contactRatchetSync(user: User, contact: Contact, ratchetSyncProgress: RatchetSyncProgress) + case groupMemberRatchetSync(user: User, groupInfo: GroupInfo, member: GroupMember, ratchetSyncProgress: RatchetSyncProgress) + case contactVerificationReset(user: User, contact: Contact) + case groupMemberVerificationReset(user: User, groupInfo: GroupInfo, member: GroupMember) case contactCode(user: User, contact: Contact, connectionCode: String) case groupMemberCode(user: User, groupInfo: GroupInfo, member: GroupMember, connectionCode: String) case connectionVerified(user: User, verified: Bool, expectedCode: String) @@ -533,6 +555,14 @@ public enum ChatResponse: Decodable, Error { case .groupMemberSwitchStarted: return "groupMemberSwitchStarted" case .contactSwitchAborted: return "contactSwitchAborted" case .groupMemberSwitchAborted: return "groupMemberSwitchAborted" + case .contactSwitch: return "contactSwitch" + case .groupMemberSwitch: return "groupMemberSwitch" + case .contactRatchetSyncStarted: return "contactRatchetSyncStarted" + case .groupMemberRatchetSyncStarted: return "groupMemberRatchetSyncStarted" + case .contactRatchetSync: return "contactRatchetSync" + case .groupMemberRatchetSync: return "groupMemberRatchetSync" + case .contactVerificationReset: return "contactVerificationReset" + case .groupMemberVerificationReset: return "groupMemberVerificationReset" case .contactCode: return "contactCode" case .groupMemberCode: return "groupMemberCode" case .connectionVerified: return "connectionVerified" @@ -655,6 +685,14 @@ public enum ChatResponse: Decodable, Error { case let .groupMemberSwitchStarted(u, groupInfo, member, connectionStats): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats: \(String(describing: connectionStats))") case let .contactSwitchAborted(u, contact, connectionStats): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))") case let .groupMemberSwitchAborted(u, groupInfo, member, connectionStats): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats: \(String(describing: connectionStats))") + case let .contactSwitch(u, contact, switchProgress): return withUser(u, "contact: \(String(describing: contact))\nswitchProgress: \(String(describing: switchProgress))") + case let .groupMemberSwitch(u, groupInfo, member, switchProgress): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nswitchProgress: \(String(describing: switchProgress))") + case let .contactRatchetSyncStarted(u, contact, connectionStats): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))") + case let .groupMemberRatchetSyncStarted(u, groupInfo, member, connectionStats): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats: \(String(describing: connectionStats))") + case let .contactRatchetSync(u, contact, ratchetSyncProgress): return withUser(u, "contact: \(String(describing: contact))\nratchetSyncProgress: \(String(describing: ratchetSyncProgress))") + case let .groupMemberRatchetSync(u, groupInfo, member, ratchetSyncProgress): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nratchetSyncProgress: \(String(describing: ratchetSyncProgress))") + case let .contactVerificationReset(u, contact): return withUser(u, "contact: \(String(describing: contact))") + case let .groupMemberVerificationReset(u, groupInfo, member): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))") case let .contactCode(u, contact, connectionCode): return withUser(u, "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)") case let .groupMemberCode(u, groupInfo, member, connectionCode): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)") case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)") @@ -1106,9 +1144,20 @@ public struct ChatSettings: Codable { public static let defaults: ChatSettings = ChatSettings(enableNtfs: true, favorite: false) } -public struct ConnectionStats: Codable { +public struct ConnectionStats: Decodable { + public var connAgentVersion: Int public var rcvQueuesInfo: [RcvQueueInfo] public var sndQueuesInfo: [SndQueueInfo] + public var ratchetSyncState: RatchetSyncState + public var ratchetSyncSupported: Bool + + public var ratchetSyncAllowed: Bool { + ratchetSyncSupported && [.allowed, .required].contains(ratchetSyncState) + } + + public var ratchetSyncSendProhibited: Bool { + [.required, .started, .agreed].contains(ratchetSyncState) + } } public struct RcvQueueInfo: Codable { @@ -1134,6 +1183,30 @@ public enum SndSwitchStatus: String, Codable { case sendingQTEST = "sending_qtest" } +public enum QueueDirection: String, Decodable { + case rcv + case snd +} + +public struct SwitchProgress: Decodable { + public var queueDirection: QueueDirection + public var switchPhase: SwitchPhase + public var connectionStats: ConnectionStats +} + +public struct RatchetSyncProgress: Decodable { + public var ratchetSyncStatus: RatchetSyncState + public var connectionStats: ConnectionStats +} + +public enum RatchetSyncState: String, Decodable { + case ok + case allowed + case required + case started + case agreed +} + public struct UserContactLink: Decodable { public var connReqContact: String public var autoAccept: AutoAccept? diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 7b0f87ea1a..cc1e328ef3 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1353,7 +1353,7 @@ public struct Contact: Identifiable, Decodable, NamedChat { public var id: ChatId { get { "@\(contactId)" } } public var apiId: Int64 { get { contactId } } public var ready: Bool { get { activeConn.connStatus == .ready } } - public var sendMsgEnabled: Bool { get { true } } + public var sendMsgEnabled: Bool { get { !(activeConn.connectionStats?.ratchetSyncSendProhibited ?? false) } } public var displayName: String { localAlias == "" ? profile.displayName : localAlias } public var fullName: String { get { profile.fullName } } public var image: String? { get { profile.image } } @@ -1426,6 +1426,12 @@ public struct Connection: Decodable { public var customUserProfileId: Int64? public var connectionCode: SecurityCode? + public var connectionStats: ConnectionStats? = nil + + private enum CodingKeys: String, CodingKey { + case connId, agentConnId, connStatus, connLevel, viaGroupLink, customUserProfileId, connectionCode + } + public var id: ChatId { get { ":\(connId)" } } static let sampleData = Connection( @@ -2456,11 +2462,15 @@ public enum CIContent: Decodable, ItemContent { public enum MsgDecryptError: String, Decodable { case ratchetHeader case tooManySkipped + case ratchetEarlier + case other var text: String { switch self { case .ratchetHeader: return NSLocalizedString("Permanent decryption error", comment: "message decrypt error item") case .tooManySkipped: return NSLocalizedString("Permanent decryption error", comment: "message decrypt error item") + case .ratchetEarlier: return NSLocalizedString("Decryption error", comment: "message decrypt error item") + case .other: return NSLocalizedString("Decryption error", comment: "message decrypt error item") } } } @@ -3057,6 +3067,8 @@ public enum SndGroupEvent: Decodable { public enum RcvConnEvent: Decodable { case switchQueue(phase: SwitchPhase) + case ratchetSync(syncStatus: RatchetSyncState) + case verificationCodeReset var text: String { switch self { @@ -3064,25 +3076,51 @@ public enum RcvConnEvent: Decodable { if case .completed = phase { return NSLocalizedString("changed address for you", comment: "chat item text") } - return NSLocalizedString("changing address...", comment: "chat item text") + return NSLocalizedString("changing address…", comment: "chat item text") + case let .ratchetSync(syncStatus): + return ratchetSyncStatusToText(syncStatus) + case .verificationCodeReset: + return NSLocalizedString("security code changed", comment: "chat item text") } } } +func ratchetSyncStatusToText(_ ratchetSyncStatus: RatchetSyncState) -> String { + switch ratchetSyncStatus { + case .ok: return NSLocalizedString("encryption ok", comment: "chat item text") + case .allowed: return NSLocalizedString("encryption re-negotiation allowed", comment: "chat item text") + case .required: return NSLocalizedString("encryption re-negotiation required", comment: "chat item text") + case .started: return NSLocalizedString("agreeing encryption…", comment: "chat item text") + case .agreed: return NSLocalizedString("encryption agreed", comment: "chat item text") + } +} + public enum SndConnEvent: Decodable { case switchQueue(phase: SwitchPhase, member: GroupMemberRef?) + case ratchetSync(syncStatus: RatchetSyncState, member: GroupMemberRef?) var text: String { switch self { case let .switchQueue(phase, member): if let name = member?.profile.profileViewName { return phase == .completed - ? String.localizedStringWithFormat(NSLocalizedString("you changed address for %@", comment: "chat item text"), name) - : String.localizedStringWithFormat(NSLocalizedString("changing address for %@...", comment: "chat item text"), name) + ? String.localizedStringWithFormat(NSLocalizedString("you changed address for %@", comment: "chat item text"), name) + : String.localizedStringWithFormat(NSLocalizedString("changing address for %@…", comment: "chat item text"), name) } return phase == .completed - ? NSLocalizedString("you changed address", comment: "chat item text") - : NSLocalizedString("changing address...", comment: "chat item text") + ? NSLocalizedString("you changed address", comment: "chat item text") + : NSLocalizedString("changing address…", comment: "chat item text") + case let .ratchetSync(syncStatus, member): + if let name = member?.profile.profileViewName { + switch syncStatus { + case .ok: return String.localizedStringWithFormat(NSLocalizedString("encryption ok for %@", comment: "chat item text"), name) + case .allowed: return String.localizedStringWithFormat(NSLocalizedString("encryption re-negotiation allowed for %@", comment: "chat item text"), name) + case .required: return String.localizedStringWithFormat(NSLocalizedString("encryption re-negotiation required for %@", comment: "chat item text"), name) + case .started: return String.localizedStringWithFormat(NSLocalizedString("agreeing encryption for %@…", comment: "chat item text"), name) + case .agreed: return String.localizedStringWithFormat(NSLocalizedString("encryption agreed for %@", comment: "chat item text"), name) + } + } + return ratchetSyncStatusToText(syncStatus) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 00a1408747..bc4ef30749 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.TextDecoration +import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* import chat.simplex.common.views.chat.ComposeState @@ -136,13 +137,36 @@ object ChatModel { fun updateChatInfo(cInfo: ChatInfo) { val i = getChatIndex(cInfo.id) - if (i >= 0) chats[i] = chats[i].copy(chatInfo = cInfo) + if (i >= 0) { + val currentCInfo = chats[i].chatInfo + var newCInfo = cInfo + if (currentCInfo is ChatInfo.Direct && newCInfo is ChatInfo.Direct) { + val currentStats = currentCInfo.contact.activeConn.connectionStats + val newStats = newCInfo.contact.activeConn.connectionStats + if (currentStats != null && newStats == null) { + newCInfo = newCInfo.copy( + contact = newCInfo.contact.copy( + activeConn = newCInfo.contact.activeConn.copy( + connectionStats = currentStats + ) + ) + ) + } + } + chats[i] = chats[i].copy(chatInfo = newCInfo) + } } fun updateContactConnection(contactConnection: PendingContactConnection) = updateChat(ChatInfo.ContactConnection(contactConnection)) fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact), addMissing = contact.directOrUsed) + fun updateContactConnectionStats(contact: Contact, connectionStats: ConnectionStats) { + val updatedConn = contact.activeConn.copy(connectionStats = connectionStats) + val updatedContact = contact.copy(activeConn = updatedConn) + updateContact(updatedContact) + } + fun updateGroup(groupInfo: GroupInfo) = updateChat(ChatInfo.Group(groupInfo)) private fun updateChat(cInfo: ChatInfo, addMissing: Boolean = true) { @@ -441,6 +465,15 @@ object ChatModel { } } + fun updateGroupMemberConnectionStats(groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) { + val memberConn = member.activeConn + if (memberConn != null) { + val updatedConn = memberConn.copy(connectionStats = connectionStats) + val updatedMember = member.copy(activeConn = updatedConn) + upsertGroupMember(groupInfo, updatedMember) + } + } + fun setContactNetworkStatus(contact: Contact, status: NetworkStatus) { networkStatuses[contact.activeConn.agentConnId] = status } @@ -752,7 +785,7 @@ data class Contact( override val id get() = "@$contactId" override val apiId get() = contactId override val ready get() = activeConn.connStatus == ConnStatus.Ready - override val sendMsgEnabled get() = true + override val sendMsgEnabled get() = !(activeConn.connectionStats?.ratchetSyncSendProhibited ?: false) override val ntfsEnabled get() = chatSettings.enableNtfs override val incognito get() = contactConnIncognito override fun featureEnabled(feature: ChatFeature) = when (feature) { @@ -832,7 +865,8 @@ data class Connection( val connLevel: Int, val viaGroupLink: Boolean, val customUserProfileId: Long? = null, - val connectionCode: SecurityCode? = null + val connectionCode: SecurityCode? = null, + val connectionStats: ConnectionStats? = null ) { val id: ChatId get() = ":$connId" companion object { @@ -1773,11 +1807,15 @@ sealed class CIContent: ItemContent { @Serializable enum class MsgDecryptError { @SerialName("ratchetHeader") RatchetHeader, - @SerialName("tooManySkipped") TooManySkipped; + @SerialName("tooManySkipped") TooManySkipped, + @SerialName("ratchetEarlier") RatchetEarlier, + @SerialName("other") Other; val text: String get() = when (this) { RatchetHeader -> generalGetString(MR.strings.decryption_error) TooManySkipped -> generalGetString(MR.strings.decryption_error) + RatchetEarlier -> generalGetString(MR.strings.decryption_error) + Other -> generalGetString(MR.strings.decryption_error) } } @@ -2333,18 +2371,33 @@ sealed class SndGroupEvent() { @Serializable sealed class RcvConnEvent { @Serializable @SerialName("switchQueue") class SwitchQueue(val phase: SwitchPhase): RcvConnEvent() + @Serializable @SerialName("ratchetSync") class RatchetSync(val syncStatus: RatchetSyncState): RcvConnEvent() + @Serializable @SerialName("verificationCodeReset") object VerificationCodeReset: RcvConnEvent() val text: String get() = when (this) { is SwitchQueue -> when (phase) { SwitchPhase.Completed -> generalGetString(MR.strings.rcv_conn_event_switch_queue_phase_completed) else -> generalGetString(MR.strings.rcv_conn_event_switch_queue_phase_changing) } + is RatchetSync -> ratchetSyncStatusToText(syncStatus) + is VerificationCodeReset -> generalGetString(MR.strings.rcv_conn_event_verification_code_reset) + } +} + +fun ratchetSyncStatusToText(ratchetSyncStatus: RatchetSyncState): String { + return when (ratchetSyncStatus) { + RatchetSyncState.Ok -> generalGetString(MR.strings.conn_event_ratchet_sync_ok) + RatchetSyncState.Allowed -> generalGetString(MR.strings.conn_event_ratchet_sync_allowed) + RatchetSyncState.Required -> generalGetString(MR.strings.conn_event_ratchet_sync_required) + RatchetSyncState.Started -> generalGetString(MR.strings.conn_event_ratchet_sync_started) + RatchetSyncState.Agreed -> generalGetString(MR.strings.conn_event_ratchet_sync_agreed) } } @Serializable sealed class SndConnEvent { @Serializable @SerialName("switchQueue") class SwitchQueue(val phase: SwitchPhase, val member: GroupMemberRef? = null): SndConnEvent() + @Serializable @SerialName("ratchetSync") class RatchetSync(val syncStatus: RatchetSyncState, val member: GroupMemberRef? = null): SndConnEvent() val text: String get() = when (this) { @@ -2360,6 +2413,19 @@ sealed class SndConnEvent { else -> generalGetString(MR.strings.snd_conn_event_switch_queue_phase_changing) } } + + is RatchetSync -> { + member?.profile?.profileViewName?.let { + return when (syncStatus) { + RatchetSyncState.Ok -> String.format(generalGetString(MR.strings.snd_conn_event_ratchet_sync_ok), it) + RatchetSyncState.Allowed -> String.format(generalGetString(MR.strings.snd_conn_event_ratchet_sync_allowed), it) + RatchetSyncState.Required -> String.format(generalGetString(MR.strings.snd_conn_event_ratchet_sync_required), it) + RatchetSyncState.Started -> String.format(generalGetString(MR.strings.snd_conn_event_ratchet_sync_started), it) + RatchetSyncState.Agreed -> String.format(generalGetString(MR.strings.snd_conn_event_ratchet_sync_agreed), it) + } + } + ratchetSyncStatusToText(syncStatus) + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index de77db23ca..5585761561 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -722,9 +722,9 @@ object ChatController { return null } - suspend fun apiGroupMemberInfo(groupId: Long, groupMemberId: Long): ConnectionStats? { + suspend fun apiGroupMemberInfo(groupId: Long, groupMemberId: Long): Pair? { val r = sendCmd(CC.APIGroupMemberInfo(groupId, groupMemberId)) - if (r is CR.GroupMemberInfo) return r.connectionStats_ + if (r is CR.GroupMemberInfo) return Pair(r.member, r.connectionStats_) Log.e(TAG, "apiGroupMemberInfo bad response: ${r.responseType} ${r.details}") return null } @@ -736,9 +736,9 @@ object ChatController { return null } - suspend fun apiSwitchGroupMember(groupId: Long, groupMemberId: Long): ConnectionStats? { + suspend fun apiSwitchGroupMember(groupId: Long, groupMemberId: Long): Pair? { val r = sendCmd(CC.APISwitchGroupMember(groupId, groupMemberId)) - if (r is CR.GroupMemberSwitchStarted) return r.connectionStats + if (r is CR.GroupMemberSwitchStarted) return Pair(r.member, r.connectionStats) apiErrorAlert("apiSwitchGroupMember", generalGetString(MR.strings.error_changing_address), r) return null } @@ -750,13 +750,27 @@ object ChatController { return null } - suspend fun apiAbortSwitchGroupMember(groupId: Long, groupMemberId: Long): ConnectionStats? { + suspend fun apiAbortSwitchGroupMember(groupId: Long, groupMemberId: Long): Pair? { val r = sendCmd(CC.APIAbortSwitchGroupMember(groupId, groupMemberId)) - if (r is CR.GroupMemberSwitchAborted) return r.connectionStats + if (r is CR.GroupMemberSwitchAborted) return Pair(r.member, r.connectionStats) apiErrorAlert("apiAbortSwitchGroupMember", generalGetString(MR.strings.error_aborting_address_change), r) return null } + suspend fun apiSyncContactRatchet(contactId: Long, force: Boolean): ConnectionStats? { + val r = sendCmd(CC.APISyncContactRatchet(contactId, force)) + if (r is CR.ContactRatchetSyncStarted) return r.connectionStats + apiErrorAlert("apiSyncContactRatchet", generalGetString(MR.strings.error_synchronizing_connection), r) + return null + } + + suspend fun apiSyncGroupMemberRatchet(groupId: Long, groupMemberId: Long, force: Boolean): Pair? { + val r = sendCmd(CC.APISyncGroupMemberRatchet(groupId, groupMemberId, force)) + if (r is CR.GroupMemberRatchetSyncStarted) return Pair(r.member, r.connectionStats) + apiErrorAlert("apiSyncGroupMemberRatchet", generalGetString(MR.strings.error_synchronizing_connection), r) + return null + } + suspend fun apiGetContactCode(contactId: Long): Pair { val r = sendCmd(CC.APIGetContactCode(contactId)) if (r is CR.ContactCode) return r.contact to r.connectionCode @@ -1576,6 +1590,14 @@ object ChatController { } } } + is CR.ContactSwitch -> + chatModel.updateContactConnectionStats(r.contact, r.switchProgress.connectionStats) + is CR.GroupMemberSwitch -> + chatModel.updateGroupMemberConnectionStats(r.groupInfo, r.member, r.switchProgress.connectionStats) + is CR.ContactRatchetSync -> + chatModel.updateContactConnectionStats(r.contact, r.ratchetSyncProgress.connectionStats) + is CR.GroupMemberRatchetSync -> + chatModel.updateGroupMemberConnectionStats(r.groupInfo, r.member, r.ratchetSyncProgress.connectionStats) else -> Log.d(TAG , "unsupported event: ${r.responseType}") } @@ -1796,6 +1818,8 @@ sealed class CC { class APISwitchGroupMember(val groupId: Long, val groupMemberId: Long): CC() class APIAbortSwitchContact(val contactId: Long): CC() class APIAbortSwitchGroupMember(val groupId: Long, val groupMemberId: Long): CC() + class APISyncContactRatchet(val contactId: Long, val force: Boolean): CC() + class APISyncGroupMemberRatchet(val groupId: Long, val groupMemberId: Long, val force: Boolean): CC() class APIGetContactCode(val contactId: Long): CC() class APIGetGroupMemberCode(val groupId: Long, val groupMemberId: Long): CC() class APIVerifyContact(val contactId: Long, val connectionCode: String?): CC() @@ -1891,6 +1915,8 @@ sealed class CC { is APISwitchGroupMember -> "/_switch #$groupId $groupMemberId" is APIAbortSwitchContact -> "/_abort switch @$contactId" is APIAbortSwitchGroupMember -> "/_abort switch #$groupId $groupMemberId" + is APISyncContactRatchet -> if (force) "/_sync @$contactId force=on" else "/_sync @$contactId" + is APISyncGroupMemberRatchet -> if (force) "/_sync #$groupId $groupMemberId force=on" else "/_sync #$groupId $groupMemberId" is APIGetContactCode -> "/_get code @$contactId" is APIGetGroupMemberCode -> "/_get code #$groupId $groupMemberId" is APIVerifyContact -> "/_verify code @$contactId" + if (connectionCode != null) " $connectionCode" else "" @@ -1981,6 +2007,8 @@ sealed class CC { is APISwitchGroupMember -> "apiSwitchGroupMember" is APIAbortSwitchContact -> "apiAbortSwitchContact" is APIAbortSwitchGroupMember -> "apiAbortSwitchGroupMember" + is APISyncContactRatchet -> "apiSyncContactRatchet" + is APISyncGroupMemberRatchet -> "apiSyncGroupMemberRatchet" is APIGetContactCode -> "apiGetContactCode" is APIGetGroupMemberCode -> "apiGetGroupMemberCode" is APIVerifyContact -> "apiVerifyContact" @@ -3168,6 +3196,14 @@ sealed class CR { @Serializable @SerialName("groupMemberSwitchStarted") class GroupMemberSwitchStarted(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val connectionStats: ConnectionStats): CR() @Serializable @SerialName("contactSwitchAborted") class ContactSwitchAborted(val user: User, val contact: Contact, val connectionStats: ConnectionStats): CR() @Serializable @SerialName("groupMemberSwitchAborted") class GroupMemberSwitchAborted(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val connectionStats: ConnectionStats): CR() + @Serializable @SerialName("contactSwitch") class ContactSwitch(val user: User, val contact: Contact, val switchProgress: SwitchProgress): CR() + @Serializable @SerialName("groupMemberSwitch") class GroupMemberSwitch(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val switchProgress: SwitchProgress): CR() + @Serializable @SerialName("contactRatchetSyncStarted") class ContactRatchetSyncStarted(val user: User, val contact: Contact, val connectionStats: ConnectionStats): CR() + @Serializable @SerialName("groupMemberRatchetSyncStarted") class GroupMemberRatchetSyncStarted(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val connectionStats: ConnectionStats): CR() + @Serializable @SerialName("contactRatchetSync") class ContactRatchetSync(val user: User, val contact: Contact, val ratchetSyncProgress: RatchetSyncProgress): CR() + @Serializable @SerialName("groupMemberRatchetSync") class GroupMemberRatchetSync(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val ratchetSyncProgress: RatchetSyncProgress): CR() + @Serializable @SerialName("contactVerificationReset") class ContactVerificationReset(val user: User, val contact: Contact): CR() + @Serializable @SerialName("groupMemberVerificationReset") class GroupMemberVerificationReset(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("contactCode") class ContactCode(val user: User, val contact: Contact, val connectionCode: String): CR() @Serializable @SerialName("groupMemberCode") class GroupMemberCode(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val connectionCode: String): CR() @Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: User, val verified: Boolean, val expectedCode: String): CR() @@ -3286,6 +3322,14 @@ sealed class CR { is GroupMemberSwitchStarted -> "groupMemberSwitchStarted" is ContactSwitchAborted -> "contactSwitchAborted" is GroupMemberSwitchAborted -> "groupMemberSwitchAborted" + is ContactSwitch -> "contactSwitch" + is GroupMemberSwitch -> "groupMemberSwitch" + is ContactRatchetSyncStarted -> "contactRatchetSyncStarted" + is GroupMemberRatchetSyncStarted -> "groupMemberRatchetSyncStarted" + is ContactRatchetSync -> "contactRatchetSync" + is GroupMemberRatchetSync -> "groupMemberRatchetSync" + is ContactVerificationReset -> "contactVerificationReset" + is GroupMemberVerificationReset -> "groupMemberVerificationReset" is ContactCode -> "contactCode" is GroupMemberCode -> "groupMemberCode" is ConnectionVerified -> "connectionVerified" @@ -3401,6 +3445,14 @@ sealed class CR { is GroupMemberSwitchStarted -> withUser(user, "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionStats: ${json.encodeToString(connectionStats)}") is ContactSwitchAborted -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}") is GroupMemberSwitchAborted -> withUser(user, "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionStats: ${json.encodeToString(connectionStats)}") + is ContactSwitch -> withUser(user, "contact: ${json.encodeToString(contact)}\nswitchProgress: ${json.encodeToString(switchProgress)}") + is GroupMemberSwitch -> withUser(user, "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nswitchProgress: ${json.encodeToString(switchProgress)}") + is ContactRatchetSyncStarted -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}") + is GroupMemberRatchetSyncStarted -> withUser(user, "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionStats: ${json.encodeToString(connectionStats)}") + is ContactRatchetSync -> withUser(user, "contact: ${json.encodeToString(contact)}\nratchetSyncProgress: ${json.encodeToString(ratchetSyncProgress)}") + is GroupMemberRatchetSync -> withUser(user, "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nratchetSyncProgress: ${json.encodeToString(ratchetSyncProgress)}") + is ContactVerificationReset -> withUser(user, "contact: ${json.encodeToString(contact)}") + is GroupMemberVerificationReset -> withUser(user, "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}") is ContactCode -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionCode: $connectionCode") is GroupMemberCode -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode") is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode") @@ -3532,7 +3584,19 @@ abstract class TerminalItem { } @Serializable -class ConnectionStats(val rcvQueuesInfo: List, val sndQueuesInfo: List) +class ConnectionStats( + val connAgentVersion: Int, + val rcvQueuesInfo: List, + val sndQueuesInfo: List, + val ratchetSyncState: RatchetSyncState, + val ratchetSyncSupported: Boolean +) { + val ratchetSyncAllowed: Boolean get() = + ratchetSyncSupported && listOf(RatchetSyncState.Allowed, RatchetSyncState.Required).contains(ratchetSyncState) + + val ratchetSyncSendProhibited: Boolean get() = + listOf(RatchetSyncState.Required, RatchetSyncState.Started, RatchetSyncState.Agreed).contains(ratchetSyncState) +} @Serializable class RcvQueueInfo( @@ -3561,6 +3625,34 @@ enum class SndSwitchStatus { @SerialName("sending_qtest") SendingQTEST } +@Serializable +enum class QueueDirection { + @SerialName("rcv") Rcv, + @SerialName("snd") Snd +} + +@Serializable +class SwitchProgress( + val queueDirection: QueueDirection, + val switchPhase: SwitchPhase, + val connectionStats: ConnectionStats +) + +@Serializable +class RatchetSyncProgress( + val ratchetSyncStatus: RatchetSyncState, + val connectionStats: ConnectionStats +) + +@Serializable +enum class RatchetSyncState { + @SerialName("ok") Ok, + @SerialName("allowed") Allowed, + @SerialName("required") Required, + @SerialName("started") Started, + @SerialName("agreed") Agreed +} + @Serializable class UserContactLinkRec(val connReqContact: String, val autoAccept: AutoAccept? = null) { val responseDetails: String get() = "connReqContact: ${connReqContact}\nautoAccept: ${AutoAccept.cmdString(autoAccept)}" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 6cb7b4b478..83e3df2286 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -33,6 +33,8 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCode import chat.simplex.common.views.usersettings.* import chat.simplex.common.platform.* +import chat.simplex.common.views.chat.ContactPreferencesView +import chat.simplex.common.views.chat.VerifyCodeView import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* @@ -81,14 +83,45 @@ fun ChatInfoView( switchContactAddress = { showSwitchAddressAlert(switchAddress = { withApi { - connStats.value = chatModel.controller.apiSwitchContact(contact.contactId) + val cStats = chatModel.controller.apiSwitchContact(contact.contactId) + connStats.value = cStats + if (cStats != null) { + chatModel.updateContactConnectionStats(contact, cStats) + } + close.invoke() } }) }, abortSwitchContactAddress = { showAbortSwitchAddressAlert(abortSwitchAddress = { withApi { - connStats.value = chatModel.controller.apiAbortSwitchContact(contact.contactId) + val cStats = chatModel.controller.apiAbortSwitchContact(contact.contactId) + connStats.value = cStats + if (cStats != null) { + chatModel.updateContactConnectionStats(contact, cStats) + } + } + }) + }, + syncContactConnection = { + withApi { + val cStats = chatModel.controller.apiSyncContactRatchet(contact.contactId, force = false) + connStats.value = cStats + if (cStats != null) { + chatModel.updateContactConnectionStats(contact, cStats) + } + close.invoke() + } + }, + syncContactConnectionForce = { + showSyncConnectionForceAlert(syncConnectionForce = { + withApi { + val cStats = chatModel.controller.apiSyncContactRatchet(contact.contactId, force = true) + connStats.value = cStats + if (cStats != null) { + chatModel.updateContactConnectionStats(contact, cStats) + } + close.invoke() } }) }, @@ -176,6 +209,8 @@ fun ChatInfoLayout( clearChat: () -> Unit, switchContactAddress: () -> Unit, abortSwitchContactAddress: () -> Unit, + syncContactConnection: () -> Unit, + syncContactConnectionForce: () -> Unit, verifyClicked: () -> Unit, ) { val cStats = connStats.value @@ -205,6 +240,11 @@ fun ChatInfoLayout( VerifyCodeButton(contact.verified, verifyClicked) } ContactPreferencesButton(openPreferences) + if (cStats != null && cStats.ratchetSyncAllowed) { + SynchronizeConnectionButton(syncContactConnection) + } else if (developerTools) { + SynchronizeConnectionButtonForce(syncContactConnectionForce) + } } SectionDividerSpaced() @@ -228,12 +268,12 @@ fun ChatInfoLayout( } if (cStats != null) { SwitchAddressButton( - disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null }, + disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null } || cStats.ratchetSyncSendProhibited, switchAddress = switchContactAddress ) if (cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null }) { AbortSwitchAddressButton( - disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null && !it.canAbortSwitch }, + disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null && !it.canAbortSwitch } || cStats.ratchetSyncSendProhibited, abortSwitchAddress = abortSwitchContactAddress ) } @@ -419,6 +459,28 @@ fun AbortSwitchAddressButton(disabled: Boolean, abortSwitchAddress: () -> Unit) } } +@Composable +fun SynchronizeConnectionButton(syncConnection: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_sync_problem), + stringResource(MR.strings.fix_connection), + click = syncConnection, + textColor = WarningOrange, + iconColor = WarningOrange + ) +} + +@Composable +fun SynchronizeConnectionButtonForce(syncConnectionForce: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_warning), + stringResource(MR.strings.renegotiate_encryption), + click = syncConnectionForce, + textColor = Color.Red, + iconColor = Color.Red + ) +} + @Composable fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit) { SettingsActionItem( @@ -496,6 +558,16 @@ fun showAbortSwitchAddressAlert(abortSwitchAddress: () -> Unit) { ) } +fun showSyncConnectionForceAlert(syncConnectionForce: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.sync_connection_force_question), + text = generalGetString(MR.strings.sync_connection_force_desc), + confirmText = generalGetString(MR.strings.sync_connection_force_confirm), + onConfirm = syncConnectionForce, + destructive = true, + ) +} + @Preview @Composable fun PreviewChatInfoLayout() { @@ -518,6 +590,8 @@ fun PreviewChatInfoLayout() { clearChat = {}, switchContactAddress = {}, abortSwitchContactAddress = {}, + syncContactConnection = {}, + syncContactConnectionForce = {}, verifyClicked = {}, ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index b0846cc043..71e04337d6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -161,7 +161,8 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { showMemberInfo = { groupInfo: GroupInfo, member: GroupMember -> hideKeyboard(view) withApi { - val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId) + val r = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId) + val stats = r?.second val (_, code) = if (member.memberActive) { try { chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) @@ -254,6 +255,47 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { chatModel.controller.allowFeatureToContact(contact, feature, param) } }, + updateContactStats = { contact -> + withApi { + val r = chatModel.controller.apiContactInfo(chat.chatInfo.apiId) + if (r != null) { + chatModel.updateContactConnectionStats(contact, r.first) + } + } + }, + updateMemberStats = { groupInfo, member -> + withApi { + val r = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId) + if (r != null) { + val memStats = r.second + if (memStats != null) { + chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, memStats) + } + } + } + }, + syncContactConnection = { contact -> + withApi { + val cStats = chatModel.controller.apiSyncContactRatchet(contact.contactId, force = false) + if (cStats != null) { + chatModel.updateContactConnectionStats(contact, cStats) + } + } + }, + syncMemberConnection = { groupInfo, member -> + withApi { + val r = chatModel.controller.apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, force = false) + if (r != null) { + chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, r.second) + } + } + }, + findModelChat = { chatId -> + chatModel.getChat(chatId) + }, + findModelMember = { memberId -> + chatModel.groupMembers.find { it.id == memberId } + }, setReaction = { cInfo, cItem, add, reaction -> withApi { val updatedCI = chatModel.controller.apiChatItemReaction( @@ -338,6 +380,12 @@ fun ChatLayout( startCall: (CallMediaType) -> Unit, acceptCall: (Contact) -> Unit, acceptFeature: (Contact, ChatFeature, Int?) -> Unit, + updateContactStats: (Contact) -> Unit, + updateMemberStats: (GroupInfo, GroupMember) -> Unit, + syncContactConnection: (Contact) -> Unit, + syncMemberConnection: (GroupInfo, GroupMember) -> Unit, + findModelChat: (String) -> Chat?, + findModelMember: (String) -> GroupMember?, setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit, showItemDetails: (ChatInfo, ChatItem) -> Unit, addMembers: (GroupInfo) -> Unit, @@ -382,7 +430,9 @@ fun ChatLayout( ChatItemsList( chat, unreadCount, composeState, chatItems, searchValue, useLinkPreviews, linkMode, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage, - receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, setReaction, showItemDetails, markRead, setFloatingButton, onComposed, + receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, + updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, + setReaction, showItemDetails, markRead, setFloatingButton, onComposed, ) } } @@ -555,6 +605,12 @@ fun BoxWithConstraintsScope.ChatItemsList( joinGroup: (Long) -> Unit, acceptCall: (Contact) -> Unit, acceptFeature: (Contact, ChatFeature, Int?) -> Unit, + updateContactStats: (Contact) -> Unit, + updateMemberStats: (GroupInfo, GroupMember) -> Unit, + syncContactConnection: (Contact) -> Unit, + syncMemberConnection: (GroupInfo, GroupMember) -> Unit, + findModelChat: (String) -> Chat?, + findModelMember: (String) -> GroupMember?, setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit, showItemDetails: (ChatInfo, ChatItem) -> Unit, markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, @@ -670,11 +726,11 @@ fun BoxWithConstraintsScope.ChatItemsList( } else { Spacer(Modifier.size(42.dp)) } - ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails) + ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails) } } else { Box(Modifier.padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp).then(swipeableModifier)) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails) + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails) } } } else { // direct message @@ -685,7 +741,7 @@ fun BoxWithConstraintsScope.ChatItemsList( end = if (sent || voiceWithTransparentBack) 12.dp else 76.dp, ).then(swipeableModifier) ) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails) + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails) } } @@ -1107,6 +1163,12 @@ fun PreviewChatLayout() { startCall = {}, acceptCall = { _ -> }, acceptFeature = { _, _, _ -> }, + updateContactStats = { }, + updateMemberStats = { _, _ -> }, + syncContactConnection = { }, + syncMemberConnection = { _, _ -> }, + findModelChat = { null }, + findModelMember = { null }, setReaction = { _, _, _, _ -> }, showItemDetails = { _, _ -> }, addMembers = { _ -> }, @@ -1169,6 +1231,12 @@ fun PreviewGroupChatLayout() { startCall = {}, acceptCall = { _ -> }, acceptFeature = { _, _, _ -> }, + updateContactStats = { }, + updateMemberStats = { _, _ -> }, + syncContactConnection = { }, + syncMemberConnection = { _, _ -> }, + findModelChat = { null }, + findModelMember = { null }, setReaction = { _, _, _, _ -> }, showItemDetails = { _, _ -> }, addMembers = { _ -> }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index d93e69728f..92c10725a4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -60,7 +60,8 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberR }, showMemberInfo = { member -> withApi { - val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId) + val r = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId) + val stats = r?.second val (_, code) = if (member.memberActive) { try { chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index 53d98bfa1d..a0129b5034 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -100,14 +100,46 @@ fun GroupMemberInfoView( switchMemberAddress = { showSwitchAddressAlert(switchAddress = { withApi { - connStats.value = chatModel.controller.apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId) + val r = chatModel.controller.apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId) + if (r != null) { + connStats.value = r.second + chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, r.second) + close.invoke() + } } }) }, abortSwitchMemberAddress = { showAbortSwitchAddressAlert(abortSwitchAddress = { withApi { - connStats.value = chatModel.controller.apiAbortSwitchGroupMember(groupInfo.apiId, member.groupMemberId) + val r = chatModel.controller.apiAbortSwitchGroupMember(groupInfo.apiId, member.groupMemberId) + if (r != null) { + connStats.value = r.second + chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, r.second) + close.invoke() + } + } + }) + }, + syncMemberConnection = { + withApi { + val r = chatModel.controller.apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, force = false) + if (r != null) { + connStats.value = r.second + chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, r.second) + close.invoke() + } + } + }, + syncMemberConnectionForce = { + showSyncConnectionForceAlert(syncConnectionForce = { + withApi { + val r = chatModel.controller.apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, force = true) + if (r != null) { + connStats.value = r.second + chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, r.second) + close.invoke() + } } }) }, @@ -174,6 +206,8 @@ fun GroupMemberInfoLayout( onRoleSelected: (GroupMemberRole) -> Unit, switchMemberAddress: () -> Unit, abortSwitchMemberAddress: () -> Unit, + syncMemberConnection: () -> Unit, + syncMemberConnectionForce: () -> Unit, verifyClicked: () -> Unit, ) { val cStats = connStats.value @@ -210,6 +244,11 @@ fun GroupMemberInfoLayout( if (connectionCode != null) { VerifyCodeButton(member.verified, verifyClicked) } + if (cStats != null && cStats.ratchetSyncAllowed) { + SynchronizeConnectionButton(syncMemberConnection) + } else if (developerTools) { + SynchronizeConnectionButtonForce(syncMemberConnectionForce) + } } SectionDividerSpaced() } @@ -252,12 +291,12 @@ fun GroupMemberInfoLayout( SectionDividerSpaced() SectionView(title = stringResource(MR.strings.conn_stats_section_title_servers)) { SwitchAddressButton( - disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null }, + disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null } || cStats.ratchetSyncSendProhibited, switchAddress = switchMemberAddress ) if (cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null }) { AbortSwitchAddressButton( - disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null && !it.canAbortSwitch }, + disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null && !it.canAbortSwitch } || cStats.ratchetSyncSendProhibited, abortSwitchAddress = abortSwitchMemberAddress ) } @@ -412,6 +451,8 @@ fun PreviewGroupMemberInfoLayout() { onRoleSelected = {}, switchMemberAddress = {}, abortSwitchMemberAddress = {}, + syncMemberConnection = {}, + syncMemberConnectionForce = {}, verifyClicked = {}, ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt index e0aa04afbb..2ce52f9712 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt @@ -1,25 +1,232 @@ package chat.simplex.common.views.chat.item -import androidx.compose.runtime.Composable +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.* +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.CurrentColors import chat.simplex.common.views.helpers.AlertManager import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.common.model.ChatItem -import chat.simplex.common.model.MsgDecryptError import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource @Composable -fun CIRcvDecryptionError(msgDecryptError: MsgDecryptError, msgCount: UInt, ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean) { - CIMsgError(ci, timedMessagesTTL, showMember) { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.decryption_error), - text = when (msgDecryptError) { - MsgDecryptError.RatchetHeader -> String.format(generalGetString(MR.strings.alert_text_decryption_error_header), msgCount.toLong()) + "\n" + - generalGetString(MR.strings.alert_text_fragment_encryption_out_of_sync_old_database) + "\n" + - generalGetString(MR.strings.alert_text_fragment_permanent_error_reconnect) - MsgDecryptError.TooManySkipped -> String.format(generalGetString(MR.strings.alert_text_decryption_error_too_many_skipped), msgCount.toLong()) + "\n" + - generalGetString(MR.strings.alert_text_fragment_encryption_out_of_sync_old_database) + "\n" + - generalGetString(MR.strings.alert_text_fragment_permanent_error_reconnect) +fun CIRcvDecryptionError( + msgDecryptError: MsgDecryptError, + msgCount: UInt, + cInfo: ChatInfo, + ci: ChatItem, + updateContactStats: (Contact) -> Unit, + updateMemberStats: (GroupInfo, GroupMember) -> Unit, + syncContactConnection: (Contact) -> Unit, + syncMemberConnection: (GroupInfo, GroupMember) -> Unit, + findModelChat: (String) -> Chat?, + findModelMember: (String) -> GroupMember?, + showMember: Boolean +) { + LaunchedEffect(Unit) { + if (cInfo is ChatInfo.Direct) { + updateContactStats(cInfo.contact) + } else if (cInfo is ChatInfo.Group && ci.chatDir is CIDirection.GroupRcv) { + updateMemberStats(cInfo.groupInfo, ci.chatDir.groupMember) + } + } + + @Composable + fun BasicDecryptionErrorItem() { + DecryptionErrorItem( + ci, + showMember, + onClick = { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.decryption_error), + text = alertMessage(msgDecryptError, msgCount) + ) } ) } + + if (cInfo is ChatInfo.Direct) { + val modelCInfo = findModelChat(cInfo.id)?.chatInfo + if (modelCInfo is ChatInfo.Direct) { + val modelContactStats = modelCInfo.contact.activeConn.connectionStats + if (modelContactStats != null) { + if (modelContactStats.ratchetSyncAllowed) { + DecryptionErrorItemFixButton( + ci, + showMember, + onClick = { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.fix_connection_question), + text = alertMessage(msgDecryptError, msgCount), + confirmText = generalGetString(MR.strings.fix_connection_confirm), + onConfirm = { syncContactConnection(cInfo.contact) }, + ) + }, + syncSupported = true + ) + } else if (!modelContactStats.ratchetSyncSupported) { + DecryptionErrorItemFixButton( + ci, + showMember, + onClick = { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.fix_connection_not_supported_by_contact), + text = alertMessage(msgDecryptError, msgCount) + ) + }, + syncSupported = false + ) + } else { + BasicDecryptionErrorItem() + } + } else { + BasicDecryptionErrorItem() + } + } else { + BasicDecryptionErrorItem() + } + } else if (cInfo is ChatInfo.Group && ci.chatDir is CIDirection.GroupRcv) { + val modelMember = findModelMember(ci.chatDir.groupMember.id) + val modelMemberStats = modelMember?.activeConn?.connectionStats + if (modelMemberStats != null) { + if (modelMemberStats.ratchetSyncAllowed) { + DecryptionErrorItemFixButton( + ci, + showMember, + onClick = { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.fix_connection_question), + text = alertMessage(msgDecryptError, msgCount), + confirmText = generalGetString(MR.strings.fix_connection_confirm), + onConfirm = { syncMemberConnection(cInfo.groupInfo, modelMember) }, + ) + }, + syncSupported = true + ) + } else if (!modelMemberStats.ratchetSyncSupported) { + DecryptionErrorItemFixButton( + ci, + showMember, + onClick = { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.fix_connection_not_supported_by_group_member), + text = alertMessage(msgDecryptError, msgCount) + ) + }, + syncSupported = false + ) + } else { + BasicDecryptionErrorItem() + } + } else { + BasicDecryptionErrorItem() + } + } else { + BasicDecryptionErrorItem() + } +} + +@Composable +fun DecryptionErrorItemFixButton( + ci: ChatItem, + showMember: Boolean, + onClick: () -> Unit, + syncSupported: Boolean +) { + val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage + Surface( + Modifier.clickable(onClick = onClick), + shape = RoundedCornerShape(18.dp), + color = receivedColor, + ) { + Box( + Modifier.padding(vertical = 6.dp, horizontal = 12.dp), + contentAlignment = Alignment.BottomEnd, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + buildAnnotatedString { + appendSender(this, if (showMember) ci.memberDisplayName else null, true) + withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) } + }, + style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp) + ) + Row { + Icon( + painterResource(MR.images.ic_sync_problem), + stringResource(MR.strings.fix_connection), + tint = if (syncSupported) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + ) + Spacer(Modifier.padding(2.dp)) + Text( + buildAnnotatedString { + append(generalGetString(MR.strings.fix_connection)) + withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null)) } + withStyle(reserveTimestampStyle) { append(" ") } // for icon + }, + color = if (syncSupported) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + ) + } + } + CIMetaView(ci, timedMessagesTTL = null) + } + } +} + +@Composable +fun DecryptionErrorItem( + ci: ChatItem, + showMember: Boolean, + onClick: () -> Unit +) { + val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage + Surface( + Modifier.clickable(onClick = onClick), + shape = RoundedCornerShape(18.dp), + color = receivedColor, + ) { + Box( + Modifier.padding(vertical = 6.dp, horizontal = 12.dp), + contentAlignment = Alignment.BottomEnd, + ) { + Text( + buildAnnotatedString { + appendSender(this, if (showMember) ci.memberDisplayName else null, true) + withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) } + withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null)) } + }, + style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp) + ) + CIMetaView(ci, timedMessagesTTL = null) + } + } +} + +private fun alertMessage(msgDecryptError: MsgDecryptError, msgCount: UInt): String { + return when (msgDecryptError) { + MsgDecryptError.RatchetHeader -> String.format(generalGetString(MR.strings.alert_text_decryption_error_n_messages_failed_to_decrypt), msgCount.toLong()) + "\n" + + generalGetString(MR.strings.alert_text_fragment_encryption_out_of_sync_old_database) + + MsgDecryptError.TooManySkipped -> String.format(generalGetString(MR.strings.alert_text_decryption_error_too_many_skipped), msgCount.toLong()) + "\n" + + generalGetString(MR.strings.alert_text_fragment_encryption_out_of_sync_old_database) + + MsgDecryptError.RatchetEarlier -> String.format(generalGetString(MR.strings.alert_text_decryption_error_n_messages_failed_to_decrypt), msgCount.toLong()) + "\n" + + generalGetString(MR.strings.alert_text_fragment_encryption_out_of_sync_old_database) + + MsgDecryptError.Other -> String.format(generalGetString(MR.strings.alert_text_decryption_error_n_messages_failed_to_decrypt), msgCount.toLong()) + "\n" + + generalGetString(MR.strings.alert_text_fragment_encryption_out_of_sync_old_database) + } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 2fe7d94f6d..67b76ece0f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -46,6 +46,12 @@ fun ChatItemView( acceptCall: (Contact) -> Unit, scrollToItem: (Long) -> Unit, acceptFeature: (Contact, ChatFeature, Int?) -> Unit, + updateContactStats: (Contact) -> Unit, + updateMemberStats: (GroupInfo, GroupMember) -> Unit, + syncContactConnection: (Contact) -> Unit, + syncMemberConnection: (GroupInfo, GroupMember) -> Unit, + findModelChat: (String) -> Chat?, + findModelMember: (String) -> GroupMember?, setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit, showItemDetails: (ChatInfo, ChatItem) -> Unit, ) { @@ -285,7 +291,7 @@ fun ChatItemView( is CIContent.SndCall -> CallItem(c.status, c.duration) is CIContent.RcvCall -> CallItem(c.status, c.duration) is CIContent.RcvIntegrityError -> IntegrityErrorItemView(c.msgError, cItem, cInfo.timedMessagesTTL, showMember = showMember) - is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cItem, cInfo.timedMessagesTTL, showMember = showMember) + is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, showMember = showMember) is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) is CIContent.RcvGroupEventContent -> CIEventView(cItem) @@ -508,6 +514,12 @@ fun PreviewChatItemView() { acceptCall = { _ -> }, scrollToItem = {}, acceptFeature = { _, _, _ -> }, + updateContactStats = { }, + updateMemberStats = { _, _ -> }, + syncContactConnection = { }, + syncMemberConnection = { _, _ -> }, + findModelChat = { null }, + findModelMember = { null }, setReaction = { _, _, _, _ -> }, showItemDetails = { _, _ -> }, ) @@ -531,6 +543,12 @@ fun PreviewChatItemViewDeletedContent() { acceptCall = { _ -> }, scrollToItem = {}, acceptFeature = { _, _, _ -> }, + updateContactStats = { }, + updateMemberStats = { _, _ -> }, + syncContactConnection = { }, + syncMemberConnection = { _, _ -> }, + findModelChat = { null }, + findModelMember = { null }, setReaction = { _, _, _, _ -> }, showItemDetails = { _, _ -> }, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 41298b0521..de2d67b48c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -135,8 +135,19 @@ suspend fun apiFindMessages(chatInfo: ChatInfo, chatModel: ChatModel, search: St suspend fun setGroupMembers(groupInfo: GroupInfo, chatModel: ChatModel) { val groupMembers = chatModel.controller.apiListMembers(groupInfo.groupId) + val currentMembers = chatModel.groupMembers + val newMembers = groupMembers.map { newMember -> + val currentMember = currentMembers.find { it.id == newMember.id } + val currentMemberStats = currentMember?.activeConn?.connectionStats + val newMemberConn = newMember.activeConn + if (currentMemberStats != null && newMemberConn != null && newMemberConn.connectionStats == null) { + newMember.copy(activeConn = newMemberConn.copy(connectionStats = currentMemberStats)) + } else { + newMember + } + } chatModel.groupMembers.clear() - chatModel.groupMembers.addAll(groupMembers) + chatModel.groupMembers.addAll(newMembers) } @Composable diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 75a6bf3546..1797890271 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -104,6 +104,7 @@ Error deleting pending contact connection Error changing address Error aborting address change + Error synchronizing connection Test failed at step %s. Server requires authorization to create queues, check password Server requires authorization to upload, check password @@ -339,6 +340,9 @@ Abort changing address? Address change will be aborted. Old receiving address will be used. Abort + Renegotiate encryption? + The encryption is working and the new encryption agreement is not required. It may result in connection errors! + Renegotiate View security code Verify security code @@ -816,10 +820,9 @@ The hash of the previous message is different." Bad message ID The ID of the next message is incorrect (less or equal to the previous).\nIt can happen because of some bug or when the connection is compromised. - %1$d messages failed to decrypt. + %1$d messages failed to decrypt. %1$d messages skipped. It can happen when you or your connection used the old database backup. - This error is permanent for this connection, please re-connect. Please report it to the developers. @@ -1065,6 +1068,17 @@ changing address for %s… you changed address changing address… + encryption ok + encryption re-negotiation allowed + encryption re-negotiation required + agreeing encryption… + encryption agreed + encryption ok for %s + encryption re-negotiation allowed for %s + encryption re-negotiation required for %s + agreeing encryption for %s + encryption agreed for %s + security code changed observer @@ -1184,6 +1198,12 @@ Network status Change receiving address Abort changing address + Fix connection + Fix connection? + Fix + Fix not supported by contact + Fix not supported by group member + Renegotiate encryption Create secret group diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index 431b494d29..d5aa11739e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -1096,7 +1096,7 @@ Hash předchozí zprávy se liší. ID další zprávy je nesprávné (menší nebo rovno předchozí). \nMůže se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitováno. - %1$d zprávy se nepodařilo dešifrovat. + %1$d zprávy se nepodařilo dešifrovat. %1$d zprývy přeskočeny. Nahlaste to prosím vývojářům. Tato chyba je pro toto připojení trvalá, připojte se znovu. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml index 97402b4871..c44133812e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -1173,7 +1173,7 @@ XFTP-Server Die ID der nächsten Nachricht ist falsch (kleiner oder gleich der Vorherigen). \nDies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompromittiert wurde. - %1$d Nachrichten konnten nicht entschlüsselt werden. + %1$d Nachrichten konnten nicht entschlüsselt werden. Der Hash der vorherigen Nachricht unterscheidet sich. Sie können die SimpleX Sperre über die Einstellungen aktivieren. SOCKS-Proxy Einstellungen 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 a4c2f6614e..b7415a3b97 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -1101,7 +1101,7 @@ Puede ocurrir cuando tu o tu contacto estáis usando una copia de seguridad antigua de la base de datos. El hash del mensaje anterior es diferente. El error es permanente para esta conexión, por favor vuelve a conectarte. - %1$d mensajes no pudieron ser descifrados. + %1$d mensajes no pudieron ser descifrados. ¡Sin espacios! Detener archivo El archivo será eliminado de los servidores. 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 39b860d455..c576c7bece 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml @@ -1244,7 +1244,7 @@ %1$d viestit ohitettu. Seuraavan viestin tunnus on väärä (pienempi tai yhtä suuri kuin edellisen). \nTämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut. - %1$d viestien salauksen purku epäonnistui. + %1$d viestien salauksen purku epäonnistui. Näytä Sinun on annettava tunnuslause aina, kun sovellus käynnistyy - sitä ei tallenneta laitteeseen. Päivitä tietokannan tunnuslause diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index 325b02ecfd..7ffc6aca85 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -1119,7 +1119,7 @@ Arrêter de recevoir le fichier \? Cette erreur est persistante pour cette connexion, veuillez vous reconnecter. Vous n\'avez pas pu être vérifié·e ; veuillez réessayer. - %1$d messages n\'ont pas pu être déchiffrés. + %1$d messages n\'ont pas pu être déchiffrés. %1$d messages sautés. Vous pouvez activer SimpleX Lock dans les Paramètres. Merci aux utilisateurs - contribuez via Weblate ! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_sync_problem.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_sync_problem.svg new file mode 100644 index 0000000000..79a0441792 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_sync_problem.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_warning.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_warning.svg new file mode 100644 index 0000000000..7148c5740b --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_warning.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index 2fdc66e335..fe4eb5be6c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -1097,7 +1097,7 @@ L\'ID del messaggio successivo non è corretto (inferiore o uguale al precedente). \nPuò accadere a causa di qualche bug o quando la connessione è compromessa. L\'errore è permanente per questa connessione, riconnettiti. - %1$d messaggi non decifrati. + %1$d messaggi non decifrati. Hash del messaggio errato L\'hash del messaggio precedente è diverso. Si prega di segnalarlo agli sviluppatori. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml index a9f4d8e190..6392861799 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -1197,7 +1197,7 @@ דרך ממסר וידאו כבוי וידאו פעיל - %1$d הודעות לא הצליחו לעבור פענוח. + %1$d הודעות לא הצליחו לעבור פענוח. עליכם להשתמש בגרסה העדכנית ביותר של מסד הנתונים שלכם במכשיר אחד בלבד, אחרת אתם עלולים להפסיק לקבל הודעות מאנשי קשר מסוימים. סיסמה שגויה! הוזמנתם לקבוצה. הצטרפו כדי ליצור קשר עם חברי הקבוצה. 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 48dc894f1f..b72dabf154 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -1072,7 +1072,7 @@ 開発者に報告してください。 このエラーはこの接続では永続的なものです。再接続してください。 1GBまでのビデオとファイル - %1$d メッセージの復号化に失敗しました。 + %1$d メッセージの復号化に失敗しました。 %1$d メッセージをスキップしました SMP サーバーのロード中にエラーが発生しました ユーザーパスワード保存エラー diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index 3697081620..5a3500c902 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -1099,7 +1099,7 @@ Onjuiste bericht ID De ID van het volgende bericht is onjuist (minder of gelijk aan het vorige). \nHet kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. - %1$d-berichten konden niet worden ontsleuteld. + %1$d-berichten konden niet worden ontsleuteld. Geen spaties! Het ontvangen van het bestand wordt gestopt. Intrekken diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index 552f41ffe9..f5a6258891 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -1096,7 +1096,7 @@ Hash poprzedniej wiadomości jest inny. Identyfikator następnej wiadomości jest nieprawidłowy (mniejszy lub równy poprzedniej). \nMoże się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skompromitowane. - Nie udało się odszyfrować %1$d wiadomości. + Nie udało się odszyfrować %1$d wiadomości. Ten błąd jest trwały dla tego połączenia, proszę o ponowne połączenie. %1$d pominiętych wiadomości. Może się to zdarzyć, gdy Ty lub Twoje połączenie użyło starej kopii zapasowej bazy danych. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml index ccd12968c1..0a1868092e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml @@ -1088,7 +1088,7 @@ Você pode ativar o bloqueio SimpleX via Configurações. Hash de mensagem incorreta O hash da mensagem anterior é diferente. - %1$d descriptografia das mensagens falhou + %1$d descriptografia das mensagens falhou ID de mensagem incorreta A ID da próxima mensagem está incorreta (menor ou igual à anterior). \nIsso pode acontecer por causa de algum bug ou quando a conexão está comprometida. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml index e7a82a1146..77903a4d7b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -1156,7 +1156,7 @@ Аутентификация отменена Системная %d секунд - %1$d сообщений не удалось расшифровать. + %1$d сообщений не удалось расшифровать. Ошибка расшифровки Блокировка SimpleX не включена! Ошибка хэш сообщения diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml index 3f704f2a9f..bf2073c480 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml @@ -1052,7 +1052,7 @@ ตรวจสอบความปลอดภัยในการเชื่อมต่อ เมื่อคุณแชร์โปรไฟล์ที่ไม่ระบุตัวตนกับใครสักคน โปรไฟล์นี้จะใช้สำหรับกลุ่มที่พวกเขาเชิญคุณ %1$s ต้องการเชื่อมต่อกับคุณผ่านทาง - ข้อความ %1$d ไม่สามารถ decrypt ได้ + ข้อความ %1$d ไม่สามารถ decrypt ได้ %1$s สมาชิก เชื่อมต่อกับ SimpleX Chat นักพัฒนาแอปเพื่อถามคำถามและรับการอัปเดต]]> คุณสามารถแชร์ที่อยู่ของคุณเป็นลิงก์หรือรหัสคิวอาร์ - ใคร ๆ ก็สามารถเชื่อมต่อกับคุณได้ diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml index 3641fa2b3e..eb45409d56 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -874,7 +874,7 @@ Показати Вимкнути Сервер ретрансляції використовується лише за необхідності. Інша сторона може спостерігати за вашою IP-адресою. - %1$d повідомлення не вдалося розшифрувати. + %1$d повідомлення не вдалося розшифрувати. %1$d повідомлення пропущені. Будь ласка, повідомте про це розробникам. Надіслати попередній перегляд за посиланням 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 e90a1e4f60..105750cf2b 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 @@ -1092,7 +1092,7 @@ 立即 错误消息散列 错误消息 ID - %1$d 消息解密失败。 + %1$d 消息解密失败。 %1$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 8b28a432d1..65337b4df7 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 @@ -1120,7 +1120,7 @@ 系統 此ID的下一則訊息是錯誤(小於或等於上一則的)。 \n當一些錯誤出現或你的連結被破壞時會發生。 - %1$d 訊息解密失敗。 + %1$d 訊息解密失敗。 使用SOCKS 代理伺服器 你的 XFTP 伺服器 這個連結錯誤是永久性的,請重新連接。 diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index ac73c373a4..4f8f55dbb7 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -25,8 +25,8 @@ android.nonTransitiveRClass=true android.enableJetifier=true kotlin.mpp.androidSourceSetLayoutVersion=2 -android.version_name=5.2-beta.0 -android.version_code=129 +android.version_name=5.2-beta.1 +android.version_code=131 desktop.version_name=1.0 diff --git a/cabal.project b/cabal.project index e7686237fe..66e867b2ca 100644 --- a/cabal.project +++ b/cabal.project @@ -7,7 +7,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: f2657f9c0b954f952aaf381bb9b55ac34ea59ed7 + tag: 1afcefa5e7cf7c4a5e5732104105d14259be16b6 source-repository-package type: git diff --git a/package.yaml b/package.yaml index 0e0f49a2b7..d3b6c93a79 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.2.0.0 +version: 5.2.0.1 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index f7b224524b..5ee0055293 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."f2657f9c0b954f952aaf381bb9b55ac34ea59ed7" = "04qhadd0shs4hj5b62i78jhnq5c620b72naqavqirvjc7pymyq5g"; + "https://github.com/simplex-chat/simplexmq.git"."1afcefa5e7cf7c4a5e5732104105d14259be16b6" = "0h017r0cjjc00d59wn3gs482dyjaqgapym370s36xhhvhn11n96x"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb"; "https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 8a2bb16a69..53cb483deb 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 5.2.0.0 +version: 5.2.0.1 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index af0f8e31a5..c575efb34d 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -16,6 +16,7 @@ module Simplex.Chat where import Control.Applicative (optional, (<|>)) import Control.Concurrent.STM (retry, stateTVar) +import qualified Control.Exception as E import Control.Logger.Simple import Control.Monad.Except import Control.Monad.IO.Unlift @@ -89,14 +90,14 @@ import Simplex.Messaging.Transport.Client (defaultSocksProxy) import Simplex.Messaging.Util import System.Exit (exitFailure, exitSuccess) import System.FilePath (combine, splitExtensions, takeFileName, ()) -import System.IO (Handle, IOMode (..), SeekMode (..), hFlush, openFile, stdout) +import System.IO (Handle, IOMode (..), SeekMode (..), hFlush, stdout) import System.Random (randomRIO) import Text.Read (readMaybe) import UnliftIO.Async import UnliftIO.Concurrent (forkFinally, forkIO, mkWeakThreadId, threadDelay) import UnliftIO.Directory -import qualified UnliftIO.Exception as E -import UnliftIO.IO (hClose, hSeek, hTell) +import qualified UnliftIO.Exception as UE +import UnliftIO.IO (hClose, hSeek, hTell, openFile) import UnliftIO.STM defaultChatConfig :: ChatConfig @@ -288,11 +289,11 @@ startFilesToReceive users = do startReceive :: [User] -> m () startReceive = mapM_ $ runExceptT . startReceiveUserFiles -startReceiveUserFiles :: forall m. ChatMonad m => User -> m () +startReceiveUserFiles :: ChatMonad m => User -> m () startReceiveUserFiles user = do filesToReceive <- withStoreCtx' (Just "startReceiveUserFiles, getRcvFilesToReceive") (`getRcvFilesToReceive` user) forM_ filesToReceive $ \ft -> - flip catchError (toView . CRChatError (Just user)) $ + flip catchChatError (toView . CRChatError (Just user)) $ toView =<< receiveFile' user ft Nothing Nothing restoreCalls :: ChatMonad' m => m () @@ -590,7 +591,7 @@ processChatCommand = \case sendGroupFileInline :: [GroupMember] -> SharedMsgId -> FileTransferMeta -> m () sendGroupFileInline ms sharedMsgId ft@FileTransferMeta {fileInline} = when (fileInline == Just IFMSent) . forM_ ms $ \m -> - processMember m `catchError` (toView . CRChatError (Just user)) + processMember m `catchChatError` (toView . CRChatError (Just user)) where processMember m@GroupMember {activeConn = Just conn@Connection {connStatus}} = when (connStatus == ConnReady || connStatus == ConnSndReady) $ do @@ -653,7 +654,7 @@ processChatCommand = \case let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just file, fileStatus = CIFSSndStored, fileProtocol = FPXFTP} case contactOrGroup of CGContact Contact {activeConn} -> withStore' $ \db -> createSndFTDescrXFTP db user Nothing activeConn ft fileDescr - CGGroup (Group _ ms) -> forM_ ms $ \m -> saveMemberFD m `catchError` (toView . CRChatError (Just user)) + CGGroup (Group _ ms) -> forM_ ms $ \m -> saveMemberFD m `catchChatError` (toView . CRChatError (Just user)) where -- we are not sending files to pending members, same as with inline files saveMemberFD m@GroupMember {activeConn = Just conn@Connection {connStatus}} = @@ -870,7 +871,7 @@ processChatCommand = \case deleteUnusedContact :: ContactId -> m [ConnId] deleteUnusedContact contactId = (withStore (\db -> getContact db user contactId) >>= delete) - `catchError` (\e -> toView (CRChatError (Just user) e) $> []) + `catchChatError` (\e -> toView (CRChatError (Just user) e) $> []) where delete ct | directOrUsed ct = pure [] @@ -880,7 +881,7 @@ processChatCommand = \case Nothing -> do conns <- withStore $ \db -> getContactConnections db userId ct withStore' (\db -> setContactDeleted db user ct) - `catchError` (toView . CRChatError (Just user)) + `catchChatError` (toView . CRChatError (Just user)) pure $ map aConnId conns CTContactRequest -> pure $ chatCmdError (Just user) "not supported" APIClearChat (ChatRef cType chatId) -> withUser $ \user -> case cType of @@ -911,7 +912,7 @@ processChatCommand = \case cReq@UserContactRequest {agentContactConnId = AgentConnId connId, agentInvitationId = AgentInvId invId} <- withStore $ \db -> getContactRequest db user connReqId - `E.finally` liftIO (deleteContactRequest db user connReqId) + `storeFinally` liftIO (deleteContactRequest db user connReqId) withAgent $ \a -> rejectContact a connId invId pure $ CRContactRequestRejected user cReq APISendCallInvitation contactId callType -> withUser $ \user -> do @@ -1032,7 +1033,7 @@ processChatCommand = \case user_ <- withStore' (`getUserByAConnId` agentConnId) connEntity <- pure user_ $>>= \user -> - withStore (\db -> Just <$> getConnectionEntity db user agentConnId) `catchError` (\e -> toView (CRChatError (Just user) e) $> Nothing) + withStore (\db -> Just <$> getConnectionEntity db user agentConnId) `catchChatError` (\e -> toView (CRChatError (Just user) e) $> Nothing) pure CRNtfMessages {user_, connEntity, msgTs = msgTs', ntfMessages} APIGetUserProtoServers userId (AProtocolType p) -> withUserId userId $ \user -> withServerProtocol p $ do ChatConfig {defaultServers} <- asks config @@ -1099,7 +1100,7 @@ processChatCommand = \case liftIO $ updateGroupSettings db user chatId chatSettings pure ms forM_ (filter memberActive ms) $ \m -> forM_ (memberConnId m) $ \connId -> - withAgent (\a -> toggleConnectionNtfs a connId $ enableNtfs chatSettings) `catchError` (toView . CRChatError (Just user)) + withAgent (\a -> toggleConnectionNtfs a connId $ enableNtfs chatSettings) `catchChatError` (toView . CRChatError (Just user)) ok user _ -> pure $ chatCmdError (Just user) "not supported" APIContactInfo contactId -> withUser $ \user@User {userId} -> do @@ -1307,7 +1308,7 @@ processChatCommand = \case where mc = MCText msg sendAndCount user ll (s, f) ct = - (sendToContact user ct $> (s + 1, f)) `catchError` \e -> when (ll <= CLLInfo) (toView $ CRChatError (Just user) e) $> (s, f + 1) + (sendToContact user ct $> (s + 1, f)) `catchChatError` \e -> when (ll <= CLLInfo) (toView $ CRChatError (Just user) e) $> (s, f + 1) sendToContact user ct = do (sndMsg, _) <- sendDirectContactMessage ct (XMsgNew $ MCSimple (extMsgContent mc Nothing)) void $ saveSndChatItem user (CDDirectSnd ct) sndMsg (CISndMsgContent mc) @@ -1607,7 +1608,7 @@ processChatCommand = \case Just XFTPRcvFile {agentRcvFileId} -> do forM_ (liveRcvFileTransferPath ftr) $ \filePath -> do fsFilePath <- toFSFilePath filePath - removeFile fsFilePath `E.catch` \(_ :: E.SomeException) -> pure () + liftIO $ removeFile fsFilePath `catchAll_` pure () forM_ agentRcvFileId $ \(AgentRcvFileId aFileId) -> withAgent (`xftpDeleteRcvFile` aFileId) ci <- withStore $ \db -> do @@ -1683,7 +1684,7 @@ processChatCommand = \case -- corrId <- liftIO $ SMP.CorrId <$> randomBytes gVar 8 -- void . forkIO $ -- withAgentLock a . withLock l name $ - -- (atomically . writeTBQueue q) . (Just corrId,) =<< (action `catchError` (pure . CRChatError)) + -- (atomically . writeTBQueue q) . (Just corrId,) =<< (action `catchChatError` (pure . CRChatError)) -- pure $ CRCmdAccepted corrId -- use function below to make commands "synchronous" procCmd :: m ChatResponse -> m ChatResponse @@ -1797,7 +1798,7 @@ processChatCommand = \case (successes, failures) <- foldM (processAndCount user' logLevel) (0, 0) contacts pure $ CRUserProfileUpdated user' (fromLocalProfile p) p' successes failures where - processAndCount user' ll (s, f) ct = (processContact user' ct $> (s + 1, f)) `catchError` \e -> when (ll <= CLLInfo) (toView $ CRChatError (Just user) e) $> (s, f + 1) + processAndCount user' ll (s, f) ct = (processContact user' ct $> (s + 1, f)) `catchChatError` \e -> when (ll <= CLLInfo) (toView $ CRChatError (Just user) e) $> (s, f + 1) processContact user' ct = do let mergedProfile = userProfileToSend user Nothing $ Just ct ct' = updateMergedPreferences user' ct @@ -1816,7 +1817,7 @@ processChatCommand = \case mergedProfile' = userProfileToSend user (fromLocalProfile <$> incognitoProfile) (Just ct') when (mergedProfile' /= mergedProfile) $ withChatLock "updateProfile" $ do - void (sendDirectContactMessage ct' $ XInfo mergedProfile') `catchError` (toView . CRChatError (Just user)) + void (sendDirectContactMessage ct' $ XInfo mergedProfile') `catchChatError` (toView . CRChatError (Just user)) when (directOrUsed ct') $ createSndFeatureItems user ct ct' pure $ CRContactPrefsUpdated user ct ct' runUpdateGroupProfile :: User -> Group -> GroupProfile -> m ChatResponse @@ -1996,7 +1997,7 @@ startExpireCIThread user@User {userId} = do liftIO $ threadDelay' delay interval <- asks $ ciExpirationInterval . config forever $ do - flip catchError (toView . CRChatError (Just user)) $ do + flip catchChatError (toView . CRChatError (Just user)) $ do expireFlags <- asks expireCIFlags atomically $ TM.lookup userId expireFlags >>= \b -> unless (b == Just True) retry ttl <- withStoreCtx' (Just "startExpireCIThread, getChatItemTTL") (`getChatItemTTL` user) @@ -2015,26 +2016,26 @@ setAllExpireCIFlags b = do keys <- M.keys <$> readTVar expireFlags forM_ keys $ \k -> TM.insert k b expireFlags -deleteFilesAndConns :: forall m. ChatMonad m => User -> [CIFileInfo] -> m () +deleteFilesAndConns :: ChatMonad m => User -> [CIFileInfo] -> m () deleteFilesAndConns user filesInfo = do connIds <- mapM (deleteFile user) filesInfo deleteAgentConnectionsAsync user $ concat connIds -deleteFile :: forall m. ChatMonad m => User -> CIFileInfo -> m [ConnId] +deleteFile :: ChatMonad m => User -> CIFileInfo -> m [ConnId] deleteFile user fileInfo = deleteFile' user fileInfo False deleteFile' :: forall m. ChatMonad m => User -> CIFileInfo -> Bool -> m [ConnId] deleteFile' user ciFileInfo@CIFileInfo {filePath} sendCancel = do aConnIds <- cancelFile' user ciFileInfo sendCancel - delete `catchError` (toView . CRChatError (Just user)) + delete `catchChatError` (toView . CRChatError (Just user)) pure aConnIds where delete :: m () delete = withFilesFolder $ \filesFolder -> - forM_ filePath $ \fPath -> do + liftIO . forM_ filePath $ \fPath -> do let fsFilePath = filesFolder fPath - removeFile fsFilePath `E.catch` \(_ :: E.SomeException) -> - removePathForcibly fsFilePath `E.catch` \(_ :: E.SomeException) -> pure () + removeFile fsFilePath `catchAll` \_ -> + removePathForcibly fsFilePath `catchAll_` pure () -- perform an action only if filesFolder is set (i.e. on mobile devices) withFilesFolder :: (FilePath -> m ()) -> m () withFilesFolder action = asks filesFolder >>= readTVarIO >>= mapM_ action @@ -2042,7 +2043,7 @@ deleteFile' user ciFileInfo@CIFileInfo {filePath} sendCancel = do cancelFile' :: forall m. ChatMonad m => User -> CIFileInfo -> Bool -> m [ConnId] cancelFile' user CIFileInfo {fileId, fileStatus} sendCancel = case fileStatus of - Just fStatus -> cancel' fStatus `catchError` (\e -> toView (CRChatError (Just user) e) $> []) + Just fStatus -> cancel' fStatus `catchChatError` (\e -> toView (CRChatError (Just user) e) $> []) Nothing -> pure [] where cancel' :: ACIFileStatus -> m [ConnId] @@ -2099,13 +2100,13 @@ callStatusItemContent user Contact {contactId} chatItemId receivedStatus = do -- mobile clients use file paths relative to app directory (e.g. for the reason ios app directory changes on updates), -- so we have to differentiate between the file path stored in db and communicated with frontend, and the file path -- used during file transfer for actual operations with file system -toFSFilePath :: ChatMonad m => FilePath -> m FilePath +toFSFilePath :: ChatMonad' m => FilePath -> m FilePath toFSFilePath f = maybe f ( f) <$> (readTVarIO =<< asks filesFolder) receiveFile' :: ChatMonad m => User -> RcvFileTransfer -> Maybe Bool -> Maybe FilePath -> m ChatResponse receiveFile' user ft rcvInline_ filePath_ = do - (CRRcvFileAccepted user <$> acceptFileReceive user ft rcvInline_ filePath_) `catchError` processError + (CRRcvFileAccepted user <$> acceptFileReceive user ft rcvInline_ filePath_) `catchChatError` processError where processError = \case -- TODO AChatItem in Cancelled events @@ -2215,7 +2216,7 @@ getRcvFilePath fileId fPath_ fn keepHandle = case fPath_ of (createEmptyFile fPath) where createEmptyFile :: FilePath -> m FilePath - createEmptyFile fPath = emptyFile fPath `E.catch` (throwChatError . CEFileWrite fPath . (show :: E.SomeException -> String)) + createEmptyFile fPath = emptyFile fPath `catchThrow` (ChatError . CEFileWrite fPath . show) emptyFile :: FilePath -> m FilePath emptyFile fPath = do h <- @@ -2225,8 +2226,7 @@ getRcvFilePath fileId fPath_ fn keepHandle = case fPath_ of liftIO $ B.hPut h "" >> hFlush h pure fPath getTmpHandle :: FilePath -> m Handle - getTmpHandle fPath = - liftIO (openFile fPath AppendMode) `E.catch` (throwChatError . CEFileInternal . (show :: E.SomeException -> String)) + getTmpHandle fPath = openFile fPath AppendMode `catchThrow` (ChatError . CEFileInternal . show) uniqueCombine :: FilePath -> String -> m FilePath uniqueCombine filePath fileName = tryCombine (0 :: Int) where @@ -2288,7 +2288,7 @@ agentSubscriber = do where run action = do let name = "agentSubscriber entity=" <> show e <> " entId=" <> str entId <> " msg=" <> str (aCommandTag msg) - withLock l name $ runExceptT $ action `catchError` (toView . CRChatError Nothing) + withLock l name $ runExceptT $ action `catchChatError` (toView . CRChatError Nothing) str :: StrEncoding a => a -> String str = B.unpack . strEncode @@ -2393,7 +2393,7 @@ subscribeUserConnections agentBatchSubscribe user@User {userId} = do pendingConnSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId PendingContactConnection -> m () pendingConnSubsToView rs = toView . CRPendingSubSummary user . map (uncurry PendingSubStatus) . resultsFor rs withStore_ :: String -> (DB.Connection -> User -> IO [a]) -> m [a] - withStore_ ctx a = withStoreCtx' (Just ctx) (`a` user) `catchError` \e -> toView (CRChatError (Just user) e) $> [] + withStore_ ctx a = withStoreCtx' (Just ctx) (`a` user) `catchChatError` \e -> toView (CRChatError (Just user) e) $> [] filterErrors :: [(a, Maybe ChatError)] -> [(a, ChatError)] filterErrors = mapMaybe (\(a, e_) -> (a,) <$> e_) resultsFor :: Map ConnId (Either AgentErrorType ()) -> Map ConnId a -> [(a, Maybe ChatError)] @@ -2415,36 +2415,36 @@ cleanupManager = do liftIO $ threadDelay' initialDelay stepDelay <- asks (cleanupManagerStepDelay . config) forever $ do - flip catchError (toView . CRChatError Nothing) $ do + flip catchChatError (toView . CRChatError Nothing) $ do waitChatStarted users <- withStoreCtx' (Just "cleanupManager, getUsers 1") getUsers let (us, us') = partition activeUser users forM_ us $ cleanupUser interval stepDelay forM_ us' $ cleanupUser interval stepDelay - cleanupMessages `catchError` (toView . CRChatError Nothing) + cleanupMessages `catchChatError` (toView . CRChatError Nothing) liftIO $ threadDelay' $ diffToMicroseconds interval where - runWithoutInitialDelay cleanupInterval = flip catchError (toView . CRChatError Nothing) $ do + runWithoutInitialDelay cleanupInterval = flip catchChatError (toView . CRChatError Nothing) $ do waitChatStarted users <- withStoreCtx' (Just "cleanupManager, getUsers 2") getUsers let (us, us') = partition activeUser users - forM_ us $ \u -> cleanupTimedItems cleanupInterval u `catchError` (toView . CRChatError (Just u)) - forM_ us' $ \u -> cleanupTimedItems cleanupInterval u `catchError` (toView . CRChatError (Just u)) + forM_ us $ \u -> cleanupTimedItems cleanupInterval u `catchChatError` (toView . CRChatError (Just u)) + forM_ us' $ \u -> cleanupTimedItems cleanupInterval u `catchChatError` (toView . CRChatError (Just u)) cleanupUser cleanupInterval stepDelay user = do - cleanupTimedItems cleanupInterval user `catchError` (toView . CRChatError (Just user)) + cleanupTimedItems cleanupInterval user `catchChatError` (toView . CRChatError (Just user)) liftIO $ threadDelay' stepDelay - cleanupDeletedContacts user `catchError` (toView . CRChatError (Just user)) + cleanupDeletedContacts user `catchChatError` (toView . CRChatError (Just user)) liftIO $ threadDelay' stepDelay cleanupTimedItems cleanupInterval user = do ts <- liftIO getCurrentTime let startTimedThreadCutoff = addUTCTime cleanupInterval ts timedItems <- withStoreCtx' (Just "cleanupManager, getTimedItems") $ \db -> getTimedItems db user startTimedThreadCutoff - forM_ timedItems $ \(itemRef, deleteAt) -> startTimedItemThread user itemRef deleteAt `catchError` const (pure ()) + forM_ timedItems $ \(itemRef, deleteAt) -> startTimedItemThread user itemRef deleteAt `catchChatError` const (pure ()) cleanupDeletedContacts user = do contacts <- withStore' (`getDeletedContacts` user) forM_ contacts $ \ct -> withStore' (\db -> deleteContactWithoutGroups db user ct) - `catchError` (toView . CRChatError (Just user)) + `catchChatError` (toView . CRChatError (Just user)) cleanupMessages = do ts <- liftIO getCurrentTime let cutoffTs = addUTCTime (- (30 * nominalDay)) ts @@ -2508,7 +2508,7 @@ expireChatItems user@User {userId} ttl sync = do loop :: [a] -> (a -> m ()) -> m () loop [] _ = pure () loop (a : as) process = continue $ do - process a `catchError` (toView . CRChatError (Just user)) + process a `catchChatError` (toView . CRChatError (Just user)) loop as process continue :: m () -> m () continue a = @@ -2538,7 +2538,7 @@ processAgentMessage _ connId DEL_CONN = toView $ CRAgentConnDeleted (AgentConnId connId) processAgentMessage corrId connId msg = withStore' (`getUserByAConnId` AgentConnId connId) >>= \case - Just user -> processAgentMessageConn user corrId connId msg `catchError` (toView . CRChatError (Just user)) + Just user -> processAgentMessageConn user corrId connId msg `catchChatError` (toView . CRChatError (Just user)) _ -> throwChatError $ CENoConnectionUser (AgentConnId connId) processAgentMessageNoConn :: forall m. ChatMonad m => ACommand 'Agent 'AENone -> m () @@ -2560,7 +2560,7 @@ processAgentMessageNoConn = \case processAgentMsgSndFile :: forall m. ChatMonad m => ACorrId -> SndFileId -> ACommand 'Agent 'AESndFile -> m () processAgentMsgSndFile _corrId aFileId msg = withStore' (`getUserByASndFileId` AgentSndFileId aFileId) >>= \case - Just user -> process user `catchError` (toView . CRChatError (Just user)) + Just user -> process user `catchChatError` (toView . CRChatError (Just user)) _ -> do withAgent (`xftpDeleteSndFileInternal` aFileId) throwChatError $ CENoSndFileUser $ AgentSndFileId aFileId @@ -2597,7 +2597,7 @@ processAgentMsgSndFile _corrId aFileId msg = let rfdsMemberFTs = zip rfds $ memberFTs ms extraRFDs = drop (length rfdsMemberFTs) rfds withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs) - forM_ rfdsMemberFTs $ \mt -> sendToMember mt `catchError` (toView . CRChatError (Just user)) + forM_ rfdsMemberFTs $ \mt -> sendToMember mt `catchChatError` (toView . CRChatError (Just user)) ci' <- withStore $ \db -> do liftIO $ updateCIFileStatus db user fileId CIFSSndComplete getChatItemByFileId db user fileId @@ -2649,7 +2649,7 @@ processAgentMsgSndFile _corrId aFileId msg = processAgentMsgRcvFile :: forall m. ChatMonad m => ACorrId -> RcvFileId -> ACommand 'Agent 'AERcvFile -> m () processAgentMsgRcvFile _corrId aFileId msg = withStore' (`getUserByARcvFileId` AgentRcvFileId aFileId) >>= \case - Just user -> process user `catchError` (toView . CRChatError (Just user)) + Just user -> process user `catchChatError` (toView . CRChatError (Just user)) _ -> do withAgent (`xftpDeleteRcvFile` aFileId) throwChatError $ CENoRcvFileUser $ AgentRcvFileId aFileId @@ -3004,7 +3004,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do intros <- withStore' $ \db -> createIntroductions db members m void . sendGroupMessage user gInfo members . XGrpMemNew $ memberInfo m forM_ intros $ \intro -> - processIntro intro `catchError` (toView . CRChatError (Just user)) + processIntro intro `catchChatError` (toView . CRChatError (Just user)) where processIntro intro@GroupMemberIntro {introId} = do void $ sendDirectMessage conn (XGrpMemIntro . memberInfo $ reMember intro) (GroupId groupId) @@ -3337,9 +3337,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do withStore' $ \db -> createCommand db user (Just connId) CFAckMessage withAckMessage :: ConnId -> CommandId -> MsgMeta -> m () -> m () - withAckMessage cId cmdId MsgMeta {recipient = (msgId, _)} action = + withAckMessage cId cmdId MsgMeta {recipient = (msgId, _)} action = do -- [async agent commands] command should be asynchronous, continuation is ackMsgDeliveryEvent - action `E.finally` withAgent (\a -> ackMessageAsync a (aCorrId cmdId) cId msgId) + action `chatFinally` withAgent (\a -> ackMessageAsync a (aCorrId cmdId) cId msgId) ackMsgDeliveryEvent :: Connection -> CommandId -> m () ackMsgDeliveryEvent Connection {connId} ackCmdId = @@ -3391,7 +3391,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do else do cs <- withStore' $ \db -> getMatchingContacts db user ct let probeHash = ProbeHash $ C.sha256Hash (unProbe probe) - forM_ cs $ \c -> sendProbeHash c probeHash probeId `catchError` const (pure ()) + forM_ cs $ \c -> sendProbeHash c probeHash probeId `catchChatError` \_ -> pure () where sendProbeHash :: Contact -> ProbeHash -> Int64 -> m () sendProbeHash c probeHash probeId = do @@ -3409,6 +3409,12 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct checkIntegrityCreateItem (CDDirectRcv ct) msgMeta let ExtMsgContent content fInv_ _ _ = mcExtMsgContent mc + -- Uncomment to test stuck delivery on errors - see test testDirectMessageDelete + -- case content of + -- MCText "hello 111" -> + -- UE.throwIO $ userError "#####################" + -- -- throwChatError $ CECommandError "#####################" + -- _ -> pure () if isVoice content && not (featureAllowed SCFVoice forContact ct) then do void $ newChatItem (CIRcvChatFeatureRejected CFVoice) Nothing Nothing False @@ -3580,7 +3586,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do catchCINotFound :: m a -> (SharedMsgId -> m a) -> m a catchCINotFound f handle = - f `catchError` \case + f `catchChatError` \case ChatErrorStore (SEChatItemSharedMsgIdNotFound sharedMsgId) -> handle sharedMsgId e -> throwError e @@ -4316,7 +4322,7 @@ sendFileChunkNo ft@SndFileTransfer {agentConnId = AgentConnId acId} chunkNo = do readFileChunk :: ChatMonad m => SndFileTransfer -> Integer -> m ByteString readFileChunk SndFileTransfer {fileId, filePath, chunkSize} chunkNo = do fsFilePath <- toFSFilePath filePath - read_ fsFilePath `E.catch` (throwChatError . CEFileRead filePath . (show :: E.SomeException -> String)) + read_ fsFilePath `catchThrow` (ChatError . CEFileRead filePath . show) where read_ fsFilePath = do h <- getFileHandle fileId fsFilePath sndFiles ReadMode @@ -4341,9 +4347,8 @@ appendFileChunk ft@RcvFileTransfer {fileId, fileStatus} chunkNo chunk = append_ filePath = do fsFilePath <- toFSFilePath filePath h <- getFileHandle fileId fsFilePath rcvFiles AppendMode - E.try (liftIO $ B.hPut h chunk >> hFlush h) >>= \case - Left (e :: E.SomeException) -> throwChatError . CEFileWrite fsFilePath $ show e - Right () -> withStore' $ \db -> updatedRcvFileChunkStored db ft chunkNo + liftIO (B.hPut h chunk >> hFlush h) `catchThrow` (ChatError . CEFileWrite filePath . show) + withStore' $ \db -> updatedRcvFileChunkStored db ft chunkNo getFileHandle :: ChatMonad m => Int64 -> FilePath -> (ChatController -> TVar (Map Int64 Handle)) -> IOMode -> m Handle getFileHandle fileId filePath files ioMode = do @@ -4352,7 +4357,7 @@ getFileHandle fileId filePath files ioMode = do maybe (newHandle fs) pure h_ where newHandle fs = do - h <- liftIO (openFile filePath ioMode) `E.catch` (throwChatError . CEFileInternal . (show :: E.SomeException -> String)) + h <- openFile filePath ioMode `catchThrow` (ChatError . CEFileInternal . show) atomically . modifyTVar fs $ M.insert fileId h pure h @@ -4363,7 +4368,7 @@ isFileActive fileId files = do cancelRcvFileTransfer :: ChatMonad m => User -> RcvFileTransfer -> m (Maybe ConnId) cancelRcvFileTransfer user ft@RcvFileTransfer {fileId, xftpRcvFile, rcvFileInline} = - cancel' `catchError` (\e -> toView (CRChatError (Just user) e) $> fileConnId) + cancel' `catchChatError` (\e -> toView (CRChatError (Just user) e) $> fileConnId) where cancel' = do closeFileHandle fileId rcvFiles @@ -4381,20 +4386,20 @@ cancelRcvFileTransfer user ft@RcvFileTransfer {fileId, xftpRcvFile, rcvFileInlin cancelSndFile :: ChatMonad m => User -> FileTransferMeta -> [SndFileTransfer] -> Bool -> m [ConnId] cancelSndFile user FileTransferMeta {fileId, xftpSndFile} fts sendCancel = do withStore' (\db -> updateFileCancelled db user fileId CIFSSndCancelled) - `catchError` (toView . CRChatError (Just user)) + `catchChatError` (toView . CRChatError (Just user)) case xftpSndFile of Nothing -> catMaybes <$> forM fts (\ft -> cancelSndFileTransfer user ft sendCancel) Just xsf -> do forM_ fts (\ft -> cancelSndFileTransfer user ft False) - agentXFTPDeleteSndFileRemote user xsf fileId `catchError` (toView . CRChatError (Just user)) + agentXFTPDeleteSndFileRemote user xsf fileId `catchChatError` (toView . CRChatError (Just user)) pure [] cancelSndFileTransfer :: ChatMonad m => User -> SndFileTransfer -> Bool -> m (Maybe ConnId) cancelSndFileTransfer user@User {userId} ft@SndFileTransfer {fileId, connId, agentConnId = AgentConnId acId, fileStatus, fileInline} sendCancel = if fileStatus == FSCancelled || fileStatus == FSComplete then pure Nothing - else cancel' `catchError` (\e -> toView (CRChatError (Just user) e) $> fileConnId) + else cancel' `catchChatError` (\e -> toView (CRChatError (Just user) e) $> fileConnId) where cancel' = do withStore' $ \db -> do @@ -4412,7 +4417,7 @@ closeFileHandle :: ChatMonad m => Int64 -> (ChatController -> TVar (Map Int64 Ha closeFileHandle fileId files = do fs <- asks files h_ <- atomically . stateTVar fs $ \m -> (M.lookup fileId m, M.delete fileId m) - mapM_ hClose h_ `E.catch` \(_ :: E.SomeException) -> pure () + liftIO $ mapM_ hClose h_ `catchAll_` pure () throwChatError :: ChatMonad m => ChatErrorType -> m a throwChatError = throwError . ChatError @@ -4478,7 +4483,7 @@ sendGroupMessage' user members chatMsgEvent groupId introId_ postDeliver = do msg <- createSndMessage chatMsgEvent (GroupId groupId) -- TODO collect failed deliveries into a single error forM_ (filter memberCurrent members) $ \m -> - messageMember m msg `catchError` (toView . CRChatError (Just user)) + messageMember m msg `catchChatError` (toView . CRChatError (Just user)) pure msg where messageMember m@GroupMember {groupMemberId} SndMessage {msgId, msgBody} = case memberConn m of @@ -4495,7 +4500,7 @@ sendPendingGroupMessages user GroupMember {groupMemberId, localDisplayName} conn pendingMessages <- withStore' $ \db -> getPendingGroupMessages db groupMemberId -- TODO ensure order - pending messages interleave with user input messages forM_ pendingMessages $ \pgm -> - processPendingMessage pgm `catchError` (toView . CRChatError (Just user)) + processPendingMessage pgm `catchChatError` (toView . CRChatError (Just user)) where processPendingMessage PendingGroupMessage {msgId, cmEventTag = ACMEventTag _ tag, msgBody, introId_} = do void $ deliverMessage conn tag msgBody msgId @@ -4625,12 +4630,12 @@ agentAcceptContactAsync user enableNtfs invId msg = do deleteAgentConnectionAsync :: ChatMonad m => User -> ConnId -> m () deleteAgentConnectionAsync user acId = - withAgent (`deleteConnectionAsync` acId) `catchError` (toView . CRChatError (Just user)) + withAgent (`deleteConnectionAsync` acId) `catchChatError` (toView . CRChatError (Just user)) deleteAgentConnectionsAsync :: ChatMonad m => User -> [ConnId] -> m () deleteAgentConnectionsAsync _ [] = pure () deleteAgentConnectionsAsync user acIds = - withAgent (`deleteConnectionsAsync` acIds) `catchError` (toView . CRChatError (Just user)) + withAgent (`deleteConnectionsAsync` acIds) `catchChatError` (toView . CRChatError (Just user)) agentXFTPDeleteRcvFile :: ChatMonad m => RcvFileId -> FileTransferId -> m () agentXFTPDeleteRcvFile aFileId fileId = do @@ -4803,7 +4808,7 @@ withUser' action = >>= readTVarIO >>= maybe (throwChatError CENoActiveUser) run where - run u = action u `catchError` (pure . CRChatCmdError (Just u)) + run u = action u `catchChatError` (pure . CRChatCmdError (Just u)) withUser :: ChatMonad m => (User -> m ChatResponse) -> m ChatResponse withUser action = withUser' $ \user -> diff --git a/src/Simplex/Chat/Archive.hs b/src/Simplex/Chat/Archive.hs index 7247f200ff..2444785501 100644 --- a/src/Simplex/Chat/Archive.hs +++ b/src/Simplex/Chat/Archive.hs @@ -123,7 +123,7 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D checkFile `with` fs backup `with` fs (export chatDb chatEncrypted >> export agentDb agentEncrypted) - `catchError` \e -> (restore `with` fs) >> throwError e + `catchChatError` \e -> (restore `with` fs) >> throwError e where action `with` StorageFiles {chatDb, agentDb} = action chatDb >> action agentDb backup f = copyFile f (f <> ".bak") diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 9784180e27..b3552e5028 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -8,6 +8,7 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE StrictData #-} {-# LANGUAGE TemplateHaskell #-} @@ -60,6 +61,7 @@ import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType, CorrId, import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) +import Simplex.Messaging.Util (catchAllErrors, allFinally) import System.IO (Handle) import System.Mem.Weak (Weak) import UnliftIO.STM @@ -900,6 +902,18 @@ type ChatMonad' m = (MonadUnliftIO m, MonadReader ChatController m) type ChatMonad m = (ChatMonad' m, MonadError ChatError m) +catchChatError :: ChatMonad m => m a -> (ChatError -> m a) -> m a +catchChatError = catchAllErrors mkChatError +{-# INLINE catchChatError #-} + +chatFinally :: ChatMonad m => m a -> m b -> m a +chatFinally = allFinally mkChatError +{-# INLINE chatFinally #-} + +mkChatError :: SomeException -> ChatError +mkChatError = ChatError . CEException . show +{-# INLINE mkChatError #-} + chatCmdError :: Maybe User -> String -> ChatResponse chatCmdError user = CRChatCmdError user . ChatError . CECommandError diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 28c70c03ca..dad63d64e6 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -34,6 +34,7 @@ import Simplex.Chat.Types import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, UserId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) +import Simplex.Messaging.Util (allFinally) import UnliftIO.STM -- These error type constructors must be added to mobile apps @@ -107,6 +108,14 @@ handleSQLError err e | DB.sqlError e == DB.ErrorConstraint = err | otherwise = SEInternalError $ show e +storeFinally :: ExceptT StoreError IO a -> ExceptT StoreError IO b -> ExceptT StoreError IO a +storeFinally = allFinally mkStoreError +{-# INLINE storeFinally #-} + +mkStoreError :: E.SomeException -> StoreError +mkStoreError = SEInternalError . show +{-# INLINE mkStoreError #-} + fileInfoQuery :: Query fileInfoQuery = [sql| diff --git a/src/Simplex/Chat/Terminal/Notification.hs b/src/Simplex/Chat/Terminal/Notification.hs index 693947ae68..98031fe525 100644 --- a/src/Simplex/Chat/Terminal/Notification.hs +++ b/src/Simplex/Chat/Terminal/Notification.hs @@ -6,7 +6,6 @@ module Simplex.Chat.Terminal.Notification (Notification (..), initializeNotifications) where -import Control.Exception import Control.Monad (void) import Data.List (isInfixOf) import Data.Map (Map, fromList) @@ -15,6 +14,7 @@ import Data.Maybe (fromMaybe, isJust) import Data.Text (Text) import qualified Data.Text as T import Simplex.Chat.Types +import Simplex.Messaging.Util (catchAll_) import System.Directory (createDirectoryIfMissing, doesFileExist, findExecutable, getAppUserDataDirectory) import System.FilePath (combine) import System.Info (os) @@ -39,7 +39,7 @@ noNotifications :: Notification -> IO () noNotifications _ = pure () hideException :: (a -> IO ()) -> (a -> IO ()) -hideException f a = f a `catch` \(_ :: SomeException) -> pure () +hideException f a = f a `catchAll_` pure () initLinuxNotify :: IO (Notification -> IO ()) initLinuxNotify = do diff --git a/stack.yaml b/stack.yaml index f0cc34fadd..fb0a56f1a7 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: f2657f9c0b954f952aaf381bb9b55ac34ea59ed7 + commit: 1afcefa5e7cf7c4a5e5732104105d14259be16b6 - github: kazu-yamamoto/http2 commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 # - ../direct-sqlcipher diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index beba5aeeaa..82da77d03b 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -356,6 +356,12 @@ testDirectMessageDelete = \alice bob -> do connectUsers alice bob + -- Test for exception not interrupting the delivery - uncomment lines in newContentMessage + -- alice #> "@bob hello 111" + -- bob <## "exception: user error (#####################)" + -- -- bob <## "bad chat command: #####################" + -- -- bob <# "alice> hello 111" + -- alice, bob: msg id 1 alice #> "@bob hello 🙂" bob <# "alice> hello 🙂" diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index 773a9b999f..cd493ab34e 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -10,7 +10,7 @@ import Data.List (dropWhileEnd) import Data.Maybe (fromJust, isJust) import Simplex.Chat.Store (createChatStore) import qualified Simplex.Chat.Store as Store -import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), closeSQLiteStore, createSQLiteStore, withConnection) +import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), closeSQLiteStore, createSQLiteStore) import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..), MigrationsToRun (..), toDownMigration) import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations import Simplex.Messaging.Util (ifM, whenM) @@ -53,14 +53,14 @@ testSchemaMigrations = withTmpFiles $ do putStrLn $ "down migration " <> name m let downMigr = fromJust $ toDownMigration m schema <- getSchema testDB testSchema - withConnection st (`Migrations.run` MTRUp [m]) + Migrations.run st $ MTRUp [m] schema' <- getSchema testDB testSchema schema' `shouldNotBe` schema - withConnection st (`Migrations.run` MTRDown [downMigr]) + Migrations.run st $ MTRDown [downMigr] unless (name m `elem` skipComparisonForDownMigrations) $ do schema'' <- getSchema testDB testSchema schema'' `shouldBe` schema - withConnection st (`Migrations.run` MTRUp [m]) + Migrations.run st $ MTRUp [m] schema''' <- getSchema testDB testSchema schema''' `shouldBe` schema'