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 863a0ab475..adda558d29 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 @@ -1738,23 +1738,32 @@ class CIFile( is CIFileStatus.SndTransfer -> true is CIFileStatus.SndComplete -> true is CIFileStatus.SndCancelled -> true + is CIFileStatus.SndError -> true is CIFileStatus.RcvInvitation -> false is CIFileStatus.RcvAccepted -> false is CIFileStatus.RcvTransfer -> false is CIFileStatus.RcvCancelled -> false is CIFileStatus.RcvComplete -> true + is CIFileStatus.RcvError -> false } - 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 + val cancelAction: CancelAction? = when (fileStatus) { + is CIFileStatus.SndStored -> sndCancelAction + is CIFileStatus.SndTransfer -> sndCancelAction + is CIFileStatus.SndComplete -> + if (fileProtocol == FileProtocol.XFTP) { + revokeCancelAction + } else { + null + } + is CIFileStatus.SndCancelled -> null + is CIFileStatus.SndError -> null + is CIFileStatus.RcvInvitation -> null + is CIFileStatus.RcvAccepted -> rcvCancelAction + is CIFileStatus.RcvTransfer -> rcvCancelAction + is CIFileStatus.RcvCancelled -> null + is CIFileStatus.RcvComplete -> null + is CIFileStatus.RcvError -> null } companion object { @@ -1769,6 +1778,44 @@ class CIFile( } } +@Serializable +class CancelAction( + val uiActionId: Int, + val alert: AlertInfo +) + +@Serializable +class AlertInfo( + val titleId: Int, + val messageId: Int, + val confirmId: Int +) + +private val sndCancelAction: CancelAction = CancelAction( + uiActionId = R.string.stop_file__action, + alert = AlertInfo( + titleId = R.string.stop_snd_file__title, + messageId = R.string.stop_snd_file__message, + confirmId = R.string.stop_file__confirm + ) +) +private val revokeCancelAction: CancelAction = CancelAction( + uiActionId = R.string.revoke_file__action, + alert = AlertInfo( + titleId = R.string.revoke_file__title, + messageId = R.string.revoke_file__message, + confirmId = R.string.revoke_file__confirm + ) +) +private val rcvCancelAction: CancelAction = CancelAction( + uiActionId = R.string.stop_file__action, + alert = AlertInfo( + titleId = R.string.stop_rcv_file__title, + messageId = R.string.stop_rcv_file__message, + confirmId = R.string.stop_file__confirm + ) +) + @Serializable enum class FileProtocol { @SerialName("smp") SMP, @@ -1781,11 +1828,13 @@ sealed class CIFileStatus { @Serializable @SerialName("sndTransfer") class SndTransfer(val sndProgress: Long, val sndTotal: Long): CIFileStatus() @Serializable @SerialName("sndComplete") object SndComplete: CIFileStatus() @Serializable @SerialName("sndCancelled") object SndCancelled: CIFileStatus() + @Serializable @SerialName("sndError") object SndError: CIFileStatus() @Serializable @SerialName("rcvInvitation") object RcvInvitation: CIFileStatus() @Serializable @SerialName("rcvAccepted") object RcvAccepted: CIFileStatus() @Serializable @SerialName("rcvTransfer") class RcvTransfer(val rcvProgress: Long, val rcvTotal: Long): CIFileStatus() @Serializable @SerialName("rcvComplete") object RcvComplete: CIFileStatus() @Serializable @SerialName("rcvCancelled") object RcvCancelled: CIFileStatus() + @Serializable @SerialName("rcvError") object RcvError: CIFileStatus() } @Suppress("SERIALIZER_TYPE_INCOMPATIBLE") 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 e3e79b28bb..1fcb96c8aa 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 @@ -1442,6 +1442,10 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } is CR.RcvFileProgressXFTP -> chatItemSimpleUpdate(r.user, r.chatItem) + is CR.RcvFileError -> { + chatItemSimpleUpdate(r.user, r.chatItem) + cleanupFile(r.chatItem) + } is CR.SndFileStart -> chatItemSimpleUpdate(r.user, r.chatItem) is CR.SndFileComplete -> { @@ -1458,6 +1462,10 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a chatItemSimpleUpdate(r.user, r.chatItem) cleanupFile(r.chatItem) } + is CR.SndFileError -> { + chatItemSimpleUpdate(r.user, r.chatItem) + cleanupFile(r.chatItem) + } is CR.CallInvitation -> { chatModel.callManager.reportNewIncomingCall(r.callInvitation) } @@ -3132,6 +3140,7 @@ sealed class 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() + @Serializable @SerialName("rcvFileError") class RcvFileError(val user: User, val chatItem: AChatItem): 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() @@ -3139,6 +3148,8 @@ sealed class CR { @Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val user: User, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() @Serializable @SerialName("sndFileProgressXFTP") class SndFileProgressXFTP(val user: User, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sentSize: Long, val totalSize: Long): CR() @Serializable @SerialName("sndFileCompleteXFTP") class SndFileCompleteXFTP(val user: User, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta): CR() + @Serializable @SerialName("sndFileError") class SndFileError(val user: User, val chatItem: AChatItem): CR() + // call events @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() @Serializable @SerialName("callAnswer") class CallAnswer(val user: User, val contact: Contact, val answer: WebRTCSession): CR() @@ -3238,12 +3249,14 @@ sealed class CR { is RcvFileCancelled -> "rcvFileCancelled" is RcvFileSndCancelled -> "rcvFileSndCancelled" is RcvFileProgressXFTP -> "rcvFileProgressXFTP" + is RcvFileError -> "rcvFileError" is SndFileCancelled -> "sndFileCancelled" is SndFileComplete -> "sndFileComplete" is SndFileRcvCancelled -> "sndFileRcvCancelled" is SndFileStart -> "sndFileStart" is SndFileProgressXFTP -> "sndFileProgressXFTP" is SndFileCompleteXFTP -> "sndFileCompleteXFTP" + is SndFileError -> "sndFileError" is CallInvitation -> "callInvitation" is CallOffer -> "callOffer" is CallAnswer -> "callAnswer" @@ -3345,12 +3358,14 @@ sealed class CR { 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 RcvFileError -> withUser(user, json.encodeToString(chatItem)) 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 SndFileProgressXFTP -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\nsentSize: $sentSize\ntotalSize: $totalSize") is SndFileCompleteXFTP -> withUser(user, json.encodeToString(chatItem)) + is SndFileError -> withUser(user, json.encodeToString(chatItem)) 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)}") is CallAnswer -> withUser(user, "contact: ${contact.id}\nanswer: ${json.encodeToString(answer)}") diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt index 5f448fdcdb..01694f6c41 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt @@ -156,6 +156,7 @@ fun CIFileView( } is CIFileStatus.SndComplete -> fileIcon(innerIcon = Icons.Filled.Check) is CIFileStatus.SndCancelled -> fileIcon(innerIcon = Icons.Outlined.Close) + is CIFileStatus.SndError -> fileIcon(innerIcon = Icons.Outlined.Close) is CIFileStatus.RcvInvitation -> if (fileSizeValid()) fileIcon(innerIcon = Icons.Outlined.ArrowDownward, color = MaterialTheme.colors.primary) @@ -170,6 +171,7 @@ fun CIFileView( } is CIFileStatus.RcvComplete -> fileIcon() is CIFileStatus.RcvCancelled -> fileIcon(innerIcon = Icons.Outlined.Close) + is CIFileStatus.RcvError -> fileIcon(innerIcon = Icons.Outlined.Close) } } else { fileIcon() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt index eb261a4ff0..70cc608d61 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt @@ -82,10 +82,12 @@ fun CIImageView( is CIFileStatus.SndTransfer -> progressIndicator() is CIFileStatus.SndComplete -> fileIcon(Icons.Filled.Check, R.string.icon_descr_image_snd_complete) is CIFileStatus.SndCancelled -> fileIcon(Icons.Outlined.Close, R.string.icon_descr_file) + is CIFileStatus.SndError -> fileIcon(Icons.Outlined.Close, R.string.icon_descr_file) is CIFileStatus.RcvInvitation -> fileIcon(Icons.Outlined.ArrowDownward, R.string.icon_descr_asked_to_receive) is CIFileStatus.RcvAccepted -> fileIcon(Icons.Outlined.MoreHoriz, R.string.icon_descr_waiting_for_image) is CIFileStatus.RcvTransfer -> progressIndicator() is CIFileStatus.RcvCancelled -> fileIcon(Icons.Outlined.Close, R.string.icon_descr_file) + is CIFileStatus.RcvError -> fileIcon(Icons.Outlined.Close, R.string.icon_descr_file) else -> {} } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVideoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVideoView.kt index 5ba59bce5d..cc976e242e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVideoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVideoView.kt @@ -300,6 +300,7 @@ private fun loadingIndicator(file: CIFile?) { } is CIFileStatus.SndComplete -> fileIcon(Icons.Filled.Check, R.string.icon_descr_video_snd_complete) is CIFileStatus.SndCancelled -> fileIcon(Icons.Outlined.Close, R.string.icon_descr_file) + is CIFileStatus.SndError -> fileIcon(Icons.Outlined.Close, R.string.icon_descr_file) is CIFileStatus.RcvInvitation -> fileIcon(Icons.Outlined.ArrowDownward, R.string.icon_descr_video_asked_to_receive) is CIFileStatus.RcvAccepted -> fileIcon(Icons.Outlined.MoreHoriz, R.string.icon_descr_waiting_for_video) is CIFileStatus.RcvTransfer -> @@ -309,6 +310,7 @@ private fun loadingIndicator(file: CIFile?) { progressIndicator() } is CIFileStatus.RcvCancelled -> fileIcon(Icons.Outlined.Close, R.string.icon_descr_file) + is CIFileStatus.RcvError -> fileIcon(Icons.Outlined.Close, R.string.icon_descr_file) else -> {} } } 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 326c81b9f5..d6a969715a 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 @@ -168,8 +168,8 @@ fun ChatItemView( } ) } - if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancellable) { - CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile) + if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null) { + CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) } if (!(live && cItem.meta.isLive)) { DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage) @@ -278,14 +278,15 @@ fun ChatItemView( fun CancelFileItemAction( fileId: Long, showMenu: MutableState, - cancelFile: (Long) -> Unit + cancelFile: (Long) -> Unit, + cancelAction: CancelAction ) { ItemAction( - stringResource(R.string.cancel_verb), + stringResource(cancelAction.uiActionId), Icons.Outlined.Close, onClick = { showMenu.value = false - cancelFileAlertDialog(fileId, cancelFile = cancelFile) + cancelFileAlertDialog(fileId, cancelFile = cancelFile, cancelAction = cancelAction) }, color = Color.Red ) @@ -344,11 +345,11 @@ fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Colo } } -fun cancelFileAlertDialog(fileId: Long, cancelFile: (Long) -> Unit) { +fun cancelFileAlertDialog(fileId: Long, cancelFile: (Long) -> Unit, cancelAction: CancelAction) { 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), + title = generalGetString(cancelAction.alert.titleId), + text = generalGetString(cancelAction.alert.messageId), + confirmText = generalGetString(cancelAction.alert.confirmId), destructive = true, onConfirm = { cancelFile(fileId) diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index f703b12af0..40be55bf04 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -218,10 +218,18 @@ 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 + Stop file + Stop sending file? + Sending file will be stopped. + Stop receiving file? + Receiving file will be stopped. + Stop + Revoke file + Revoke file? + File will be deleted from servers. + Revoke edited diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index f4e8fe8c02..c8a3ba5316 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1333,6 +1333,9 @@ func processReceivedMsg(_ res: ChatResponse) async { cleanupFile(aChatItem) case let .rcvFileProgressXFTP(user, aChatItem, _, _): chatItemSimpleUpdate(user, aChatItem) + case let .rcvFileError(user, aChatItem): + chatItemSimpleUpdate(user, aChatItem) + cleanupFile(aChatItem) case let .sndFileStart(user, aChatItem, _): chatItemSimpleUpdate(user, aChatItem) case let .sndFileComplete(user, aChatItem, _): @@ -1346,6 +1349,9 @@ func processReceivedMsg(_ res: ChatResponse) async { case let .sndFileCompleteXFTP(user, aChatItem, _): chatItemSimpleUpdate(user, aChatItem) cleanupFile(aChatItem) + case let .sndFileError(user, aChatItem): + chatItemSimpleUpdate(user, aChatItem) + cleanupFile(aChatItem) case let .callInvitation(invitation): m.callInvitations[invitation.contact.id] = invitation activateCall(invitation) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 8e6e4af4f1..134c9679d9 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -55,11 +55,13 @@ struct CIFileView: View { case .sndTransfer: return false case .sndComplete: return false case .sndCancelled: return false + case .sndError: return false case .rcvInvitation: return true case .rcvAccepted: return true case .rcvTransfer: return false case .rcvComplete: return true case .rcvCancelled: return false + case .rcvError: return false } } return false @@ -130,6 +132,7 @@ struct CIFileView: View { } case .sndComplete: fileIcon("doc.fill", innerIcon: "checkmark", innerIconSize: 10) case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) + case .sndError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) case .rcvInvitation: if fileSizeValid() { fileIcon("arrow.down.doc.fill", color: .accentColor) @@ -145,6 +148,7 @@ struct CIFileView: View { } case .rcvComplete: fileIcon("doc.fill") case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) + case .rcvError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) } } else { fileIcon("doc.fill") diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index b6f347467b..a9eadc5aa2 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -93,10 +93,12 @@ struct CIImageView: View { case .sndTransfer: progressView() case .sndComplete: fileIcon("checkmark", 10, 13) case .sndCancelled: fileIcon("xmark", 10, 13) + case .sndError: fileIcon("xmark", 10, 13) case .rcvInvitation: fileIcon("arrow.down", 10, 13) case .rcvAccepted: fileIcon("ellipsis", 14, 11) case .rcvTransfer: progressView() case .rcvCancelled: fileIcon("xmark", 10, 13) + case .rcvError: fileIcon("xmark", 10, 13) default: EmptyView() } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index 9aaf7d662b..6c7d023cbb 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -199,38 +199,33 @@ struct CIVideoView: View { case .xftp: progressCircle(sndProgress, sndTotal) case .smp: progressView() } - case .sndComplete: - Image(systemName: "checkmark") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 10, height: 10) - .foregroundColor(.white) - .padding(13) - case .rcvInvitation: - Image(systemName: "arrow.down") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) - .foregroundColor(.white) - .padding(11) - case .rcvAccepted: - Image(systemName: "ellipsis") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) - .foregroundColor(.white) - .padding(11) + case .sndComplete: fileIcon("checkmark", 10, 13) + case .sndCancelled: fileIcon("xmark", 10, 13) + case .sndError: fileIcon("xmark", 10, 13) + case .rcvInvitation: fileIcon("arrow.down", 10, 13) + case .rcvAccepted: fileIcon("ellipsis", 14, 11) case let .rcvTransfer(rcvProgress, rcvTotal): if file.fileProtocol == .xftp && rcvProgress < rcvTotal { progressCircle(rcvProgress, rcvTotal) } else { progressView() } + case .rcvCancelled: fileIcon("xmark", 10, 13) + case .rcvError: fileIcon("xmark", 10, 13) default: EmptyView() } } } + private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View { + Image(systemName: icon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: size, height: size) + .foregroundColor(.white) + .padding(padding) + } + private func progressView() -> some View { ProgressView() .progressViewStyle(.circular) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index 07d64d9b13..374d69a57c 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -108,11 +108,13 @@ struct VoiceMessagePlayer: View { case .sndTransfer: playbackButton() case .sndComplete: playbackButton() case .sndCancelled: playbackButton() + case .sndError: playbackButton() case .rcvInvitation: loadingIcon() case .rcvAccepted: loadingIcon() case .rcvTransfer: loadingIcon() case .rcvComplete: playbackButton() case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel)) + case .rcvError: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel)) } } else { playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel)) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 9c16badf6f..b008d420a8 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -496,8 +496,8 @@ struct ChatView: View { } if ci.meta.itemDeleted == nil, let file = ci.file, - file.cancellable { - menu.append(cancelFileUIAction(file.fileId, sent: ci.chatDir.sent)) + let cancelAction = file.cancelAction { + menu.append(cancelFileUIAction(file.fileId, cancelAction)) } if !live || !ci.meta.isLive { menu.append(deleteUIAction()) @@ -589,15 +589,16 @@ struct ChatView: View { } } - private func cancelFileUIAction(_ fileId: Int64, sent: Bool) -> UIAction { - UIAction( - title: NSLocalizedString("Stop file", comment: "chat item action"), - image: UIImage(systemName: "xmark") + private func cancelFileUIAction(_ fileId: Int64, _ cancelAction: CancelAction) -> UIAction { + return UIAction( + title: cancelAction.uiAction, + image: UIImage(systemName: "xmark"), + attributes: [.destructive] ) { _ in AlertManager.shared.showAlert(Alert( - title: Text(sent ? "Stop sending file?" : "Stop receiving file?"), - message: Text(sent ? "Sending file will be stopped." : "Receiving file will be stopped."), - primaryButton: .destructive(Text("Stop")) { + title: Text(cancelAction.alert.title), + message: Text(cancelAction.alert.message), + primaryButton: .destructive(Text(cancelAction.alert.confirm)) { Task { if let user = ChatModel.shared.currentUser { await cancelFile(user: user, fileId: fileId) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 857221cff1..a672081e37 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -453,6 +453,7 @@ public enum ChatResponse: Decodable, Error { case rcvFileComplete(user: User, chatItem: AChatItem) case rcvFileCancelled(user: User, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer) case rcvFileSndCancelled(user: User, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer) + case rcvFileError(user: User, chatItem: AChatItem) // sending file events case sndFileStart(user: User, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) case sndFileComplete(user: User, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) @@ -460,6 +461,8 @@ public enum ChatResponse: Decodable, Error { case sndFileRcvCancelled(user: User, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) case sndFileProgressXFTP(user: User, chatItem: AChatItem, fileTransferMeta: FileTransferMeta, sentSize: Int64, totalSize: Int64) case sndFileCompleteXFTP(user: User, chatItem: AChatItem, fileTransferMeta: FileTransferMeta) + case sndFileError(user: User, chatItem: AChatItem) + // call events case callInvitation(callInvitation: RcvCallInvitation) case callOffer(user: User, contact: Contact, callType: CallType, offer: WebRTCSession, sharedKey: String?, askConfirmation: Bool) case callAnswer(user: User, contact: Contact, answer: WebRTCSession) @@ -564,12 +567,14 @@ public enum ChatResponse: Decodable, Error { case .rcvFileComplete: return "rcvFileComplete" case .rcvFileCancelled: return "rcvFileCancelled" case .rcvFileSndCancelled: return "rcvFileSndCancelled" + case .rcvFileError: return "rcvFileError" case .sndFileStart: return "sndFileStart" case .sndFileComplete: return "sndFileComplete" case .sndFileCancelled: return "sndFileCancelled" case .sndFileRcvCancelled: return "sndFileRcvCancelled" case .sndFileProgressXFTP: return "sndFileProgressXFTP" case .sndFileCompleteXFTP: return "sndFileCompleteXFTP" + case .sndFileError: return "sndFileError" case .callInvitation: return "callInvitation" case .callOffer: return "callOffer" case .callAnswer: return "callAnswer" @@ -677,12 +682,14 @@ public enum ChatResponse: Decodable, Error { 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 .rcvFileError(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(u, chatItem, _, _): return withUser(u, String(describing: chatItem)) case let .sndFileRcvCancelled(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 .sndFileCompleteXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndFileError(u, chatItem): return withUser(u, String(describing: chatItem)) 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))") case let .callAnswer(u, contact, answer): return withUser(u, "contact: \(contact.id)\nanswer: \(String(describing: answer))") diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index a89ef26fed..a449af1c3c 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2281,32 +2281,79 @@ public struct CIFile: Decodable { case .sndTransfer: return true case .sndComplete: return true case .sndCancelled: return true + case .sndError: return true case .rcvInvitation: return false case .rcvAccepted: return false case .rcvTransfer: return false case .rcvCancelled: return false case .rcvComplete: return true + case .rcvError: return false } } } - public var cancellable: Bool { + public var cancelAction: CancelAction? { 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 + case .sndStored: return sndCancelAction + case .sndTransfer: return sndCancelAction + case .sndComplete: + if self.fileProtocol == .xftp { + return revokeCancelAction + } else { + return nil + } + case .sndCancelled: return nil + case .sndError: return nil + case .rcvInvitation: return nil + case .rcvAccepted: return rcvCancelAction + case .rcvTransfer: return rcvCancelAction + case .rcvCancelled: return nil + case .rcvComplete: return nil + case .rcvError: return nil } } } } +public struct CancelAction { + public var uiAction: String + public var alert: AlertInfo +} + +public struct AlertInfo { + public var title: LocalizedStringKey + public var message: LocalizedStringKey + public var confirm: LocalizedStringKey +} + +private var sndCancelAction = CancelAction( + uiAction: NSLocalizedString("Stop file", comment: "cancel file action"), + alert: AlertInfo( + title: "Stop sending file?", + message: "Sending file will be stopped.", + confirm: "Stop" + ) +) + +private var revokeCancelAction = CancelAction( + uiAction: NSLocalizedString("Revoke file", comment: "cancel file action"), + alert: AlertInfo( + title: "Revoke file?", + message: "File will be deleted from servers.", + confirm: "Revoke" + ) +) + +private var rcvCancelAction = CancelAction( + uiAction: NSLocalizedString("Stop file", comment: "cancel file action"), + alert: AlertInfo( + title: "Stop receiving file?", + message: "Receiving file will be stopped.", + confirm: "Stop" + ) +) + public enum FileProtocol: String, Decodable { case smp = "smp" case xftp = "xftp" @@ -2317,11 +2364,13 @@ public enum CIFileStatus: Decodable { case sndTransfer(sndProgress: Int64, sndTotal: Int64) case sndComplete case sndCancelled + case sndError case rcvInvitation case rcvAccepted case rcvTransfer(rcvProgress: Int64, rcvTotal: Int64) case rcvComplete case rcvCancelled + case rcvError var id: String { switch self { @@ -2329,11 +2378,13 @@ public enum CIFileStatus: Decodable { case let .sndTransfer(sndProgress, sndTotal): return "sndTransfer \(sndProgress) \(sndTotal)" case .sndComplete: return "sndComplete" case .sndCancelled: return "sndCancelled" + case .sndError: return "sndError" case .rcvInvitation: return "rcvInvitation" case .rcvAccepted: return "rcvAccepted" case let .rcvTransfer(rcvProgress, rcvTotal): return "rcvTransfer \(rcvProgress) \(rcvTotal)" case .rcvComplete: return "rcvComplete" case .rcvCancelled: return "rcvCancelled" + case .rcvError: return "rcvError" } } } diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 621662892d..9d2c44fb7d 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1395,9 +1395,9 @@ processChatCommand = \case CancelFile fileId -> withUser $ \user@User {userId} -> withChatLock "cancelFile" . procCmd $ withStore (\db -> getFileTransfer db user fileId) >>= \case - FTSnd ftm@FileTransferMeta {cancelled} fts + FTSnd ftm@FileTransferMeta {xftpSndFile, cancelled} fts | cancelled -> throwChatError $ CEFileCancel fileId "file already cancelled" - | not (null fts) && all (\SndFileTransfer {fileStatus = s} -> s == FSComplete || s == FSCancelled) fts -> + | not (null fts) && all fileCancelledOrCompleteSMP fts -> throwChatError $ CEFileCancel fileId "file transfer is complete" | otherwise -> do fileAgentConnIds <- cancelSndFile user ftm fts True @@ -1413,6 +1413,9 @@ processChatCommand = \case _ -> throwChatError $ CEFileInternal "invalid chat ref for file transfer" ci <- withStore $ \db -> getChatItemByFileId db user fileId pure $ CRSndFileCancelled user ci ftm fts + where + fileCancelledOrCompleteSMP SndFileTransfer {fileStatus = s} = + s == FSCancelled || (s == FSComplete && isNothing xftpSndFile) FTRcv ftr@RcvFileTransfer {cancelled, fileStatus, xftpRcvFile} | cancelled -> throwChatError $ CEFileCancel fileId "file already cancelled" | rcvFileComplete fileStatus -> throwChatError $ CEFileCancel fileId "file transfer is complete" @@ -2333,62 +2336,63 @@ processAgentMsgSndFile _corrId aFileId msg = (ft@FileTransferMeta {fileId, cancelled}, sfts) <- withStore $ \db -> do fileId <- getXFTPSndFileDBId db user $ AgentSndFileId aFileId getSndFileTransfer db user fileId - case msg of - SFPROG sndProgress sndTotal -> - unless cancelled $ do - let status = CIFSSndTransfer {sndProgress, sndTotal} + unless cancelled $ case msg of + SFPROG sndProgress sndTotal -> 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 -> do + withStore' $ \db -> setSndFTPrivateSndDescr db user fileId (fileDescrText sndDescr) + 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 : extraRFDs, sft : _, SMDSnd, DirectChat ct) -> do + withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs) + msgDeliveryId <- sendFileDescription sft rfd sharedMsgId $ sendDirectContactMessage ct + withStore' $ \db -> updateSndFTDeliveryXFTP db sft msgDeliveryId + agentXFTPDeleteSndFileInternal user aFileId + (_, _, SMDSnd, GroupChat g@GroupInfo {groupId}) -> do + ms <- withStore' $ \db -> getGroupMembers db user g + let rfdsMemberFTs = zip rfds $ memberFTs ms + extraRFDs = drop (length rfdsMemberFTs) rfds + withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs) + forM_ rfdsMemberFTs $ \mt -> sendToMember mt `catchError` (toView . CRChatError (Just user)) + ci' <- withStore $ \db -> do + liftIO $ updateCIFileStatus db user fileId CIFSSndComplete + getChatItemByFileId db user fileId + agentXFTPDeleteSndFileInternal user aFileId + toView $ CRSndFileCompleteXFTP user ci' ft + 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? + SFERR e + | temporaryAgentError e -> + throwChatError $ CEXFTPSndFile fileId (AgentSndFileId aFileId) e + | otherwise -> do ci <- withStore $ \db -> do - liftIO $ updateCIFileStatus db user fileId status + liftIO $ updateFileCancelled db user fileId CIFSSndError getChatItemByFileId db user fileId - toView $ CRSndFileProgressXFTP user ci ft sndProgress sndTotal - SFDONE sndDescr rfds -> - unless cancelled $ do - withStore' $ \db -> setSndFTPrivateSndDescr db user fileId (fileDescrText sndDescr) - 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 : extraRFDs, sft : _, SMDSnd, DirectChat ct) -> do - withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs) - msgDeliveryId <- sendFileDescription sft rfd sharedMsgId $ sendDirectContactMessage ct - withStore' $ \db -> updateSndFTDeliveryXFTP db sft msgDeliveryId - agentXFTPDeleteSndFileInternal user aFileId - (_, _, SMDSnd, GroupChat g@GroupInfo {groupId}) -> do - ms <- withStore' $ \db -> getGroupMembers db user g - let rfdsMemberFTs = zip rfds $ memberFTs ms - extraRFDs = drop (length rfdsMemberFTs) rfds - withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs) - forM_ rfdsMemberFTs $ \mt -> sendToMember mt `catchError` (toView . CRChatError (Just user)) - ci' <- withStore $ \db -> do - liftIO $ updateCIFileStatus db user fileId CIFSSndComplete - getChatItemByFileId db user fileId - agentXFTPDeleteSndFileInternal user aFileId - toView $ CRSndFileCompleteXFTP user ci' ft - 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? - SFERR e -> do - unless (temporaryAgentError e) $ do - -- update chat item status - -- send status to view agentXFTPDeleteSndFileInternal user aFileId - throwChatError $ CEXFTPSndFile fileId (AgentSndFileId aFileId) e + toView $ CRSndFileError user ci where fileDescrText :: FilePartyI p => ValidFileDescription p -> T.Text fileDescrText = safeDecodeUtf8 . strEncode @@ -2416,37 +2420,38 @@ processAgentMsgRcvFile _corrId aFileId msg = where process :: User -> m () process user = do - ft@RcvFileTransfer {fileId, cancelled} <- withStore $ \db -> do + ft@RcvFileTransfer {fileId} <- withStore $ \db -> do fileId <- getXFTPRcvFileDBId db $ AgentRcvFileId aFileId getRcvFileTransfer db user fileId - case msg of - 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 + unless (rcvFileCompleteOrCancelled ft) $ 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 -> - 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 - unless (temporaryAgentError e) $ do - -- update chat item status - -- send status to view + 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 + | temporaryAgentError e -> + throwChatError $ CEXFTPRcvFile fileId (AgentRcvFileId aFileId) e + | otherwise -> do + ci <- withStore $ \db -> do + liftIO $ updateFileCancelled db user fileId CIFSRcvError + getChatItemByFileId db user fileId agentXFTPDeleteRcvFile user aFileId fileId - throwChatError $ CEXFTPRcvFile fileId (AgentRcvFileId aFileId) e + toView $ CRRcvFileError user ci processAgentMessageConn :: forall m. ChatMonad m => User -> ACorrId -> ConnId -> ACommand 'Agent 'AEConn -> m () processAgentMessageConn user _ agentConnId END = @@ -2940,9 +2945,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> pure () receiveFileChunk :: RcvFileTransfer -> Maybe Connection -> MsgMeta -> FileChunk -> m () - receiveFileChunk ft@RcvFileTransfer {fileId, chunkSize, cancelled} conn_ meta@MsgMeta {recipient = (msgId, _), integrity} = \case + receiveFileChunk ft@RcvFileTransfer {fileId, chunkSize} conn_ meta@MsgMeta {recipient = (msgId, _), integrity} = \case FileChunkCancel -> - unless cancelled $ do + unless (rcvFileCompleteOrCancelled ft) $ do cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) ci <- withStore $ \db -> getChatItemByFileId db user fileId toView $ CRRcvFileSndCancelled user ci ft @@ -3082,8 +3087,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do agentErrToItemStatus err = CISSndError . T.unpack . safeDecodeUtf8 $ strEncode err badRcvFileChunk :: RcvFileTransfer -> String -> m () - badRcvFileChunk ft@RcvFileTransfer {cancelled} err = - unless cancelled $ do + badRcvFileChunk ft err = + unless (rcvFileCompleteOrCancelled ft) $ do cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) throwChatError $ CEFileRcvChunk err @@ -3165,14 +3170,14 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do processFDMessage :: FileTransferId -> FileDescr -> m () processFDMessage fileId fileDescr = do - RcvFileTransfer {cancelled} <- withStore $ \db -> getRcvFileTransfer db user fileId - unless cancelled $ do + ft <- withStore $ \db -> getRcvFileTransfer db user fileId + unless (rcvFileCompleteOrCancelled ft) $ do (rfd, RcvFileTransfer {fileStatus}) <- withStore $ \db -> do rfd <- appendRcvFD db userId fileId fileDescr -- reading second time in the same transaction as appending description -- to prevent race condition with accept - ft <- getRcvFileTransfer db user fileId - pure (rfd, ft) + ft' <- getRcvFileTransfer db user fileId + pure (rfd, ft') case fileStatus of RFSAccepted _ -> receiveViaCompleteFD user fileId rfd _ -> pure () @@ -3365,8 +3370,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do xFileCancel ct@Contact {contactId} sharedMsgId msgMeta = do checkIntegrityCreateItem (CDDirectRcv ct) msgMeta fileId <- withStore $ \db -> getFileIdBySharedMsgId db userId contactId sharedMsgId - ft@RcvFileTransfer {cancelled} <- withStore (\db -> getRcvFileTransfer db user fileId) - unless cancelled $ do + ft <- withStore (\db -> getRcvFileTransfer db user fileId) + unless (rcvFileCompleteOrCancelled ft) $ do cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) ci <- withStore $ \db -> getChatItemByFileId db user fileId toView $ CRRcvFileSndCancelled user ci ft @@ -3446,8 +3451,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do (SMDRcv, CIGroupRcv m) -> do if sameMemberId memberId m then do - ft@RcvFileTransfer {cancelled} <- withStore (\db -> getRcvFileTransfer db user fileId) - unless cancelled $ do + ft <- withStore (\db -> getRcvFileTransfer db user fileId) + unless (rcvFileCompleteOrCancelled ft) $ do cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) ci <- withStore $ \db -> getChatItemByFileId db user fileId toView $ CRRcvFileSndCancelled user ci ft diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index a437f8a7a9..c71a99ab9f 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -438,6 +438,7 @@ data ChatResponse | CRRcvFileComplete {user :: User, chatItem :: AChatItem} | CRRcvFileCancelled {user :: User, chatItem :: AChatItem, rcvFileTransfer :: RcvFileTransfer} | CRRcvFileSndCancelled {user :: User, chatItem :: AChatItem, rcvFileTransfer :: RcvFileTransfer} + | CRRcvFileError {user :: User, chatItem :: AChatItem} | CRSndFileStart {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} | CRSndFileComplete {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} | CRSndFileRcvCancelled {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} @@ -446,6 +447,7 @@ data ChatResponse | CRSndFileProgressXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta, sentSize :: Int64, totalSize :: Int64} | CRSndFileCompleteXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta} | CRSndFileCancelledXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta} + | CRSndFileError {user :: User, chatItem :: AChatItem} | CRUserProfileUpdated {user :: User, fromProfile :: Profile, toProfile :: Profile} | CRContactAliasUpdated {user :: User, toContact :: Contact} | CRConnectionAliasUpdated {user :: User, toConnection :: PendingContactConnection} diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 18eee1f5d6..7ef3534cbe 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -448,11 +448,13 @@ data CIFileStatus (d :: MsgDirection) where CIFSSndTransfer :: {sndProgress :: Int64, sndTotal :: Int64} -> CIFileStatus 'MDSnd CIFSSndCancelled :: CIFileStatus 'MDSnd CIFSSndComplete :: CIFileStatus 'MDSnd + CIFSSndError :: CIFileStatus 'MDSnd CIFSRcvInvitation :: CIFileStatus 'MDRcv CIFSRcvAccepted :: CIFileStatus 'MDRcv CIFSRcvTransfer :: {rcvProgress :: Int64, rcvTotal :: Int64} -> CIFileStatus 'MDRcv CIFSRcvComplete :: CIFileStatus 'MDRcv CIFSRcvCancelled :: CIFileStatus 'MDRcv + CIFSRcvError :: CIFileStatus 'MDRcv deriving instance Eq (CIFileStatus d) @@ -464,11 +466,13 @@ ciFileEnded = \case CIFSSndTransfer {} -> False CIFSSndCancelled -> True CIFSSndComplete -> True + CIFSSndError -> True CIFSRcvInvitation -> False CIFSRcvAccepted -> False CIFSRcvTransfer {} -> False CIFSRcvCancelled -> True CIFSRcvComplete -> True + CIFSRcvError -> True instance ToJSON (CIFileStatus d) where toJSON = J.toJSON . jsonCIFileStatus @@ -488,11 +492,13 @@ instance MsgDirectionI d => StrEncoding (CIFileStatus d) where CIFSSndTransfer sent total -> strEncode (Str "snd_transfer", sent, total) CIFSSndCancelled -> "snd_cancelled" CIFSSndComplete -> "snd_complete" + CIFSSndError -> "snd_error" CIFSRcvInvitation -> "rcv_invitation" CIFSRcvAccepted -> "rcv_accepted" CIFSRcvTransfer rcvd total -> strEncode (Str "rcv_transfer", rcvd, total) CIFSRcvComplete -> "rcv_complete" CIFSRcvCancelled -> "rcv_cancelled" + CIFSRcvError -> "rcv_error" strP = (\(AFS _ st) -> checkDirection st) <$?> strP instance StrEncoding ACIFileStatus where @@ -503,11 +509,13 @@ instance StrEncoding ACIFileStatus where "snd_transfer" -> AFS SMDSnd <$> progress CIFSSndTransfer "snd_cancelled" -> pure $ AFS SMDSnd CIFSSndCancelled "snd_complete" -> pure $ AFS SMDSnd CIFSSndComplete + "snd_error" -> pure $ AFS SMDSnd CIFSSndError "rcv_invitation" -> pure $ AFS SMDRcv CIFSRcvInvitation "rcv_accepted" -> pure $ AFS SMDRcv CIFSRcvAccepted "rcv_transfer" -> AFS SMDRcv <$> progress CIFSRcvTransfer "rcv_complete" -> pure $ AFS SMDRcv CIFSRcvComplete "rcv_cancelled" -> pure $ AFS SMDRcv CIFSRcvCancelled + "rcv_error" -> pure $ AFS SMDRcv CIFSRcvError _ -> fail "bad file status" where progress :: (Int64 -> Int64 -> a) -> A.Parser a @@ -519,11 +527,13 @@ data JSONCIFileStatus | JCIFSSndTransfer {sndProgress :: Int64, sndTotal :: Int64} | JCIFSSndCancelled | JCIFSSndComplete + | JCIFSSndError | JCIFSRcvInvitation | JCIFSRcvAccepted | JCIFSRcvTransfer {rcvProgress :: Int64, rcvTotal :: Int64} | JCIFSRcvComplete | JCIFSRcvCancelled + | JCIFSRcvError deriving (Generic) instance ToJSON JSONCIFileStatus where @@ -536,11 +546,13 @@ jsonCIFileStatus = \case CIFSSndTransfer sent total -> JCIFSSndTransfer sent total CIFSSndCancelled -> JCIFSSndCancelled CIFSSndComplete -> JCIFSSndComplete + CIFSSndError -> JCIFSSndError CIFSRcvInvitation -> JCIFSRcvInvitation CIFSRcvAccepted -> JCIFSRcvAccepted CIFSRcvTransfer rcvd total -> JCIFSRcvTransfer rcvd total CIFSRcvComplete -> JCIFSRcvComplete CIFSRcvCancelled -> JCIFSRcvCancelled + CIFSRcvError -> JCIFSRcvError aciFileStatusJSON :: JSONCIFileStatus -> ACIFileStatus aciFileStatusJSON = \case @@ -548,11 +560,13 @@ aciFileStatusJSON = \case JCIFSSndTransfer sent total -> AFS SMDSnd $ CIFSSndTransfer sent total JCIFSSndCancelled -> AFS SMDSnd CIFSSndCancelled JCIFSSndComplete -> AFS SMDSnd CIFSSndComplete + JCIFSSndError -> AFS SMDSnd CIFSSndError JCIFSRcvInvitation -> AFS SMDRcv CIFSRcvInvitation JCIFSRcvAccepted -> AFS SMDRcv CIFSRcvAccepted JCIFSRcvTransfer rcvd total -> AFS SMDRcv $ CIFSRcvTransfer rcvd total JCIFSRcvComplete -> AFS SMDRcv CIFSRcvComplete JCIFSRcvCancelled -> AFS SMDRcv CIFSRcvCancelled + JCIFSRcvError -> AFS SMDRcv CIFSRcvError -- to conveniently read file data from db data CIFileInfo = CIFileInfo diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 2b5540cb13..22b0041281 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -1652,6 +1652,9 @@ rcvFileComplete = \case RFSComplete _ -> True _ -> False +rcvFileCompleteOrCancelled :: RcvFileTransfer -> Bool +rcvFileCompleteOrCancelled RcvFileTransfer {fileStatus, cancelled} = rcvFileComplete fileStatus || cancelled + data RcvFileInfo = RcvFileInfo { filePath :: FilePath, connId :: Maybe Int64, diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 9513ec25ad..06a775f9e9 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -153,12 +153,14 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts = \case CRRcvFileStart u ci -> ttyUser u $ receivingFile_' "started" ci CRRcvFileComplete u ci -> ttyUser u $ receivingFile_' "completed" ci CRRcvFileSndCancelled u _ ft -> ttyUser u $ viewRcvFileSndCancelled ft + CRRcvFileError u ci -> ttyUser u $ receivingFile_' "error" ci CRSndFileStart u _ ft -> ttyUser u $ sendingFile_ "started" ft CRSndFileComplete u _ ft -> ttyUser u $ sendingFile_ "completed" ft - CRSndFileStartXFTP _ _ _ -> [] - CRSndFileProgressXFTP _ _ _ _ _ -> [] - CRSndFileCompleteXFTP u ci _ -> ttyUser u $ uploadedFile ci - CRSndFileCancelledXFTP _ _ _ -> [] + CRSndFileStartXFTP {} -> [] + CRSndFileProgressXFTP {} -> [] + CRSndFileCompleteXFTP u ci _ -> ttyUser u $ uploadingFile "completed" ci + CRSndFileCancelledXFTP {} -> [] + CRSndFileError u ci -> ttyUser u $ uploadingFile "error" ci CRSndFileRcvCancelled u _ ft@SndFileTransfer {recipientDisplayName = c} -> ttyUser u [ttyContact c <> " cancelled receiving " <> sndFile ft] CRContactConnecting u _ -> ttyUser u [] @@ -1074,12 +1076,12 @@ sendingFile_ :: StyledString -> SndFileTransfer -> [StyledString] sendingFile_ status ft@SndFileTransfer {recipientDisplayName = c} = [status <> " sending " <> sndFile ft <> " to " <> ttyContact c] -uploadedFile :: AChatItem -> [StyledString] -uploadedFile (AChatItem _ _ (DirectChat Contact {localDisplayName = c}) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIDirectSnd}) = - ["uploaded " <> fileTransferStr fileId fileName <> " for " <> ttyContact c] -uploadedFile (AChatItem _ _ (GroupChat g) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIGroupSnd}) = - ["uploaded " <> fileTransferStr fileId fileName <> " for " <> ttyGroup' g] -uploadedFile _ = ["uploaded file"] -- shouldn't happen +uploadingFile :: StyledString -> AChatItem -> [StyledString] +uploadingFile status (AChatItem _ _ (DirectChat Contact {localDisplayName = c}) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIDirectSnd}) = + [status <> " uploading " <> fileTransferStr fileId fileName <> " for " <> ttyContact c] +uploadingFile status (AChatItem _ _ (GroupChat g) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIGroupSnd}) = + [status <> " uploading " <> fileTransferStr fileId fileName <> " for " <> ttyGroup' g] +uploadingFile status _ = [status <> " uploading file"] -- shouldn't happen sndFile :: SndFileTransfer -> StyledString sndFile SndFileTransfer {fileId, fileName} = fileTransferStr fileId fileName diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 3a63158416..19e5f31567 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -343,11 +343,14 @@ xftpServerConfig = } withXFTPServer :: IO () -> IO () -withXFTPServer = +withXFTPServer = withXFTPServer' xftpServerConfig + +withXFTPServer' :: XFTPServerConfig -> IO () -> IO () +withXFTPServer' cfg = serverBracket ( \started -> do createDirectoryIfMissing False xftpServerFiles - runXFTPServerBlocking started xftpServerConfig + runXFTPServerBlocking started cfg ) serverBracket :: (TMVar Bool -> IO ()) -> IO () -> IO () diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index 449bb561f6..0cf0fe79f3 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -12,6 +12,7 @@ import Simplex.Chat (roundedFDCount) import Simplex.Chat.Controller (ChatConfig (..), InlineFilesConfig (..), XFTPFileConfig (..), defaultInlineFilesConfig) import Simplex.Chat.Options (ChatOpts (..)) import Simplex.FileTransfer.Client.Main (xftpClientCLI) +import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..)) import Simplex.Messaging.Util (unlessM) import System.Directory (copyFile, doesFileExist) import System.Environment (withArgs) @@ -59,9 +60,12 @@ chatFileTests = do it "send and receive file" testXFTPFileTransfer it "send and receive file, accepting after upload" testXFTPAcceptAfterUpload it "send and receive file in group" testXFTPGroupFileTransfer + it "delete uploaded file" testXFTPDeleteUploadedFile + it "delete uploaded file in group" testXFTPDeleteUploadedFileGroup it "with changed XFTP config: send and receive file" testXFTPWithChangedConfig it "with relative paths: send and receive file" testXFTPWithRelativePaths xit' "continue receiving file after restart" testXFTPContinueRcv + it "error receiving file" testXFTPRcvError it "cancel receiving file, repeat receive" testXFTPCancelRcvRepeat runTestFileTransfer :: HasCallStack => TestCC -> TestCC -> IO () @@ -125,9 +129,7 @@ testAcceptInlineFileSndCancelDuringTransfer = [ do alice <##. "cancelled sending file 1 (test_1MB.pdf)" alice <## "completed sending file 1 (test_1MB.pdf) to bob", - do - bob <## "completed receiving file 1 (test_1MB.pdf) from alice" - bob <## "alice cancelled sending file 1 (test_1MB.pdf)" + bob <## "completed receiving file 1 (test_1MB.pdf) from alice" ] alice #> "@bob hi" bob <# "alice> hi" @@ -988,7 +990,7 @@ testXFTPFileTransfer = bob ##> "/fr 1 ./tests/tmp" bob <## "saving file 1 from alice to ./tests/tmp/test.pdf" -- alice <## "started sending file 1 (test.pdf) to bob" -- TODO "started uploading" ? - alice <## "uploaded file 1 (test.pdf) for bob" + alice <## "completed uploading file 1 (test.pdf) for bob" bob <## "started receiving file 1 (test.pdf) from alice" bob <## "completed receiving file 1 (test.pdf) from alice" @@ -1009,7 +1011,7 @@ testXFTPAcceptAfterUpload = bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" bob <## "use /fr 1 [/ | ] to receive it" -- alice <## "started sending file 1 (test.pdf) to bob" -- TODO "started uploading" ? - alice <## "uploaded file 1 (test.pdf) for bob" + alice <## "completed uploading file 1 (test.pdf) for bob" threadDelay 100000 @@ -1041,7 +1043,7 @@ testXFTPGroupFileTransfer = cath <## "use /fr 1 [/ | ] to receive it" ] -- alice <## "started sending file 1 (test.pdf) to #team" -- TODO "started uploading" ? - alice <## "uploaded file 1 (test.pdf) for #team" + alice <## "completed uploading file 1 (test.pdf) for #team" bob ##> "/fr 1 ./tests/tmp" bob @@ -1065,6 +1067,71 @@ testXFTPGroupFileTransfer = where cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} +testXFTPDeleteUploadedFile :: HasCallStack => FilePath -> IO () +testXFTPDeleteUploadedFile = + testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do + withXFTPServer $ do + connectUsers alice bob + + alice #> "/f @bob ./tests/fixtures/test.pdf" + alice <## "use /fc 1 to cancel sending" + bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + -- alice <## "started sending file 1 (test.pdf) to bob" -- TODO "started uploading" ? + alice <## "completed uploading file 1 (test.pdf) for bob" + + alice ##> "/fc 1" + concurrentlyN_ + [ alice <## "cancelled sending file 1 (test.pdf)", + bob <## "alice cancelled sending file 1 (test.pdf)" + ] + + bob ##> "/fr 1 ./tests/tmp" + bob <## "file cancelled: test.pdf" + where + cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} + +testXFTPDeleteUploadedFileGroup :: HasCallStack => FilePath -> IO () +testXFTPDeleteUploadedFileGroup = + testChatCfg3 cfg aliceProfile bobProfile cathProfile $ \alice bob cath -> do + withXFTPServer $ do + createGroup3 "team" alice bob cath + + alice #> "/f #team ./tests/fixtures/test.pdf" + alice <## "use /fc 1 to cancel sending" + concurrentlyN_ + [ do + bob <# "#team alice> sends file test.pdf (266.0 KiB / 272376 bytes)" + bob <## "use /fr 1 [/ | ] to receive it", + do + cath <# "#team alice> sends file test.pdf (266.0 KiB / 272376 bytes)" + cath <## "use /fr 1 [/ | ] to receive it" + ] + -- alice <## "started sending file 1 (test.pdf) to #team" -- TODO "started uploading" ? + alice <## "completed uploading file 1 (test.pdf) for #team" + + bob ##> "/fr 1 ./tests/tmp" + bob + <### [ "saving file 1 from alice to ./tests/tmp/test.pdf", + "started receiving file 1 (test.pdf) from alice" + ] + bob <## "completed receiving file 1 (test.pdf) from alice" + + src <- B.readFile "./tests/fixtures/test.pdf" + dest <- B.readFile "./tests/tmp/test.pdf" + dest `shouldBe` src + + alice ##> "/fc 1" + concurrentlyN_ + [ alice <## "cancelled sending file 1 (test.pdf) to bob, cath", + cath <## "alice cancelled sending file 1 (test.pdf)" + ] + + cath ##> "/fr 1 ./tests/tmp" + cath <## "file cancelled: test.pdf" + where + cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} + testXFTPWithChangedConfig :: HasCallStack => FilePath -> IO () testXFTPWithChangedConfig = testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do @@ -1084,7 +1151,7 @@ testXFTPWithChangedConfig = bob ##> "/fr 1 ./tests/tmp" bob <## "saving file 1 from alice to ./tests/tmp/test.pdf" -- alice <## "started sending file 1 (test.pdf) to bob" -- TODO "started uploading" ? - alice <## "uploaded file 1 (test.pdf) for bob" + alice <## "completed uploading file 1 (test.pdf) for bob" bob <## "started receiving file 1 (test.pdf) from alice" bob <## "completed receiving file 1 (test.pdf) from alice" @@ -1123,7 +1190,7 @@ testXFTPWithRelativePaths = bob ##> "/fr 1" bob <## "saving file 1 from alice to test.pdf" -- alice <## "started sending file 1 (test.pdf) to bob" -- TODO "started uploading" ? - alice <## "uploaded file 1 (test.pdf) for bob" + alice <## "completed uploading file 1 (test.pdf) for bob" bob <## "started receiving file 1 (test.pdf) from alice" bob <## "completed receiving file 1 (test.pdf) from alice" @@ -1145,7 +1212,7 @@ testXFTPContinueRcv tmp = do bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" bob <## "use /fr 1 [/ | ] to receive it" -- alice <## "started sending file 1 (test.pdf) to bob" -- TODO "started uploading" ? - alice <## "uploaded file 1 (test.pdf) for bob" + alice <## "completed uploading file 1 (test.pdf) for bob" -- server is down - file is not received withTestChatCfg tmp cfg "bob" $ \bob -> do @@ -1166,6 +1233,31 @@ testXFTPContinueRcv tmp = do where cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} +testXFTPRcvError :: HasCallStack => FilePath -> IO () +testXFTPRcvError tmp = do + withXFTPServer $ do + withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do + connectUsers alice bob + + alice #> "/f @bob ./tests/fixtures/test.pdf" + alice <## "use /fc 1 to cancel sending" + bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + -- alice <## "started sending file 1 (test.pdf) to bob" -- TODO "started uploading" ? + alice <## "completed uploading file 1 (test.pdf) for bob" + + -- server is up w/t store log - file reception should fail + withXFTPServer' xftpServerConfig {storeLogFile = Nothing} $ do + withTestChatCfg tmp cfg "bob" $ \bob -> do + bob <## "1 contacts connected (use /cs for the list)" + bob ##> "/fr 1 ./tests/tmp" + bob <## "started receiving file 1 (test.pdf) from alice" + bob <## "saving file 1 from alice to ./tests/tmp/test.pdf" + bob <## "error receiving file 1 (test.pdf) from alice" + where + cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} + testXFTPCancelRcvRepeat :: HasCallStack => FilePath -> IO () testXFTPCancelRcvRepeat = testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do @@ -1181,7 +1273,7 @@ testXFTPCancelRcvRepeat = bob ##> "/fr 1 ./tests/tmp" bob <## "saving file 1 from alice to ./tests/tmp/testfile_1" -- alice <## "started sending file 1 (testfile) to bob" -- TODO "started uploading" ? - alice <## "uploaded file 1 (testfile) for bob" + alice <## "completed uploading file 1 (testfile) for bob" bob <## "started receiving file 1 (testfile) from alice" bob ##> "/fc 1"