diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index f333b6e77d..f166cfbff3 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -135,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)) } @@ -523,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 925ec575da..a8416af27b 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -75,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() @@ -195,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 { @@ -626,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/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/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/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) } } }