Merge branch 'master' into av/multiplatform-merged-master

This commit is contained in:
Avently
2023-07-12 22:43:43 +07:00
62 changed files with 1422 additions and 229 deletions

View File

@@ -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)")

View File

@@ -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<Int64, () -> 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<String>) -> UnreadChatItemCounts {
var i = 0
var totalBelow = 0

View File

@@ -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)")
}

View File

@@ -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(

View File

@@ -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 {

View File

@@ -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
)

View File

@@ -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())
}

View File

@@ -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 }
)

View File

@@ -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)
)
}
}

View File

@@ -126,6 +126,7 @@ struct GroupChatInfoView: View {
logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))")
}
}
.keyboardPadding()
}
private func groupInfoHeader() -> some View {

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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:

View File

@@ -0,0 +1,9 @@
//
// Keyboard.swift
// SimpleX (iOS)
//
// Created by Evgeny on 10/07/2023.
// Copyright © 2023 SimpleX Chat. All rights reserved.
//
import Foundation

View File

@@ -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
}
}
}

View File

@@ -36,7 +36,7 @@ struct AddGroupView: View {
}
}
} else {
createGroupView()
createGroupView().keyboardPadding()
}
}

View File

@@ -104,6 +104,7 @@ struct CreateProfile: View {
}
}
.padding()
.keyboardPadding()
}
func textField(_ placeholder: LocalizedStringKey, text: Binding<String>) -> some View {

View File

@@ -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

View File

@@ -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 = "<group>"; };
5CC2C0FE2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = "ru.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = "<group>"; };
5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIRcvDecryptionError.swift; sourceTree = "<group>"; };
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 = "<group>"; };
5CCAA6DB2A53713A00BAF93B /* libHSsimplex-chat-5.2.0.0-H74s0RJkRXv7ArDExYHa6i.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.2.0.0-H74s0RJkRXv7ArDExYHa6i.a"; sourceTree = "<group>"; };
5CCAA6DC2A53713A00BAF93B /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5CCAA6DD2A53713A00BAF93B /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5CCAA6DE2A53713A00BAF93B /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = "<group>"; };
5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = "<group>"; };
5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToConnectView.swift; sourceTree = "<group>"; };
@@ -422,6 +418,7 @@
5CE4407827ADB701007B033A /* EmojiItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiItemView.swift; sourceTree = "<group>"; };
5CEACCE227DE9246000BD591 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = "<group>"; };
5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardPadding.swift; sourceTree = "<group>"; };
5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToAppGroupView.swift; sourceTree = "<group>"; };
5CFA59CF286477B400863A68 /* ChatArchiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveView.swift; sourceTree = "<group>"; };
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 = "<group>"; };
644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FramedCIVoiceView.swift; sourceTree = "<group>"; };
644EFFE32937BE9700525D5B /* MarkedDeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkedDeletedItemView.swift; sourceTree = "<group>"; };
645041542A5C5748000221AD /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
645041552A5C5748000221AD /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
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 = "<group>"; };
645041572A5C5748000221AD /* libHSsimplex-chat-5.2.0.1-EEhQOsrCplxKU03XLccWe7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.2.0.1-EEhQOsrCplxKU03XLccWe7.a"; sourceTree = "<group>"; };
645041582A5C5748000221AD /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
6454036E2822A9750090DDFF /* ComposeFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeFileView.swift; sourceTree = "<group>"; };
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 = "<group>"; };
@@ -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 = "<group>";
@@ -619,6 +621,7 @@
18415DAAAD1ADBEDB0EDA852 /* VideoPlayerView.swift */,
64466DCB29FFE3E800E3D48D /* MailView.swift */,
64C3B0202A0D359700E19930 /* CustomTimePicker.swift */,
5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */,
);
path = Helpers;
sourceTree = "<group>";
@@ -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;

View File

@@ -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?

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -722,9 +722,9 @@ object ChatController {
return null
}
suspend fun apiGroupMemberInfo(groupId: Long, groupMemberId: Long): ConnectionStats? {
suspend fun apiGroupMemberInfo(groupId: Long, groupMemberId: Long): Pair<GroupMember, ConnectionStats?>? {
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<GroupMember, ConnectionStats>? {
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<GroupMember, ConnectionStats>? {
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<GroupMember, ConnectionStats>? {
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<Contact, String> {
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<RcvQueueInfo>, val sndQueuesInfo: List<SndQueueInfo>)
class ConnectionStats(
val connAgentVersion: Int,
val rcvQueuesInfo: List<RcvQueueInfo>,
val sndQueuesInfo: List<SndQueueInfo>,
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)}"

View File

@@ -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 = {},
)
}

View File

@@ -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 = { _ -> },

View File

@@ -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)

View File

@@ -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 = {},
)
}

View File

@@ -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)
}
}

View File

@@ -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 = { _, _ -> },
)

View File

@@ -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

View File

@@ -104,6 +104,7 @@
<string name="error_deleting_pending_contact_connection">Error deleting pending contact connection</string>
<string name="error_changing_address">Error changing address</string>
<string name="error_aborting_address_change">Error aborting address change</string>
<string name="error_synchronizing_connection">Error synchronizing connection</string>
<string name="error_smp_test_failed_at_step">Test failed at step %s.</string>
<string name="error_smp_test_server_auth">Server requires authorization to create queues, check password</string>
<string name="error_xftp_test_server_auth">Server requires authorization to upload, check password</string>
@@ -339,6 +340,9 @@
<string name="abort_switch_receiving_address_question">Abort changing address?</string>
<string name="abort_switch_receiving_address_desc">Address change will be aborted. Old receiving address will be used.</string>
<string name="abort_switch_receiving_address_confirm">Abort</string>
<string name="sync_connection_force_question">Renegotiate encryption?</string>
<string name="sync_connection_force_desc">The encryption is working and the new encryption agreement is not required. It may result in connection errors!</string>
<string name="sync_connection_force_confirm">Renegotiate</string>
<string name="view_security_code">View security code</string>
<string name="verify_security_code">Verify security code</string>
@@ -816,10 +820,9 @@
<string name="alert_text_msg_bad_hash">The hash of the previous message is different."</string>
<string name="alert_title_msg_bad_id">Bad message ID</string>
<string name="alert_text_msg_bad_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.</string>
<string name="alert_text_decryption_error_header">%1$d messages failed to decrypt.</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d messages failed to decrypt.</string>
<string name="alert_text_decryption_error_too_many_skipped">%1$d messages skipped.</string>
<string name="alert_text_fragment_encryption_out_of_sync_old_database">It can happen when you or your connection used the old database backup.</string>
<string name="alert_text_fragment_permanent_error_reconnect">This error is permanent for this connection, please re-connect.</string>
<string name="alert_text_fragment_please_report_to_developers">Please report it to the developers.</string>
<!-- Privacy settings -->
@@ -1065,6 +1068,17 @@
<string name="snd_conn_event_switch_queue_phase_changing_for_member">changing address for %s…</string>
<string name="snd_conn_event_switch_queue_phase_completed">you changed address</string>
<string name="snd_conn_event_switch_queue_phase_changing">changing address…</string>
<string name="conn_event_ratchet_sync_ok">encryption ok</string>
<string name="conn_event_ratchet_sync_allowed">encryption re-negotiation allowed</string>
<string name="conn_event_ratchet_sync_required">encryption re-negotiation required</string>
<string name="conn_event_ratchet_sync_started">agreeing encryption…</string>
<string name="conn_event_ratchet_sync_agreed">encryption agreed</string>
<string name="snd_conn_event_ratchet_sync_ok">encryption ok for %s</string>
<string name="snd_conn_event_ratchet_sync_allowed">encryption re-negotiation allowed for %s</string>
<string name="snd_conn_event_ratchet_sync_required">encryption re-negotiation required for %s</string>
<string name="snd_conn_event_ratchet_sync_started">agreeing encryption for %s</string>
<string name="snd_conn_event_ratchet_sync_agreed">encryption agreed for %s</string>
<string name="rcv_conn_event_verification_code_reset">security code changed</string>
<!-- GroupMemberRole -->
<string name="group_member_role_observer">observer</string>
@@ -1184,6 +1198,12 @@
<string name="network_status">Network status</string>
<string name="switch_receiving_address">Change receiving address</string>
<string name="abort_switch_receiving_address">Abort changing address</string>
<string name="fix_connection">Fix connection</string>
<string name="fix_connection_question">Fix connection?</string>
<string name="fix_connection_confirm">Fix</string>
<string name="fix_connection_not_supported_by_contact">Fix not supported by contact</string>
<string name="fix_connection_not_supported_by_group_member">Fix not supported by group member</string>
<string name="renegotiate_encryption">Renegotiate encryption</string>
<!-- AddGroupView.kt -->
<string name="create_secret_group_title">Create secret group</string>

View File

@@ -1096,7 +1096,7 @@
<string name="alert_text_msg_bad_hash">Hash předchozí zprávy se liší.</string>
<string name="alert_text_msg_bad_id">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.</string>
<string name="alert_text_decryption_error_header">%1$d zprávy se nepodařilo dešifrovat.</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d zprávy se nepodařilo dešifrovat.</string>
<string name="alert_text_decryption_error_too_many_skipped">%1$d zprývy přeskočeny.</string>
<string name="alert_text_fragment_please_report_to_developers">Nahlaste to prosím vývojářům.</string>
<string name="alert_text_fragment_permanent_error_reconnect">Tato chyba je pro toto připojení trvalá, připojte se znovu.</string>

View File

@@ -1173,7 +1173,7 @@
<string name="xftp_servers">XFTP-Server</string>
<string name="alert_text_msg_bad_id">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.</string>
<string name="alert_text_decryption_error_header">%1$d Nachrichten konnten nicht entschlüsselt werden.</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d Nachrichten konnten nicht entschlüsselt werden.</string>
<string name="alert_text_msg_bad_hash">Der Hash der vorherigen Nachricht unterscheidet sich.</string>
<string name="you_can_turn_on_lock">Sie können die SimpleX Sperre über die Einstellungen aktivieren.</string>
<string name="network_socks_proxy_settings">SOCKS-Proxy Einstellungen</string>

View File

@@ -1101,7 +1101,7 @@
<string name="alert_text_fragment_encryption_out_of_sync_old_database">Puede ocurrir cuando tu o tu contacto estáis usando una copia de seguridad antigua de la base de datos.</string>
<string name="alert_text_msg_bad_hash">El hash del mensaje anterior es diferente.</string>
<string name="alert_text_fragment_permanent_error_reconnect">El error es permanente para esta conexión, por favor vuelve a conectarte.</string>
<string name="alert_text_decryption_error_header">%1$d mensajes no pudieron ser descifrados.</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d mensajes no pudieron ser descifrados.</string>
<string name="no_spaces">¡Sin espacios!</string>
<string name="stop_file__action">Detener archivo</string>
<string name="revoke_file__message">El archivo será eliminado de los servidores.</string>

View File

@@ -1244,7 +1244,7 @@
<string name="alert_text_decryption_error_too_many_skipped"> %1$d viestit ohitettu.</string>
<string name="alert_text_msg_bad_id">Seuraavan viestin tunnus on väärä (pienempi tai yhtä suuri kuin edellisen).
\nTämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.</string>
<string name="alert_text_decryption_error_header">%1$d viestien salauksen purku epäonnistui.</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d viestien salauksen purku epäonnistui.</string>
<string name="user_unhide">Näytä</string>
<string name="you_have_to_enter_passphrase_every_time">Sinun on annettava tunnuslause aina, kun sovellus käynnistyy - sitä ei tallenneta laitteeseen.</string>
<string name="update_database_passphrase">Päivitä tietokannan tunnuslause</string>

View File

@@ -1119,7 +1119,7 @@
<string name="stop_rcv_file__title">Arrêter de recevoir le fichier \?</string>
<string name="alert_text_fragment_permanent_error_reconnect">Cette erreur est persistante pour cette connexion, veuillez vous reconnecter.</string>
<string name="la_could_not_be_verified">Vous n\'avez pas pu être vérifié·e; veuillez réessayer.</string>
<string name="alert_text_decryption_error_header">%1$d messages n\'ont pas pu être déchiffrés.</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d messages n\'ont pas pu être déchiffrés.</string>
<string name="alert_text_decryption_error_too_many_skipped">%1$d messages sautés.</string>
<string name="you_can_turn_on_lock">Vous pouvez activer SimpleX Lock dans les Paramètres.</string>
<string name="v5_0_polish_interface_descr">Merci aux utilisateurs - contribuez via Weblate !</string>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-284.5q-11.5 0-20-8.75t-8.5-20.25q0-11 8.5-19.75t20-8.75q11.5 0 20.25 8.75T509-313.5q0 11.5-8.75 20.25T480-284.5Zm2.325-145.5q-12.325 0-20.575-8.375T453.5-459v-189q0-11.75 8.425-20.125 8.426-8.375 20.75-8.375 11.825 0 20.075 8.375T511-648v189q0 12.25-8.425 20.625-8.426 8.375-20.25 8.375ZM190.5-477q0 65.183 28 118.591 28 53.409 90 93.409v-96q0-11.75 8.425-20.125 8.426-8.375 20.75-8.375 11.825 0 20.075 8.375T366-361v168.5q0 12.25-8.375 20.625T337.5-163.5H169q-12.25 0-20.625-8.425-8.375-8.426-8.375-20.75 0-11.825 8.375-20.075T169-221h101.5q-64.5-48-101-108T133-477q0-88 46-165.75T311-762.5q10-4.5 20.25-.25T346-748q4 11 .25 21.75T332.5-711q-62.5 32-102.25 94.881Q190.5-553.237 190.5-477Zm589.5-6.5q0-64.683-28.25-118.091Q723.5-655 661.5-695v95.5q0 12.25-8.425 20.625-8.426 8.375-20.25 8.375-12.325 0-20.575-8.375T604-599.5V-768q0-11.75 8.375-20.125T633-796.5h168.5q11.75 0 20.125 8.425 8.375 8.426 8.375 20.25 0 12.325-8.375 20.575T801.5-739H700q64 48 100.75 108t36.75 147.5q0 88.5-46.25 166.75T659-197q-10 5-20.25.25T624.5-212q-4.5-10.5-.75-21.5t14.25-16q62-31.5 102-94.381 40-62.882 40-139.619Z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M96.63-124.5q-8.63 0-15.282-4.125Q74.696-132.75 71.25-139q-3.917-6.1-4.333-13.55Q66.5-160 71.5-168l383.936-661.885Q460-837.5 466.25-841t13.75-3.5q7.5 0 13.75 3.5T505-830l384 662q4.5 8 4.083 15.45-.416 7.45-4.333 13.55-3.446 6.25-10.098 10.375Q872-124.5 863.37-124.5H96.63ZM146.5-182h667L480-758 146.5-182Zm337.728-57.5q12.272 0 20.522-8.478 8.25-8.478 8.25-20.75t-8.478-20.522q-8.478-8.25-20.75-8.25t-20.522 8.478q-8.25 8.478-8.25 20.75t8.478 20.522q8.478 8.25 20.75 8.25Zm.197-108.5q12.075 0 20.325-8.375T513-377v-165q0-11.675-8.463-20.088-8.463-8.412-20.212-8.412-12.325 0-20.575 8.412-8.25 8.413-8.25 20.088v165q0 12.25 8.425 20.625 8.426 8.375 20.5 8.375ZM480-470Z"/></svg>

After

Width:  |  Height:  |  Size: 774 B

View File

@@ -1097,7 +1097,7 @@
<string name="alert_text_msg_bad_id">L\'ID del messaggio successivo non è corretto (inferiore o uguale al precedente).
\nPuò accadere a causa di qualche bug o quando la connessione è compromessa.</string>
<string name="alert_text_fragment_permanent_error_reconnect">L\'errore è permanente per questa connessione, riconnettiti.</string>
<string name="alert_text_decryption_error_header">%1$d messaggi non decifrati.</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d messaggi non decifrati.</string>
<string name="alert_title_msg_bad_hash">Hash del messaggio errato</string>
<string name="alert_text_msg_bad_hash">L\'hash del messaggio precedente è diverso.</string>
<string name="alert_text_fragment_please_report_to_developers">Si prega di segnalarlo agli sviluppatori.</string>

View File

@@ -1197,7 +1197,7 @@
<string name="call_connection_via_relay">דרך ממסר</string>
<string name="icon_descr_video_off">וידאו כבוי</string>
<string name="icon_descr_video_on">וידאו פעיל</string>
<string name="alert_text_decryption_error_header">%1$d הודעות לא הצליחו לעבור פענוח.</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d הודעות לא הצליחו לעבור פענוח.</string>
<string name="you_must_use_the_most_recent_version_of_database">עליכם להשתמש בגרסה העדכנית ביותר של מסד הנתונים שלכם במכשיר אחד בלבד, אחרת אתם עלולים להפסיק לקבל הודעות מאנשי קשר מסוימים.</string>
<string name="wrong_passphrase_title">סיסמה שגויה!</string>
<string name="you_are_invited_to_group_join_to_connect_with_group_members">הוזמנתם לקבוצה. הצטרפו כדי ליצור קשר עם חברי הקבוצה.</string>

View File

@@ -1072,7 +1072,7 @@
<string name="alert_text_fragment_please_report_to_developers">開発者に報告してください。</string>
<string name="alert_text_fragment_permanent_error_reconnect">このエラーはこの接続では永続的なものです。再接続してください。</string>
<string name="v5_0_large_files_support">1GBまでのビデオとファイル</string>
<string name="alert_text_decryption_error_header">%1$d メッセージの復号化に失敗しました。</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d メッセージの復号化に失敗しました。</string>
<string name="alert_text_decryption_error_too_many_skipped">%1$d メッセージをスキップしました</string>
<string name="error_loading_smp_servers">SMP サーバーのロード中にエラーが発生しました</string>
<string name="error_saving_user_password">ユーザーパスワード保存エラー</string>

View File

@@ -1099,7 +1099,7 @@
<string name="alert_title_msg_bad_id">Onjuiste bericht ID</string>
<string name="alert_text_msg_bad_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.</string>
<string name="alert_text_decryption_error_header">%1$d-berichten konden niet worden ontsleuteld.</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d-berichten konden niet worden ontsleuteld.</string>
<string name="no_spaces">Geen spaties!</string>
<string name="stop_rcv_file__message">Het ontvangen van het bestand wordt gestopt.</string>
<string name="revoke_file__confirm">Intrekken</string>

View File

@@ -1096,7 +1096,7 @@
<string name="alert_text_msg_bad_hash">Hash poprzedniej wiadomości jest inny.</string>
<string name="alert_text_msg_bad_id">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.</string>
<string name="alert_text_decryption_error_header">Nie udało się odszyfrować %1$d wiadomości.</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">Nie udało się odszyfrować %1$d wiadomości.</string>
<string name="alert_text_fragment_permanent_error_reconnect">Ten błąd jest trwały dla tego połączenia, proszę o ponowne połączenie.</string>
<string name="alert_text_decryption_error_too_many_skipped">%1$d pominiętych wiadomości.</string>
<string name="alert_text_fragment_encryption_out_of_sync_old_database">Może się to zdarzyć, gdy Ty lub Twoje połączenie użyło starej kopii zapasowej bazy danych.</string>

View File

@@ -1088,7 +1088,7 @@
<string name="you_can_turn_on_lock">Você pode ativar o bloqueio SimpleX via Configurações.</string>
<string name="alert_title_msg_bad_hash">Hash de mensagem incorreta</string>
<string name="alert_text_msg_bad_hash">O hash da mensagem anterior é diferente.</string>
<string name="alert_text_decryption_error_header">%1$d descriptografia das mensagens falhou</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d descriptografia das mensagens falhou</string>
<string name="alert_title_msg_bad_id">ID de mensagem incorreta</string>
<string name="alert_text_msg_bad_id">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.</string>

View File

@@ -1156,7 +1156,7 @@
<string name="authentication_cancelled">Аутентификация отменена</string>
<string name="la_mode_system">Системная</string>
<string name="la_seconds">%d секунд</string>
<string name="alert_text_decryption_error_header">%1$d сообщений не удалось расшифровать.</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d сообщений не удалось расшифровать.</string>
<string name="decryption_error">Ошибка расшифровки</string>
<string name="lock_not_enabled">Блокировка SimpleX не включена!</string>
<string name="alert_title_msg_bad_hash">Ошибка хэш сообщения</string>

View File

@@ -1052,7 +1052,7 @@
<string name="v4_4_verify_connection_security">ตรวจสอบความปลอดภัยในการเชื่อมต่อ</string>
<string name="incognito_info_share">เมื่อคุณแชร์โปรไฟล์ที่ไม่ระบุตัวตนกับใครสักคน โปรไฟล์นี้จะใช้สำหรับกลุ่มที่พวกเขาเชิญคุณ</string>
<string name="contact_wants_to_connect_via_call">%1$s ต้องการเชื่อมต่อกับคุณผ่านทาง</string>
<string name="alert_text_decryption_error_header">ข้อความ %1$d ไม่สามารถ decrypt ได้</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">ข้อความ %1$d ไม่สามารถ decrypt ได้</string>
<string name="group_info_section_title_num_members">%1$s สมาชิก</string>
<string name="you_can_connect_to_simplex_chat_founder"><![CDATA[คุณสามารถ <font color="#0088ff">เชื่อมต่อกับ SimpleX Chat นักพัฒนาแอปเพื่อถามคำถามและรับการอัปเดต</font>]]></string>
<string name="you_can_share_your_address">คุณสามารถแชร์ที่อยู่ของคุณเป็นลิงก์หรือรหัสคิวอาร์ - ใคร ๆ ก็สามารถเชื่อมต่อกับคุณได้</string>

View File

@@ -874,7 +874,7 @@
<string name="show_call_on_lock_screen">Показати</string>
<string name="no_call_on_lock_screen">Вимкнути</string>
<string name="relay_server_if_necessary">Сервер ретрансляції використовується лише за необхідності. Інша сторона може спостерігати за вашою IP-адресою.</string>
<string name="alert_text_decryption_error_header">%1$d повідомлення не вдалося розшифрувати.</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d повідомлення не вдалося розшифрувати.</string>
<string name="alert_text_decryption_error_too_many_skipped">%1$d повідомлення пропущені.</string>
<string name="alert_text_fragment_please_report_to_developers">Будь ласка, повідомте про це розробникам.</string>
<string name="send_link_previews">Надіслати попередній перегляд за посиланням</string>

View File

@@ -1092,7 +1092,7 @@
<string name="la_immediately">立即</string>
<string name="alert_title_msg_bad_hash">错误消息散列</string>
<string name="alert_title_msg_bad_id">错误消息 ID</string>
<string name="alert_text_decryption_error_header">%1$d 消息解密失败。</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d 消息解密失败。</string>
<string name="alert_text_decryption_error_too_many_skipped">%1$d 已跳过消息。</string>
<string name="alert_text_fragment_permanent_error_reconnect">此错误对于此连接是永久性的,请重新连接。</string>
<string name="alert_text_fragment_encryption_out_of_sync_old_database">当您或您的连接使用旧数据库备份时,可能会发生这种情况。</string>

View File

@@ -1120,7 +1120,7 @@
<string name="la_mode_system">系統</string>
<string name="alert_text_msg_bad_id">此ID的下一則訊息是錯誤小於或等於上一則的
\n當一些錯誤出現或你的連結被破壞時會發生。</string>
<string name="alert_text_decryption_error_header">%1$d 訊息解密失敗。</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d 訊息解密失敗。</string>
<string name="network_socks_toggle_use_socks_proxy">使用SOCKS 代理伺服器</string>
<string name="your_XFTP_servers">你的 XFTP 伺服器</string>
<string name="alert_text_fragment_permanent_error_reconnect">這個連結錯誤是永久性的,請重新連接。</string>

View File

@@ -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