From 22f20a9c5f4c754a0eaaa88aa43741052b8a00be Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 19 Jun 2023 14:46:08 +0400 Subject: [PATCH] ios, android: abort switching connection (#2584) --- .../java/chat/simplex/app/model/ChatModel.kt | 1 + .../java/chat/simplex/app/model/SimpleXAPI.kt | 59 ++++++++++- .../simplex/app/views/chat/ChatInfoView.kt | 100 ++++++++++++------ .../views/chat/group/GroupMemberInfoView.kt | 58 ++++++---- .../app/src/main/res/values/strings.xml | 11 +- apps/ios/Shared/Model/SimpleXAPI.swift | 12 +++ apps/ios/Shared/Views/Chat/ChatInfoView.swift | 53 ++++++++-- .../Chat/Group/GroupMemberInfoView.swift | 29 ++++- apps/ios/SimpleXChat/APITypes.swift | 41 ++++++- apps/ios/SimpleXChat/ChatTypes.swift | 1 + 10 files changed, 292 insertions(+), 73 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index cbd83d58a4..9fcc0b14f7 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -2352,6 +2352,7 @@ sealed class SndConnEvent { enum class SwitchPhase { @SerialName("started") Started, @SerialName("confirmed") Confirmed, + @SerialName("secured") Secured, @SerialName("completed") Completed } diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index f6fa70b4ee..9bd76e81bb 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -740,7 +740,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a return when (val r = sendCmd(CC.APISwitchContact(contactId))) { is CR.CmdOk -> {} else -> { - apiErrorAlert("apiSwitchContact", generalGetString(R.string.connection_error), r) + apiErrorAlert("apiSwitchContact", generalGetString(R.string.error_changing_address), r) } } } @@ -754,6 +754,20 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } } + suspend fun apiAbortSwitchContact(contactId: Long): ConnectionStats? { + val r = sendCmd(CC.APIAbortSwitchContact(contactId)) + if (r is CR.ContactSwitchAborted) return r.connectionStats + apiErrorAlert("apiAbortSwitchContact", generalGetString(R.string.error_aborting_address_change), r) + return null + } + + suspend fun apiAbortSwitchGroupMember(groupId: Long, groupMemberId: Long): ConnectionStats? { + val r = sendCmd(CC.APIAbortSwitchGroupMember(groupId, groupMemberId)) + if (r is CR.GroupMemberSwitchAborted) return r.connectionStats + apiErrorAlert("apiAbortSwitchGroupMember", generalGetString(R.string.error_aborting_address_change), r) + return null + } + suspend fun apiGetContactCode(contactId: Long): Pair { val r = sendCmd(CC.APIGetContactCode(contactId)) if (r is CR.ContactCode) return r.contact to r.connectionCode @@ -1939,6 +1953,8 @@ sealed class CC { class APIGroupMemberInfo(val groupId: Long, val groupMemberId: Long): CC() class APISwitchContact(val contactId: Long): 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 APIGetContactCode(val contactId: Long): CC() class APIGetGroupMemberCode(val groupId: Long, val groupMemberId: Long): CC() class APIVerifyContact(val contactId: Long, val connectionCode: String?): CC() @@ -2032,6 +2048,8 @@ sealed class CC { is APIGroupMemberInfo -> "/_info #$groupId $groupMemberId" is APISwitchContact -> "/_switch @$contactId" is APISwitchGroupMember -> "/_switch #$groupId $groupMemberId" + is APIAbortSwitchContact -> "/_abort switch @$contactId" + is APIAbortSwitchGroupMember -> "/_abort switch #$groupId $groupMemberId" is APIGetContactCode -> "/_get code @$contactId" is APIGetGroupMemberCode -> "/_get code #$groupId $groupMemberId" is APIVerifyContact -> "/_verify code @$contactId" + if (connectionCode != null) " $connectionCode" else "" @@ -2120,6 +2138,8 @@ sealed class CC { is APIGroupMemberInfo -> "apiGroupMemberInfo" is APISwitchContact -> "apiSwitchContact" is APISwitchGroupMember -> "apiSwitchGroupMember" + is APIAbortSwitchContact -> "apiAbortSwitchContact" + is APIAbortSwitchGroupMember -> "apiAbortSwitchGroupMember" is APIGetContactCode -> "apiGetContactCode" is APIGetGroupMemberCode -> "apiGetGroupMemberCode" is APIVerifyContact -> "apiVerifyContact" @@ -3277,7 +3297,9 @@ sealed class CR { @Serializable @SerialName("chatItemTTL") class ChatItemTTL(val user: User, val chatItemTTL: Long? = null): CR() @Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR() @Serializable @SerialName("contactInfo") class ContactInfo(val user: User, val contact: Contact, val connectionStats: ConnectionStats, val customUserProfile: Profile? = null): CR() - @Serializable @SerialName("groupMemberInfo") class GroupMemberInfo(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val connectionStats_: ConnectionStats?): CR() + @Serializable @SerialName("groupMemberInfo") class GroupMemberInfo(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val connectionStats_: ConnectionStats? = null): 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("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() @@ -3392,6 +3414,8 @@ sealed class CR { is NetworkConfig -> "networkConfig" is ContactInfo -> "contactInfo" is GroupMemberInfo -> "groupMemberInfo" + is ContactSwitchAborted -> "contactSwitchAborted" + is GroupMemberSwitchAborted -> "groupMemberSwitchAborted" is ContactCode -> "contactCode" is GroupMemberCode -> "groupMemberCode" is ConnectionVerified -> "connectionVerified" @@ -3503,6 +3527,8 @@ sealed class CR { is NetworkConfig -> json.encodeToString(networkConfig) is ContactInfo -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}") is GroupMemberInfo -> 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 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") @@ -3634,7 +3660,34 @@ abstract class TerminalItem { } @Serializable -class ConnectionStats(val rcvServers: List?, val sndServers: List?) +class ConnectionStats(val rcvQueuesInfo: List, val sndQueuesInfo: List) + +@Serializable +class RcvQueueInfo( + val rcvServer: String, + val rcvSwitchStatus: RcvSwitchStatus?, + var canAbortSwitch: Boolean +) + +@Serializable +enum class RcvSwitchStatus { + @SerialName("switch_started") SwitchStarted, + @SerialName("sending_qadd") SendingQADD, + @SerialName("sending_quse") SendingQUSE, + @SerialName("received_message") ReceivedMessage +} + +@Serializable +class SndQueueInfo( + val sndServer: String, + val sndSwitchStatus: SndSwitchStatus? +) + +@Serializable +enum class SndSwitchStatus { + @SerialName("sending_qkey") SendingQKEY, + @SerialName("sending_qtest") SendingQTEST +} @Serializable class UserContactLinkRec(val connReqContact: String, val autoAccept: AutoAccept? = null) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt index a97d591388..00bf8d5444 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt @@ -5,11 +5,9 @@ import InfoRowEllipsis import SectionBottomSpacer import SectionDividerSpaced import SectionItemView -import SectionItemViewWithIcon import SectionSpacer import SectionTextFooter import SectionView -import TextIconSpaced import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.* @@ -46,7 +44,7 @@ import kotlinx.datetime.Clock fun ChatInfoView( chatModel: ChatModel, contact: Contact, - connStats: ConnectionStats?, + connectionStats: ConnectionStats?, customUserProfile: Profile?, localAlias: String, connectionCode: String?, @@ -54,6 +52,7 @@ fun ChatInfoView( ) { BackHandler(onBack = close) val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value } + val connStats = remember { mutableStateOf(connectionStats) } val developerTools = chatModel.controller.appPrefs.developerTools.get() if (chat != null) { val contactNetworkStatus = remember(chatModel.networkStatuses.toMap()) { @@ -62,7 +61,7 @@ fun ChatInfoView( ChatInfoLayout( chat, contact, - connStats, + connStats = connStats, contactNetworkStatus.value, customUserProfile, localAlias, @@ -82,7 +81,18 @@ fun ChatInfoView( deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) }, clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) }, switchContactAddress = { - showSwitchContactAddressAlert(chatModel, contact.contactId) + showSwitchAddressAlert(switchAddress = { + withApi { + chatModel.controller.apiSwitchContact(contact.contactId) + } + }) + }, + abortSwitchContactAddress = { + showAbortSwitchAddressAlert(abortSwitchAddress = { + withApi { + connStats.value = chatModel.controller.apiAbortSwitchContact(contact.contactId) + } + }) }, verifyClicked = { ModalManager.shared.showModalCloseable { close -> @@ -156,7 +166,7 @@ fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit fun ChatInfoLayout( chat: Chat, contact: Contact, - connStats: ConnectionStats?, + connStats: MutableState, contactNetworkStatus: NetworkStatus, customUserProfile: Profile?, localAlias: String, @@ -167,8 +177,10 @@ fun ChatInfoLayout( deleteContact: () -> Unit, clearChat: () -> Unit, switchContactAddress: () -> Unit, + abortSwitchContactAddress: () -> Unit, verifyClicked: () -> Unit, ) { + val cStats = connStats.value Column( Modifier .fillMaxWidth() @@ -209,21 +221,30 @@ fun ChatInfoLayout( } SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) { - SwitchAddressButton(switchContactAddress) - if (connStats != null) { - SectionItemView({ - AlertManager.shared.showAlertMsg( - generalGetString(R.string.network_status), - contactNetworkStatus.statusExplanation - )}) { - NetworkStatusRow(contactNetworkStatus) + SectionItemView({ + AlertManager.shared.showAlertMsg( + generalGetString(R.string.network_status), + contactNetworkStatus.statusExplanation + )}) { + NetworkStatusRow(contactNetworkStatus) + } + if (cStats != null) { + SwitchAddressButton( + disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null }, + switchAddress = switchContactAddress + ) + if (cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null }) { + AbortSwitchAddressButton( + disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null && !it.canAbortSwitch }, + abortSwitchAddress = abortSwitchContactAddress + ) } - val rcvServers = connStats.rcvServers - if (rcvServers != null && rcvServers.isNotEmpty()) { + val rcvServers = cStats.rcvQueuesInfo.map { it.rcvServer } + if (rcvServers.isNotEmpty()) { SimplexServers(stringResource(R.string.receiving_via), rcvServers) } - val sndServers = connStats.sndServers - if (sndServers != null && sndServers.isNotEmpty()) { + val sndServers = cStats.sndQueuesInfo.map { it.sndServer } + if (sndServers.isNotEmpty()) { SimplexServers(stringResource(R.string.sending_via), sndServers) } } @@ -360,7 +381,7 @@ private fun ServerImage(networkStatus: NetworkStatus) { Box(Modifier.size(18.dp)) { when (networkStatus) { is NetworkStatus.Connected -> - Icon(painterResource(R.drawable.ic_circle_filled), stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant) + Icon(painterResource(R.drawable.ic_circle_filled), stringResource(R.string.icon_descr_server_status_connected), tint = Color.Green) is NetworkStatus.Disconnected -> Icon(painterResource(R.drawable.ic_pending_filled), stringResource(R.string.icon_descr_server_status_disconnected), tint = MaterialTheme.colors.secondary) is NetworkStatus.Error -> @@ -381,9 +402,22 @@ fun SimplexServers(text: String, servers: List) { } @Composable -fun SwitchAddressButton(onClick: () -> Unit) { - SectionItemView(onClick) { - Text(stringResource(R.string.switch_receiving_address), color = MaterialTheme.colors.primary) +fun SwitchAddressButton(disabled: Boolean, switchAddress: () -> Unit) { + SectionItemView(switchAddress) { + Text( + stringResource(R.string.switch_receiving_address), + color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary + ) + } +} + +@Composable +fun AbortSwitchAddressButton(disabled: Boolean, abortSwitchAddress: () -> Unit) { + SectionItemView(abortSwitchAddress) { + Text( + stringResource(R.string.abort_switch_receiving_address), + color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary + ) } } @@ -445,20 +479,23 @@ private fun setContactAlias(contactApiId: Long, localAlias: String, chatModel: C } } -private fun showSwitchContactAddressAlert(m: ChatModel, contactId: Long) { +fun showSwitchAddressAlert(switchAddress: () -> Unit) { AlertManager.shared.showAlertDialog( title = generalGetString(R.string.switch_receiving_address_question), text = generalGetString(R.string.switch_receiving_address_desc), - confirmText = generalGetString(R.string.switch_verb), - onConfirm = { - switchContactAddress(m, contactId) - }, - destructive = true, + confirmText = generalGetString(R.string.change_verb), + onConfirm = switchAddress ) } -private fun switchContactAddress(m: ChatModel, contactId: Long) = withApi { - m.controller.apiSwitchContact(contactId) +fun showAbortSwitchAddressAlert(abortSwitchAddress: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(R.string.abort_switch_receiving_address_question), + text = generalGetString(R.string.abort_switch_receiving_address_desc), + confirmText = generalGetString(R.string.abort_switch_receiving_address_confirm), + onConfirm = abortSwitchAddress, + destructive = true, + ) } @Preview @@ -474,7 +511,7 @@ fun PreviewChatInfoLayout() { localAlias = "", connectionCode = "123", developerTools = false, - connStats = null, + connStats = remember { mutableStateOf(null) }, contactNetworkStatus = NetworkStatus.Connected(), onLocalAliasChanged = {}, customUserProfile = null, @@ -482,6 +519,7 @@ fun PreviewChatInfoLayout() { deleteContact = {}, clearChat = {}, switchContactAddress = {}, + abortSwitchContactAddress = {}, verifyClicked = {}, ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt index aa0fe05aba..cd0b1c460b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt @@ -37,7 +37,7 @@ import kotlinx.datetime.Clock fun GroupMemberInfoView( groupInfo: GroupInfo, member: GroupMember, - connStats: ConnectionStats?, + connectionStats: ConnectionStats?, connectionCode: String?, chatModel: ChatModel, close: () -> Unit, @@ -45,6 +45,7 @@ fun GroupMemberInfoView( ) { BackHandler(onBack = close) val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value } + val connStats = remember { mutableStateOf(connectionStats) } val developerTools = chatModel.controller.appPrefs.developerTools.get() if (chat != null) { val newRole = remember { mutableStateOf(member.memberRole) } @@ -98,7 +99,18 @@ fun GroupMemberInfoView( } }, switchMemberAddress = { - switchMemberAddress(chatModel, groupInfo, member) + showSwitchAddressAlert(switchAddress = { + withApi { + chatModel.controller.apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId) + } + }) + }, + abortSwitchMemberAddress = { + showAbortSwitchAddressAlert(abortSwitchAddress = { + withApi { + connStats.value = chatModel.controller.apiAbortSwitchGroupMember(groupInfo.apiId, member.groupMemberId) + } + }) }, verifyClicked = { ModalManager.shared.showModalCloseable { close -> @@ -152,7 +164,7 @@ fun removeMemberDialog(groupInfo: GroupInfo, member: GroupMember, chatModel: Cha fun GroupMemberInfoLayout( groupInfo: GroupInfo, member: GroupMember, - connStats: ConnectionStats?, + connStats: MutableState, newRole: MutableState, developerTools: Boolean, connectionCode: String?, @@ -162,8 +174,10 @@ fun GroupMemberInfoLayout( removeMember: () -> Unit, onRoleSelected: (GroupMemberRole) -> Unit, switchMemberAddress: () -> Unit, + abortSwitchMemberAddress: () -> Unit, verifyClicked: () -> Unit, ) { + val cStats = connStats.value fun knownDirectChat(contactId: Long): Chat? { val chat = getContactChat(contactId) return if (chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directOrUsed) { @@ -235,21 +249,26 @@ fun GroupMemberInfoLayout( InfoRow(stringResource(R.string.info_row_connection), connLevelDesc) } } - if (connStats != null) { + if (cStats != null) { SectionDividerSpaced() SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) { - SwitchAddressButton(switchMemberAddress) - val rcvServers = connStats.rcvServers - val sndServers = connStats.sndServers - if ((rcvServers != null && rcvServers.isNotEmpty()) || (sndServers != null && sndServers.isNotEmpty())) { - if (rcvServers != null && rcvServers.isNotEmpty()) { - SimplexServers(stringResource(R.string.receiving_via), rcvServers) - if (sndServers != null && sndServers.isNotEmpty()) { - SimplexServers(stringResource(R.string.sending_via), sndServers) - } - } else if (sndServers != null && sndServers.isNotEmpty()) { - SimplexServers(stringResource(R.string.sending_via), sndServers) - } + SwitchAddressButton( + disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null }, + switchAddress = switchMemberAddress + ) + if (cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null }) { + AbortSwitchAddressButton( + disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null && !it.canAbortSwitch }, + abortSwitchAddress = abortSwitchMemberAddress + ) + } + val rcvServers = cStats.rcvQueuesInfo.map { it.rcvServer } + if (rcvServers.isNotEmpty()) { + SimplexServers(stringResource(R.string.receiving_via), rcvServers) + } + val sndServers = cStats.sndQueuesInfo.map { it.sndServer } + if (sndServers.isNotEmpty()) { + SimplexServers(stringResource(R.string.sending_via), sndServers) } } } @@ -376,10 +395,6 @@ private fun updateMemberRoleDialog( ) } -private fun switchMemberAddress(m: ChatModel, groupInfo: GroupInfo, member: GroupMember) = withApi { - m.controller.apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId) -} - @Preview @Composable fun PreviewGroupMemberInfoLayout() { @@ -387,7 +402,7 @@ fun PreviewGroupMemberInfoLayout() { GroupMemberInfoLayout( groupInfo = GroupInfo.sampleData, member = GroupMember.sampleData, - connStats = null, + connStats = remember { mutableStateOf(null) }, newRole = remember { mutableStateOf(GroupMemberRole.Member) }, developerTools = false, connectionCode = "123", @@ -397,6 +412,7 @@ fun PreviewGroupMemberInfoLayout() { removeMember = {}, onRoleSelected = {}, switchMemberAddress = {}, + abortSwitchMemberAddress = {}, verifyClicked = {}, ) } diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 13108eb090..4130f55ac4 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -103,6 +103,7 @@ Error deleting contact request Error deleting pending contact connection Error changing address + Error aborting address change Test failed at step %s. Server requires authorization to create queues, check password Server requires authorization to upload, check password @@ -329,8 +330,11 @@ Disconnected Error Pending - Switch receiving address? - This feature is experimental! It will only work if the other client has version 4.2 installed. You should see the message in the conversation once the address change is completed – please check that you can still receive messages from this contact (or group member). + Change receiving address? + Receiving address will be changed to a different server. Address change will complete after sender comes online. + Abort changing address? + Address change will be aborted. Old receiving address will be used. + Abort View security code Verify security code @@ -1170,7 +1174,8 @@ Receiving via Sending via Network status - Switch receiving address + Change receiving address + Abort changing address Create secret group diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 263b02e37a..3881835fb8 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -488,6 +488,18 @@ func apiSwitchGroupMember(_ groupId: Int64, _ groupMemberId: Int64) async throws try await sendCommandOkResp(.apiSwitchGroupMember(groupId: groupId, groupMemberId: groupMemberId)) } +func apiAbortSwitchContact(_ contactId: Int64) throws -> ConnectionStats { + let r = chatSendCmdSync(.apiAbortSwitchContact(contactId: contactId)) + if case let .contactSwitchAborted(_, _, connectionStats) = r { return connectionStats } + throw r +} + +func apiAbortSwitchGroupMember(_ groupId: Int64, _ groupMemberId: Int64) throws -> ConnectionStats { + let r = chatSendCmdSync(.apiAbortSwitchGroupMember(groupId: groupId, groupMemberId: groupMemberId)) + if case let .groupMemberSwitchAborted(_, _, _, connectionStats) = r { return 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) } diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index b72006b3fe..847efa9e25 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -36,9 +36,8 @@ func localizedInfoRow(_ title: LocalizedStringKey, _ value: LocalizedStringKey) } } -@ViewBuilder func smpServers(_ title: LocalizedStringKey, _ servers: [String]?) -> some View { - if let servers = servers, - servers.count > 0 { +@ViewBuilder func smpServers(_ title: LocalizedStringKey, _ servers: [String]) -> some View { + if servers.count > 0 { HStack { Text(title).frame(width: 120, alignment: .leading) Button(serverHost(servers[0])) { @@ -76,6 +75,7 @@ struct ChatInfoView: View { case clearChatAlert case networkStatusAlert case switchAddressAlert + case abortSwitchAddressAlert case error(title: LocalizedStringKey, error: LocalizedStringKey = "") var id: String { @@ -84,6 +84,7 @@ struct ChatInfoView: View { case .clearChatAlert: return "clearChatAlert" case .networkStatusAlert: return "networkStatusAlert" case .switchAddressAlert: return "switchAddressAlert" + case .abortSwitchAddressAlert: return "abortSwitchAddressAlert" case let .error(title, _): return "error \(title)" } } @@ -136,12 +137,19 @@ struct ChatInfoView: View { .onTapGesture { alert = .networkStatusAlert } - Button("Change receiving address") { - alert = .switchAddressAlert - } if let connStats = connectionStats { - smpServers("Receiving via", connStats.rcvServers) - smpServers("Sending via", connStats.sndServers) + Button("Change receiving address") { + alert = .switchAddressAlert + } + .disabled(connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }) + if connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } { + Button("Abort changing address") { + alert = .abortSwitchAddressAlert + } + .disabled(connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }) + } + smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }) + smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }) } } @@ -166,6 +174,7 @@ struct ChatInfoView: View { case .clearChatAlert: return clearChatAlert() case .networkStatusAlert: return networkStatusAlert() case .switchAddressAlert: return switchAddressAlert(switchContactAddress) + case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress) case let .error(title, error): return mkAlert(title: title, message: error) } } @@ -369,13 +378,37 @@ struct ChatInfoView: View { } } } + + private func abortSwitchContactAddress() { + Task { + do { + let stats = try apiAbortSwitchContact(contact.apiId) + connectionStats = stats + } catch let error { + logger.error("abortSwitchContactAddress apiAbortSwitchContact error: \(responseError(error))") + let a = getErrorAlert(error, "Error aborting address change") + await MainActor.run { + alert = .error(title: a.title, error: a.message) + } + } + } + } } func switchAddressAlert(_ switchAddress: @escaping () -> Void) -> Alert { Alert( title: Text("Change receiving address?"), - message: Text("This feature is experimental! It will only work if the other client has version 4.2 installed. You should see the message in the conversation once the address change is completed – please check that you can still receive messages from this contact (or group member)."), - primaryButton: .destructive(Text("Change"), action: switchAddress), + message: Text("Receiving address will be changed to a different server. Address change will complete after sender comes online."), + primaryButton: .default(Text("Change"), action: switchAddress), + secondaryButton: .cancel() + ) +} + +func abortSwitchAddressAlert(_ abortSwitchAddress: @escaping () -> Void) -> Alert { + Alert( + title: Text("Abort changing address?"), + message: Text("Address change will be aborted. Old receiving address will be used."), + primaryButton: .destructive(Text("Abort"), action: abortSwitchAddress), secondaryButton: .cancel() ) } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 1f55f56071..71ef5a68d5 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -26,6 +26,7 @@ struct GroupMemberInfoView: View { case removeMemberAlert(mem: GroupMember) case changeMemberRoleAlert(mem: GroupMember, role: GroupMemberRole) case switchAddressAlert + case abortSwitchAddressAlert case connRequestSentAlert(type: ConnReqType) case error(title: LocalizedStringKey, error: LocalizedStringKey) case other(alert: Alert) @@ -35,6 +36,7 @@ struct GroupMemberInfoView: View { case .removeMemberAlert: return "removeMemberAlert" case let .changeMemberRoleAlert(_, role): return "changeMemberRoleAlert \(role.rawValue)" case .switchAddressAlert: return "switchAddressAlert" + case .abortSwitchAddressAlert: return "abortSwitchAddressAlert" case .connRequestSentAlert: return "connRequestSentAlert" case let .error(title, _): return "error \(title)" case let .other(alert): return "other \(alert)" @@ -127,8 +129,15 @@ struct GroupMemberInfoView: View { Button("Change receiving address") { alert = .switchAddressAlert } - smpServers("Receiving via", connStats.rcvServers) - smpServers("Sending via", connStats.sndServers) + .disabled(connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }) + if connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } { + Button("Abort changing address") { + alert = .abortSwitchAddressAlert + } + .disabled(connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }) + } + smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }) + smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }) } } @@ -175,6 +184,7 @@ struct GroupMemberInfoView: View { case let .removeMemberAlert(mem): return removeMemberAlert(mem) case let .changeMemberRoleAlert(mem, _): return changeMemberRoleAlert(mem) case .switchAddressAlert: return switchAddressAlert(switchMemberAddress) + case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress) 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 @@ -356,6 +366,21 @@ struct GroupMemberInfoView: View { } } } + + private func abortSwitchMemberAddress() { + Task { + do { + let stats = try apiAbortSwitchGroupMember(groupInfo.apiId, member.groupMemberId) + connectionStats = stats + } catch let error { + logger.error("abortSwitchMemberAddress apiAbortSwitchGroupMember error: \(responseError(error))") + let a = getErrorAlert(error, "Error aborting address change") + await MainActor.run { + alert = .error(title: a.title, error: a.message) + } + } + } + } } struct GroupMemberInfoView_Previews: PreviewProvider { diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index af23f7d676..212eab8ccd 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -71,6 +71,8 @@ public enum ChatCommand { case apiGroupMemberInfo(groupId: Int64, groupMemberId: Int64) case apiSwitchContact(contactId: Int64) case apiSwitchGroupMember(groupId: Int64, groupMemberId: Int64) + case apiAbortSwitchContact(contactId: Int64) + case apiAbortSwitchGroupMember(groupId: Int64, groupMemberId: Int64) case apiGetContactCode(contactId: Int64) case apiGetGroupMemberCode(groupId: Int64, groupMemberId: Int64) case apiVerifyContact(contactId: Int64, connectionCode: String?) @@ -179,6 +181,8 @@ public enum ChatCommand { case let .apiGroupMemberInfo(groupId, groupMemberId): return "/_info #\(groupId) \(groupMemberId)" case let .apiSwitchContact(contactId): return "/_switch @\(contactId)" 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 .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)" @@ -285,6 +289,8 @@ public enum ChatCommand { case .apiGroupMemberInfo: return "apiGroupMemberInfo" case .apiSwitchContact: return "apiSwitchContact" case .apiSwitchGroupMember: return "apiSwitchGroupMember" + case .apiAbortSwitchContact: return "apiAbortSwitchContact" + case .apiAbortSwitchGroupMember: return "apiAbortSwitchGroupMember" case .apiGetContactCode: return "apiGetContactCode" case .apiGetGroupMemberCode: return "apiGetGroupMemberCode" case .apiVerifyContact: return "apiVerifyContact" @@ -397,6 +403,8 @@ public enum ChatResponse: Decodable, Error { case networkConfig(networkConfig: NetCfg) case contactInfo(user: User, contact: Contact, connectionStats: ConnectionStats, customUserProfile: Profile?) case groupMemberInfo(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 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) @@ -516,6 +524,8 @@ public enum ChatResponse: Decodable, Error { case .networkConfig: return "networkConfig" case .contactInfo: return "contactInfo" case .groupMemberInfo: return "groupMemberInfo" + case .contactSwitchAborted: return "contactSwitchAborted" + case .groupMemberSwitchAborted: return "groupMemberSwitchAborted" case .contactCode: return "contactCode" case .groupMemberCode: return "groupMemberCode" case .connectionVerified: return "connectionVerified" @@ -633,7 +643,9 @@ public enum ChatResponse: Decodable, Error { case let .chatItemTTL(u, chatItemTTL): return withUser(u, String(describing: chatItemTTL)) case let .networkConfig(networkConfig): return String(describing: networkConfig) case let .contactInfo(u, contact, connectionStats, customUserProfile): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))\ncustomUserProfile: \(String(describing: customUserProfile))") - case let .groupMemberInfo(u, groupInfo, member, connectionStats_): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats_: \(String(describing: connectionStats_)))") + case let .groupMemberInfo(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 .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)") @@ -1083,8 +1095,31 @@ public struct ChatSettings: Codable { } public struct ConnectionStats: Codable { - public var rcvServers: [String]? - public var sndServers: [String]? + public var rcvQueuesInfo: [RcvQueueInfo] + public var sndQueuesInfo: [SndQueueInfo] +} + +public struct RcvQueueInfo: Codable { + public var rcvServer: String + public var rcvSwitchStatus: RcvSwitchStatus? + public var canAbortSwitch: Bool +} + +public enum RcvSwitchStatus: String, Codable { + case switchStarted = "switch_started" + case sendingQADD = "sending_qadd" + case sendingQUSE = "sending_quse" + case receivedMessage = "received_message" +} + +public struct SndQueueInfo: Codable { + public var sndServer: String + public var sndSwitchStatus: SndSwitchStatus? +} + +public enum SndSwitchStatus: String, Codable { + case sendingQKEY = "sending_qkey" + case sendingQTEST = "sending_qtest" } public struct UserContactLink: Decodable { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index ca60f31c87..58d1642f5e 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -3067,6 +3067,7 @@ public enum SndConnEvent: Decodable { public enum SwitchPhase: String, Decodable { case started case confirmed + case secured case completed }