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 7612ec830..8a64c39e9 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 @@ -1726,6 +1726,18 @@ class CIFile( is CIFileStatus.RcvComplete -> true } + val cancellable: Boolean = when (fileStatus) { + is CIFileStatus.SndStored -> fileProtocol != FileProtocol.XFTP // TODO true - enable when XFTP send supports cancel + is CIFileStatus.SndTransfer -> fileProtocol != FileProtocol.XFTP // TODO true + is CIFileStatus.SndComplete -> false + is CIFileStatus.SndCancelled -> false + is CIFileStatus.RcvInvitation -> false + is CIFileStatus.RcvAccepted -> true + is CIFileStatus.RcvTransfer -> true + is CIFileStatus.RcvCancelled -> false + is CIFileStatus.RcvComplete -> false + } + companion object { fun getSample( fileId: Long = 1, 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 8448475a5..ebc0c7e22 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 @@ -998,6 +998,25 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } } + suspend fun cancelFile(user: User, fileId: Long) { + val chatItem = apiCancelFile(fileId) + if (chatItem != null) { + chatItemSimpleUpdate(user, chatItem) + } + } + + suspend fun apiCancelFile(fileId: Long): AChatItem? { + val r = sendCmd(CC.CancelFile(fileId)) + return when (r) { + is CR.SndFileCancelled -> r.chatItem + is CR.RcvFileCancelled -> r.chatItem + else -> { + Log.d(TAG, "apiCancelFile bad response: ${r.responseType} ${r.details}") + null + } + } + } + suspend fun apiNewGroup(p: GroupProfile): GroupInfo? { val userId = kotlin.runCatching { currentUserId("apiNewGroup") }.getOrElse { return null } val r = sendCmd(CC.ApiNewGroup(userId, p)) @@ -1394,14 +1413,12 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a if (active(r.user)) { chatModel.updateGroup(r.toGroup) } - is CR.MemberRole -> - if (active(r.user)) { - chatModel.updateGroup(r.groupInfo) - } is CR.RcvFileStart -> chatItemSimpleUpdate(r.user, r.chatItem) is CR.RcvFileComplete -> chatItemSimpleUpdate(r.user, r.chatItem) + is CR.RcvFileSndCancelled -> + chatItemSimpleUpdate(r.user, r.chatItem) is CR.RcvFileProgressXFTP -> chatItemSimpleUpdate(r.user, r.chatItem) is CR.SndFileStart -> @@ -1419,6 +1436,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a removeFile(appContext, fileName) } } + is CR.SndFileRcvCancelled -> + chatItemSimpleUpdate(r.user, r.chatItem) is CR.SndFileProgressXFTP -> chatItemSimpleUpdate(r.user, r.chatItem) is CR.CallInvitation -> { @@ -1870,6 +1889,7 @@ sealed class CC { class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC() class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC() class ReceiveFile(val fileId: Long, val inline: Boolean?): CC() + class CancelFile(val fileId: Long): CC() class ShowVersion(): CC() val cmdString: String get() = when (this) { @@ -1953,6 +1973,7 @@ sealed class CC { is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}" is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}" is ReceiveFile -> if (inline == null) "/freceive $fileId" else "/freceive $fileId inline=${onOff(inline)}" + is CancelFile -> "/fcancel $fileId" is ShowVersion -> "/version" } @@ -2037,6 +2058,7 @@ sealed class CC { is ApiChatRead -> "apiChatRead" is ApiChatUnread -> "apiChatUnread" is ReceiveFile -> "receiveFile" + is CancelFile -> "cancelFile" is ShowVersion -> "showVersion" } @@ -3046,13 +3068,14 @@ sealed class CR { @Serializable @SerialName("rcvFileAcceptedSndCancelled") class RcvFileAcceptedSndCancelled(val user: User, val rcvFileTransfer: RcvFileTransfer): CR() @Serializable @SerialName("rcvFileStart") class RcvFileStart(val user: User, val chatItem: AChatItem): CR() @Serializable @SerialName("rcvFileComplete") class RcvFileComplete(val user: User, val chatItem: AChatItem): CR() + @Serializable @SerialName("rcvFileCancelled") class RcvFileCancelled(val user: User, val chatItem: AChatItem, val rcvFileTransfer: RcvFileTransfer): CR() + @Serializable @SerialName("rcvFileSndCancelled") class RcvFileSndCancelled(val user: User, val chatItem: AChatItem, val rcvFileTransfer: RcvFileTransfer): CR() @Serializable @SerialName("rcvFileProgressXFTP") class RcvFileProgressXFTP(val user: User, val chatItem: AChatItem, val receivedSize: Long, val totalSize: Long): CR() // sending file events @Serializable @SerialName("sndFileStart") class SndFileStart(val user: User, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() @Serializable @SerialName("sndFileComplete") class SndFileComplete(val user: User, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() - @Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() + @Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val user: User, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List): CR() @Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val user: User, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() - @Serializable @SerialName("sndGroupFileCancelled") class SndGroupFileCancelled(val user: User, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List): CR() @Serializable @SerialName("sndFileProgressXFTP") class SndFileProgressXFTP(val user: User, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sentSize: Long, val totalSize: Long): CR() @Serializable @SerialName("callInvitation") class CallInvitation(val callInvitation: RcvCallInvitation): CR() @Serializable @SerialName("callOffer") class CallOffer(val user: User, val contact: Contact, val callType: CallType, val offer: WebRTCSession, val sharedKey: String? = null, val askConfirmation: Boolean): CR() @@ -3150,12 +3173,13 @@ sealed class CR { is RcvFileAccepted -> "rcvFileAccepted" is RcvFileStart -> "rcvFileStart" is RcvFileComplete -> "rcvFileComplete" + is RcvFileCancelled -> "rcvFileCancelled" + is RcvFileSndCancelled -> "rcvFileSndCancelled" is RcvFileProgressXFTP -> "rcvFileProgressXFTP" is SndFileCancelled -> "sndFileCancelled" is SndFileComplete -> "sndFileComplete" is SndFileRcvCancelled -> "sndFileRcvCancelled" is SndFileStart -> "sndFileStart" - is SndGroupFileCancelled -> "sndGroupFileCancelled" is SndFileProgressXFTP -> "sndFileProgressXFTP" is CallInvitation -> "callInvitation" is CallOffer -> "callOffer" @@ -3255,12 +3279,13 @@ sealed class CR { is RcvFileAccepted -> withUser(user, json.encodeToString(chatItem)) is RcvFileStart -> withUser(user, json.encodeToString(chatItem)) is RcvFileComplete -> withUser(user, json.encodeToString(chatItem)) + is RcvFileCancelled -> withUser(user, json.encodeToString(chatItem)) + is RcvFileSndCancelled -> withUser(user, json.encodeToString(chatItem)) is RcvFileProgressXFTP -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\nreceivedSize: $receivedSize\ntotalSize: $totalSize") is SndFileCancelled -> json.encodeToString(chatItem) is SndFileComplete -> withUser(user, json.encodeToString(chatItem)) is SndFileRcvCancelled -> withUser(user, json.encodeToString(chatItem)) is SndFileStart -> withUser(user, json.encodeToString(chatItem)) - is SndGroupFileCancelled -> withUser(user, json.encodeToString(chatItem)) is SndFileProgressXFTP -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\nsentSize: $sentSize\ntotalSize: $totalSize") is CallInvitation -> "contact: ${callInvitation.contact.id}\ncallType: $callInvitation.callType\nsharedKey: ${callInvitation.sharedKey ?: ""}" is CallOffer -> withUser(user, "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}\naskConfirmation: $askConfirmation\noffer: ${json.encodeToString(offer)}") diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index 9558f6254..4f7eb7fe5 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -230,10 +230,10 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { } }, receiveFile = { fileId -> - val user = chatModel.currentUser.value - if (user != null) { - withApi { chatModel.controller.receiveFile(user, fileId) } - } + withApi { chatModel.controller.receiveFile(user, fileId) } + }, + cancelFile = { fileId -> + withApi { chatModel.controller.cancelFile(user, fileId) } }, joinGroup = { groupId -> withApi { chatModel.controller.apiJoinGroup(groupId) } @@ -313,6 +313,7 @@ fun ChatLayout( loadPrevMessages: (ChatInfo) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, receiveFile: (Long) -> Unit, + cancelFile: (Long) -> Unit, joinGroup: (Long) -> Unit, startCall: (CallMediaType) -> Unit, acceptCall: (Contact) -> Unit, @@ -357,7 +358,7 @@ fun ChatLayout( ChatItemsList( chat, unreadCount, composeState, chatItems, searchValue, useLinkPreviews, linkMode, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage, - receiveFile, joinGroup, acceptCall, acceptFeature, markRead, setFloatingButton, onComposed, + receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, markRead, setFloatingButton, onComposed, ) } } @@ -530,6 +531,7 @@ fun BoxWithConstraintsScope.ChatItemsList( loadPrevMessages: (ChatInfo) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, receiveFile: (Long) -> Unit, + cancelFile: (Long) -> Unit, joinGroup: (Long) -> Unit, acceptCall: (Contact) -> Unit, acceptFeature: (Contact, ChatFeature, Int?) -> Unit, @@ -638,11 +640,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, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem) + 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) } } else { Box(Modifier.padding(start = 104.dp, end = 12.dp).then(swipeableModifier)) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem) + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem) } } } else { // direct message @@ -653,7 +655,7 @@ fun BoxWithConstraintsScope.ChatItemsList( end = if (sent) 12.dp else 76.dp, ).then(swipeableModifier) ) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem) + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem) } } @@ -1060,6 +1062,7 @@ fun PreviewChatLayout() { loadPrevMessages = { _ -> }, deleteMessage = { _, _ -> }, receiveFile = {}, + cancelFile = {}, joinGroup = {}, startCall = {}, acceptCall = { _ -> }, @@ -1119,6 +1122,7 @@ fun PreviewGroupChatLayout() { loadPrevMessages = { _ -> }, deleteMessage = { _, _ -> }, receiveFile = {}, + cancelFile = {}, joinGroup = {}, startCall = {}, acceptCall = { _ -> }, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index 432fb728f..997a88c9a 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -43,6 +43,7 @@ fun ChatItemView( linkMode: SimplexLinkMode, deleteMessage: (Long, CIDeleteMode) -> Unit, receiveFile: (Long) -> Unit, + cancelFile: (Long) -> Unit, joinGroup: (Long) -> Unit, acceptCall: (Contact) -> Unit, scrollToItem: (Long) -> Unit, @@ -168,6 +169,9 @@ fun ChatItemView( } ) } + if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancellable) { + CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile) + } if (!(live && cItem.meta.isLive)) { DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage) } @@ -270,6 +274,23 @@ fun ChatItemView( } } +@Composable +fun CancelFileItemAction( + fileId: Long, + showMenu: MutableState, + cancelFile: (Long) -> Unit +) { + ItemAction( + stringResource(R.string.cancel_verb), + Icons.Outlined.Close, + onClick = { + showMenu.value = false + cancelFileAlertDialog(fileId, cancelFile = cancelFile) + }, + color = Color.Red + ) +} + @Composable fun DeleteItemAction( cItem: ChatItem, @@ -323,6 +344,18 @@ fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Colo } } +fun cancelFileAlertDialog(fileId: Long, cancelFile: (Long) -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(R.string.cancel_file__question), + text = generalGetString(R.string.file_transfer_will_be_cancelled_warning), + confirmText = generalGetString(R.string.confirm_verb), + destructive = true, + onConfirm = { + cancelFile(fileId) + } + ) +} + fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) { AlertManager.shared.showAlertDialogButtons( title = generalGetString(R.string.delete_message__question), @@ -383,6 +416,7 @@ fun PreviewChatItemView() { composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, deleteMessage = { _, _ -> }, receiveFile = {}, + cancelFile = {}, joinGroup = {}, acceptCall = { _ -> }, scrollToItem = {}, @@ -403,6 +437,7 @@ fun PreviewChatItemViewDeletedContent() { composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, deleteMessage = { _, _ -> }, receiveFile = {}, + cancelFile = {}, joinGroup = {}, acceptCall = { _ -> }, scrollToItem = {}, diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 6e0ea8f02..100389d6c 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -191,6 +191,8 @@ Delete member message? The message will be deleted for all members. The message will be marked as moderated for all members. + Cancel file transfer? + File transfer will be cancelled. If it\'s in progress it will be stoppped. Delete for me For everyone diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index ceb7c16ad..dc1a69e25 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -750,6 +750,23 @@ func apiReceiveFile(fileId: Int64, inline: Bool? = nil) async -> AChatItem? { return nil } +func cancelFile(user: User, fileId: Int64) async { + if let chatItem = await apiCancelFile(fileId: fileId) { + DispatchQueue.main.async { chatItemSimpleUpdate(user, chatItem) } + } +} + +func apiCancelFile(fileId: Int64) async -> AChatItem? { + let r = await chatSendCmd(.cancelFile(fileId: fileId)) + switch r { + case let .sndFileCancelled(_, chatItem, _, _) : return chatItem + case let .rcvFileCancelled(_, chatItem, _) : return chatItem + default: + logger.error("apiCancelFile error: \(String(describing: r))") + return nil + } +} + func networkErrorAlert(_ r: ChatResponse) -> Bool { let am = AlertManager.shared switch r { @@ -1321,6 +1338,8 @@ func processReceivedMsg(_ res: ChatResponse) async { chatItemSimpleUpdate(user, aChatItem) case let .rcvFileComplete(user, aChatItem): chatItemSimpleUpdate(user, aChatItem) + case let .rcvFileSndCancelled(user, aChatItem, _): + chatItemSimpleUpdate(user, aChatItem) case let .rcvFileProgressXFTP(user, aChatItem, _, _): chatItemSimpleUpdate(user, aChatItem) case let .sndFileStart(user, aChatItem, _): @@ -1334,6 +1353,8 @@ func processReceivedMsg(_ res: ChatResponse) async { let fileName = cItem.file?.filePath { removeFile(fileName) } + case let .sndFileRcvCancelled(user, aChatItem, _): + chatItemSimpleUpdate(user, aChatItem) case let .sndFileProgressXFTP(user, aChatItem, _, _, _): chatItemSimpleUpdate(user, aChatItem) case let .callInvitation(invitation): diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 8d189bd4d..1524a1c29 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -489,6 +489,11 @@ struct ChatView: View { if revealed { menu.append(hideUIAction()) } + if ci.meta.itemDeleted == nil, + let file = ci.file, + file.cancellable { + menu.append(cancelFileUIAction(file.fileId)) + } if !live || !ci.meta.isLive { menu.append(deleteUIAction()) } @@ -579,6 +584,27 @@ struct ChatView: View { } } + private func cancelFileUIAction(_ fileId: Int64) -> UIAction { + UIAction( + title: NSLocalizedString("Cancel", comment: "chat item action"), + image: UIImage(systemName: "xmark"), + attributes: [.destructive] + ) { _ in + AlertManager.shared.showAlert(Alert( + title: Text("Cancel file transfer?"), + message: Text("File transfer will be cancelled. If it's in progress it will be stoppped."), + primaryButton: .destructive(Text("Confirm")) { + Task { + if let user = ChatModel.shared.currentUser { + await cancelFile(user: user, fileId: fileId) + } + } + }, + secondaryButton: .cancel() + )) + } + } + private func hideUIAction() -> UIAction { UIAction( title: NSLocalizedString("Hide", comment: "chat item action"), diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 069c65290..48429538a 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -100,6 +100,7 @@ public enum ChatCommand { case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) case receiveFile(fileId: Int64, inline: Bool?) + case cancelFile(fileId: Int64) case showVersion case string(String) @@ -205,6 +206,7 @@ public enum ChatCommand { return "/freceive \(fileId) inline=\(onOff(inline))" } return "/freceive \(fileId)" + case let .cancelFile(fileId): return "/fcancel \(fileId)" case .showVersion: return "/version" case let .string(str): return str } @@ -300,6 +302,7 @@ public enum ChatCommand { case .apiChatRead: return "apiChatRead" case .apiChatUnread: return "apiChatUnread" case .receiveFile: return "receiveFile" + case .cancelFile: return "cancelFile" case .showVersion: return "showVersion" case .string: return "console command" } @@ -448,12 +451,13 @@ public enum ChatResponse: Decodable, Error { case rcvFileStart(user: User, chatItem: AChatItem) case rcvFileProgressXFTP(user: User, chatItem: AChatItem, receivedSize: Int64, totalSize: Int64) case rcvFileComplete(user: User, chatItem: AChatItem) + case rcvFileCancelled(user: User, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer) + case rcvFileSndCancelled(user: User, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer) // sending file events case sndFileStart(user: User, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) case sndFileComplete(user: User, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) - case sndFileCancelled(chatItem: AChatItem, sndFileTransfer: SndFileTransfer) + case sndFileCancelled(user: User, chatItem: AChatItem, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer]) case sndFileRcvCancelled(user: User, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) - case sndGroupFileCancelled(user: User, chatItem: AChatItem, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer]) case sndFileProgressXFTP(user: User, chatItem: AChatItem, fileTransferMeta: FileTransferMeta, sentSize: Int64, totalSize: Int64) case callInvitation(callInvitation: RcvCallInvitation) case callOffer(user: User, contact: Contact, callType: CallType, offer: WebRTCSession, sharedKey: String?, askConfirmation: Bool) @@ -557,11 +561,12 @@ public enum ChatResponse: Decodable, Error { case .rcvFileStart: return "rcvFileStart" case .rcvFileProgressXFTP: return "rcvFileProgressXFTP" case .rcvFileComplete: return "rcvFileComplete" + case .rcvFileCancelled: return "rcvFileCancelled" + case .rcvFileSndCancelled: return "rcvFileSndCancelled" case .sndFileStart: return "sndFileStart" case .sndFileComplete: return "sndFileComplete" case .sndFileCancelled: return "sndFileCancelled" case .sndFileRcvCancelled: return "sndFileRcvCancelled" - case .sndGroupFileCancelled: return "sndGroupFileCancelled" case .sndFileProgressXFTP: return "sndFileProgressXFTP" case .callInvitation: return "callInvitation" case .callOffer: return "callOffer" @@ -668,11 +673,12 @@ public enum ChatResponse: Decodable, Error { case let .rcvFileStart(u, chatItem): return withUser(u, String(describing: chatItem)) case let .rcvFileProgressXFTP(u, chatItem, receivedSize, totalSize): return withUser(u, "chatItem: \(String(describing: chatItem))\nreceivedSize: \(receivedSize)\ntotalSize: \(totalSize)") case let .rcvFileComplete(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .rcvFileCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .rcvFileSndCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileStart(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .sndFileCancelled(chatItem, _): return String(describing: chatItem) + case let .sndFileCancelled(u, chatItem, _, _): return withUser(u, String(describing: chatItem)) case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .sndGroupFileCancelled(u, chatItem, _, _): return withUser(u, String(describing: chatItem)) case let .sndFileProgressXFTP(u, chatItem, _, sentSize, totalSize): return withUser(u, "chatItem: \(String(describing: chatItem))\nsentSize: \(sentSize)\ntotalSize: \(totalSize)") case let .callInvitation(inv): return String(describing: inv) case let .callOffer(u, contact, callType, offer, sharedKey, askConfirmation): return withUser(u, "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")\naskConfirmation: \(askConfirmation)\noffer: \(String(describing: offer))") diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 3b3b4acd2..eb33b77d3 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2236,6 +2236,22 @@ public struct CIFile: Decodable { } } } + + public var cancellable: Bool { + get { + switch self.fileStatus { + case .sndStored: return self.fileProtocol != .xftp // TODO true - enable when XFTP send supports cancel + case .sndTransfer: return self.fileProtocol != .xftp // TODO true + case .sndComplete: return false + case .sndCancelled: return false + case .rcvInvitation: return false + case .rcvAccepted: return true + case .rcvTransfer: return true + case .rcvCancelled: return false + case .rcvComplete: return false + } + } + } } public enum FileProtocol: String, Decodable { diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 10ac7c64d..20b7c999c 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1383,7 +1383,9 @@ processChatCommand = \case withChatLock "cancelFile" . procCmd $ withStore (\db -> getFileTransfer db user fileId) >>= \case FTSnd ftm@FileTransferMeta {cancelled} fts - | cancelled -> throwChatError $ CEFileAlreadyCancelled fileId + | cancelled -> throwChatError $ CEFileCancel fileId "file already cancelled" + | not (null fts) && all (\SndFileTransfer {fileStatus = s} -> s == FSComplete || s == FSCancelled) fts -> + throwChatError $ CEFileCancel fileId "file transfer is complete" | otherwise -> do fileAgentConnIds <- cancelSndFile user ftm fts True deleteAgentConnectionsAsync user fileAgentConnIds @@ -1398,8 +1400,9 @@ processChatCommand = \case _ -> throwChatError $ CEFileInternal "invalid chat ref for file transfer" ci <- withStore $ \db -> getChatItemByFileId db user fileId pure $ CRSndFileCancelled user ci ftm fts - FTRcv ftr@RcvFileTransfer {cancelled} - | cancelled -> throwChatError $ CEFileAlreadyCancelled fileId + FTRcv ftr@RcvFileTransfer {cancelled, fileStatus} + | cancelled -> throwChatError $ CEFileCancel fileId "file already cancelled" + | rcvFileComplete fileStatus -> throwChatError $ CEFileCancel fileId "file transfer is complete" | otherwise -> do cancelRcvFileTransfer user ftr >>= mapM_ (deleteAgentConnectionAsync user) ci <- withStore $ \db -> getChatItemByFileId db user fileId @@ -2281,48 +2284,50 @@ processAgentMsgSndFile _corrId aFileId msg = where process :: User -> m () process user = do - fileId <- withStore $ \db -> getXFTPSndFileDBId db user $ AgentSndFileId aFileId + (ft@FileTransferMeta {fileId, cancelled}, sfts) <- withStore $ \db -> do + fileId <- getXFTPSndFileDBId db user $ AgentSndFileId aFileId + getSndFileTransfer db user fileId case msg of - SFPROG sndProgress sndTotal -> do - let status = CIFSSndTransfer {sndProgress, sndTotal} - (ci, ft) <- withStore $ \db -> do - liftIO $ updateCIFileStatus db user fileId status - ft <- getFileTransferMeta db user fileId - (,ft) <$> getChatItemByFileId db user fileId - toView $ CRSndFileProgressXFTP user ci ft sndProgress sndTotal - SFDONE _sndDescr rfds -> do - ci@(AChatItem _ d cInfo _ci@ChatItem {meta = CIMeta {itemSharedMsgId = msgId_, itemDeleted}}) <- - withStore $ \db -> getChatItemByFileId db user fileId - case (msgId_, itemDeleted) of - (Just sharedMsgId, Nothing) -> do - (ft, sfts) <- withStore $ \db -> getSndFileTransfer db user fileId - when (length rfds < length sfts) $ throwChatError $ CEInternalError "not enough XFTP file descriptions to send" - -- TODO either update database status or move to SFPROG - toView $ CRSndFileProgressXFTP user ci ft 1 1 - case (rfds, sfts, d, cInfo) of - (rfd : _, sft : _, SMDSnd, DirectChat ct) -> do - msgDeliveryId <- sendFileDescription sft rfd sharedMsgId $ sendDirectContactMessage ct - withStore' $ \db -> updateSndFTDeliveryXFTP db sft msgDeliveryId - (_, _, SMDSnd, GroupChat g@GroupInfo {groupId}) -> do - ms <- withStore' $ \db -> getGroupMembers db user g - forM_ (zip rfds $ memberFTs ms) $ \mt -> sendToMember mt `catchError` (toView . CRChatError (Just user)) - -- TODO update database status and send event to view CRSndFileCompleteXFTP - pure () - where - memberFTs :: [GroupMember] -> [(Connection, SndFileTransfer)] - memberFTs ms = M.elems $ M.intersectionWith (,) (M.fromList mConns') (M.fromList sfts') - where - mConns' = mapMaybe useMember ms - sfts' = mapMaybe (\sft@SndFileTransfer {groupMemberId} -> (,sft) <$> groupMemberId) sfts - useMember GroupMember {groupMemberId, activeConn = Just conn@Connection {connStatus}} - | (connStatus == ConnReady || connStatus == ConnSndReady) && not (connDisabled conn) = Just (groupMemberId, conn) - | otherwise = Nothing - useMember _ = Nothing - sendToMember :: (ValidFileDescription 'FRecipient, (Connection, SndFileTransfer)) -> m () - sendToMember (rfd, (conn, sft)) = - void $ sendFileDescription sft rfd sharedMsgId $ \msg' -> sendDirectMessage conn msg' $ GroupId groupId - _ -> pure () - _ -> pure () -- TODO error? + SFPROG sndProgress sndTotal -> + unless cancelled $ do + let status = CIFSSndTransfer {sndProgress, sndTotal} + ci <- withStore $ \db -> do + liftIO $ updateCIFileStatus db user fileId status + getChatItemByFileId db user fileId + toView $ CRSndFileProgressXFTP user ci ft sndProgress sndTotal + SFDONE _sndDescr rfds -> + unless cancelled $ do + ci@(AChatItem _ d cInfo _ci@ChatItem {meta = CIMeta {itemSharedMsgId = msgId_, itemDeleted}}) <- + withStore $ \db -> getChatItemByFileId db user fileId + case (msgId_, itemDeleted) of + (Just sharedMsgId, Nothing) -> do + when (length rfds < length sfts) $ throwChatError $ CEInternalError "not enough XFTP file descriptions to send" + -- TODO either update database status or move to SFPROG + toView $ CRSndFileProgressXFTP user ci ft 1 1 + case (rfds, sfts, d, cInfo) of + (rfd : _, sft : _, SMDSnd, DirectChat ct) -> do + msgDeliveryId <- sendFileDescription sft rfd sharedMsgId $ sendDirectContactMessage ct + withStore' $ \db -> updateSndFTDeliveryXFTP db sft msgDeliveryId + (_, _, SMDSnd, GroupChat g@GroupInfo {groupId}) -> do + ms <- withStore' $ \db -> getGroupMembers db user g + forM_ (zip rfds $ memberFTs ms) $ \mt -> sendToMember mt `catchError` (toView . CRChatError (Just user)) + -- TODO update database status and send event to view CRSndFileCompleteXFTP + pure () + where + memberFTs :: [GroupMember] -> [(Connection, SndFileTransfer)] + memberFTs ms = M.elems $ M.intersectionWith (,) (M.fromList mConns') (M.fromList sfts') + where + mConns' = mapMaybe useMember ms + sfts' = mapMaybe (\sft@SndFileTransfer {groupMemberId} -> (,sft) <$> groupMemberId) sfts + useMember GroupMember {groupMemberId, activeConn = Just conn@Connection {connStatus}} + | (connStatus == ConnReady || connStatus == ConnSndReady) && not (connDisabled conn) = Just (groupMemberId, conn) + | otherwise = Nothing + useMember _ = Nothing + sendToMember :: (ValidFileDescription 'FRecipient, (Connection, SndFileTransfer)) -> m () + sendToMember (rfd, (conn, sft)) = + void $ sendFileDescription sft rfd sharedMsgId $ \msg' -> sendDirectMessage conn msg' $ GroupId groupId + _ -> pure () + _ -> pure () -- TODO error? where sendFileDescription :: SndFileTransfer -> ValidFileDescription 'FRecipient -> SharedMsgId -> (ChatMsgEvent 'Json -> m (SndMessage, Int64)) -> m Int64 sendFileDescription sft rfd msgId sendMsg = do @@ -2348,28 +2353,31 @@ processAgentMsgRcvFile _corrId aFileId msg = where process :: User -> m () process user = do - fileId <- withStore (`getXFTPRcvFileDBId` AgentRcvFileId aFileId) + ft@RcvFileTransfer {fileId, cancelled} <- withStore $ \db -> do + fileId <- getXFTPRcvFileDBId db $ AgentRcvFileId aFileId + getRcvFileTransfer db user fileId case msg of - RFPROG rcvProgress rcvTotal -> do - let status = CIFSRcvTransfer {rcvProgress, rcvTotal} - ci <- withStore $ \db -> do - liftIO $ updateCIFileStatus db user fileId status - getChatItemByFileId db user fileId - toView $ CRRcvFileProgressXFTP user ci rcvProgress rcvTotal - RFDONE xftpPath -> do - ft <- withStore $ \db -> getRcvFileTransfer db user fileId - case liveRcvFileTransferPath ft of - Nothing -> throwChatError $ CEInternalError "no target path for received XFTP file" - Just targetPath -> do - fsTargetPath <- toFSFilePath targetPath - renameFile xftpPath fsTargetPath - ci <- withStore $ \db -> do - liftIO $ do - updateRcvFileStatus db fileId FSComplete - updateCIFileStatus db user fileId CIFSRcvComplete - getChatItemByFileId db user fileId - agentXFTPDeleteRcvFile user aFileId fileId - toView $ CRRcvFileComplete user ci + RFPROG rcvProgress rcvTotal -> + unless cancelled $ do + let status = CIFSRcvTransfer {rcvProgress, rcvTotal} + ci <- withStore $ \db -> do + liftIO $ updateCIFileStatus db user fileId status + getChatItemByFileId db user fileId + toView $ CRRcvFileProgressXFTP user ci rcvProgress rcvTotal + RFDONE xftpPath -> + unless cancelled $ do + case liveRcvFileTransferPath ft of + Nothing -> throwChatError $ CEInternalError "no target path for received XFTP file" + Just targetPath -> do + fsTargetPath <- toFSFilePath targetPath + renameFile xftpPath fsTargetPath + ci <- withStore $ \db -> do + liftIO $ do + updateRcvFileStatus db fileId FSComplete + updateCIFileStatus db user fileId CIFSRcvComplete + getChatItemByFileId db user fileId + agentXFTPDeleteRcvFile user aFileId fileId + toView $ CRRcvFileComplete user ci RFERR _e -> do -- update chat item status -- send status to view @@ -2757,7 +2765,11 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do cancelSndFileTransfer user ft True >>= mapM_ (deleteAgentConnectionAsync user) case err of SMP SMP.AUTH -> unless (fileStatus == FSCancelled) $ do - ci <- withStore $ \db -> getChatItemByFileId db user fileId + ci <- withStore $ \db -> do + getChatRefByFileId db user fileId >>= \case + ChatRef CTDirect _ -> liftIO $ updateFileCancelled db user fileId CIFSSndCancelled + _ -> pure () + getChatItemByFileId db user fileId toView $ CRSndFileRcvCancelled user ci ft _ -> throwChatError $ CEFileSend fileId err MSG meta _ _ -> do diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 74ed4dca8..3de890d79 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -770,7 +770,7 @@ data ChatErrorType | CEFileNotFound {message :: String} | CEFileAlreadyReceiving {message :: String} | CEFileCancelled {message :: String} - | CEFileAlreadyCancelled {fileId :: FileTransferId} + | CEFileCancel {fileId :: FileTransferId, message :: String} | CEFileAlreadyExists {filePath :: FilePath} | CEFileRead {filePath :: FilePath, message :: String} | CEFileWrite {filePath :: FilePath, message :: String} diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 36ce9e863..3c17570ca 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -1614,6 +1614,11 @@ instance ToJSON RcvFileStatus where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RFS" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RFS" +rcvFileComplete :: RcvFileStatus -> Bool +rcvFileComplete = \case + RFSComplete _ -> True + _ -> False + data RcvFileInfo = RcvFileInfo { filePath :: FilePath, connId :: Maybe Int64, diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 8c6f16408..717c26948 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1274,7 +1274,7 @@ viewChatError logLevel = \case CEFileNotFound f -> ["file not found: " <> plain f] CEFileAlreadyReceiving f -> ["file is already being received: " <> plain f] CEFileCancelled f -> ["file cancelled: " <> plain f] - CEFileAlreadyCancelled fileId -> ["file already cancelled: " <> sShow fileId] + CEFileCancel fileId e -> ["error cancelling file " <> sShow fileId <> ": " <> sShow e] CEFileAlreadyExists f -> ["file already exists: " <> plain f] CEFileRead f e -> ["cannot read file " <> plain f, sShow e] CEFileWrite f e -> ["cannot write file " <> plain f, sShow e] diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index 9d631838d..98fe18e41 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -275,6 +275,7 @@ testFileRcvCancel = alice <## "bob cancelled receiving file 1 (test.jpg)" alice ##> "/fs 1" alice <## "sending file 1 (test.jpg) cancelled: bob" + alice <## "file transfer cancelled" ] checkPartialTransfer "test.jpg" @@ -606,6 +607,7 @@ testFilesFoldersImageRcvDelete = alice <## "bob cancelled receiving file 1 (test.jpg)" alice ##> "/fs 1" alice <## "sending file 1 (test.jpg) cancelled: bob" + alice <## "file transfer cancelled" testSendImageWithTextAndQuote :: HasCallStack => FilePath -> IO () testSendImageWithTextAndQuote =