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 e3351dc124..7612ec8304 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 @@ -1711,7 +1711,8 @@ class CIFile( val fileName: String, val fileSize: Long, val filePath: String? = null, - val fileStatus: CIFileStatus + val fileStatus: CIFileStatus, + val fileProtocol: FileProtocol ) { val loaded: Boolean = when (fileStatus) { is CIFileStatus.SndStored -> true @@ -1733,19 +1734,25 @@ class CIFile( filePath: String? = "test.txt", fileStatus: CIFileStatus = CIFileStatus.RcvComplete ): CIFile = - CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, filePath = filePath, fileStatus = fileStatus) + CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, filePath = filePath, fileStatus = fileStatus, fileProtocol = FileProtocol.XFTP) } } +@Serializable +enum class FileProtocol { + @SerialName("smp") SMP, + @SerialName("xftp") XFTP; +} + @Serializable sealed class CIFileStatus { @Serializable @SerialName("sndStored") object SndStored: CIFileStatus() - @Serializable @SerialName("sndTransfer") class SndTransfer(val sndProgress: Int, val sndTotal: Int): 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("rcvInvitation") object RcvInvitation: CIFileStatus() @Serializable @SerialName("rcvAccepted") object RcvAccepted: CIFileStatus() - @Serializable @SerialName("rcvTransfer") class RcvTransfer(val rcvProgress: Int, val rcvTotal: Int): 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() } 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 04f721ec3a..c8ceacae5a 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 @@ -1402,6 +1402,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a chatItemSimpleUpdate(r.user, r.chatItem) is CR.RcvFileComplete -> chatItemSimpleUpdate(r.user, r.chatItem) + is CR.RcvFileProgressXFTP -> + chatItemSimpleUpdate(r.user, r.chatItem) is CR.SndFileStart -> chatItemSimpleUpdate(r.user, r.chatItem) is CR.SndFileComplete -> { @@ -1417,6 +1419,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a removeFile(appContext, fileName) } } + is CR.SndFileProgressXFTP -> + chatItemSimpleUpdate(r.user, r.chatItem) is CR.CallInvitation -> { chatModel.callManager.reportNewIncomingCall(r.callInvitation) } @@ -3044,12 +3048,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("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("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() @Serializable @SerialName("callAnswer") class CallAnswer(val user: User, val contact: Contact, val answer: WebRTCSession): CR() @@ -3146,11 +3152,13 @@ sealed class CR { is RcvFileAccepted -> "rcvFileAccepted" is RcvFileStart -> "rcvFileStart" is RcvFileComplete -> "rcvFileComplete" + 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" is CallAnswer -> "callAnswer" @@ -3249,11 +3257,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 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)}") 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/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index d63385ee2e..574e56f740 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -182,6 +182,8 @@ fun ComposeView( val pendingLinkUrl = rememberSaveable { mutableStateOf(null) } val cancelledLinks = rememberSaveable { mutableSetOf() } val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() + val xftpSendEnabled = chatModel.controller.appPrefs.xftpSendEnabled.get() + val maxFileSize = getMaxFileSize(fileProtocol = if (xftpSendEnabled) FileProtocol.XFTP else FileProtocol.SMP) val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) val textStyle = remember { mutableStateOf(smallFont) } val cameraLauncher = rememberCameraLauncher { uri: Uri? -> @@ -212,13 +214,13 @@ fun ComposeView( if (isAnimNewApi || isAnimOldApi) { // It's a gif or webp val fileSize = getFileSize(context, uri) - if (fileSize != null && fileSize <= MAX_FILE_SIZE) { + if (fileSize != null && fileSize <= maxFileSize) { content.add(UploadContent.AnimatedImage(uri)) } else { bitmap = null AlertManager.shared.showAlertMsg( generalGetString(R.string.large_file), - String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE)) + String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(maxFileSize)) ) } } else { @@ -236,7 +238,7 @@ fun ComposeView( val processPickedFile = { uri: Uri?, text: String? -> if (uri != null) { val fileSize = getFileSize(context, uri) - if (fileSize != null && fileSize <= MAX_FILE_SIZE) { + if (fileSize != null && fileSize <= maxFileSize) { val fileName = getFileName(SimplexApp.context, uri) if (fileName != null) { composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.FilePreview(fileName, uri)) @@ -244,7 +246,7 @@ fun ComposeView( } else { AlertManager.shared.showAlertMsg( generalGetString(R.string.large_file), - String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE)) + String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(maxFileSize)) ) } } 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 0df2c1518e..ac759cd2ce 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 @@ -3,6 +3,7 @@ package chat.simplex.app.views.chat.item import android.widget.Toast import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons @@ -16,6 +17,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.* import androidx.compose.ui.unit.dp @@ -64,7 +66,7 @@ fun CIFileView( fun fileSizeValid(): Boolean { if (file != null) { - return file.fileSize <= MAX_FILE_SIZE + return file.fileSize <= getMaxFileSize(file.fileProtocol) } return false } @@ -78,15 +80,23 @@ fun CIFileView( } else { AlertManager.shared.showAlertMsg( generalGetString(R.string.large_file), - String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(MAX_FILE_SIZE)) + String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol))) ) } } is CIFileStatus.RcvAccepted -> - AlertManager.shared.showAlertMsg( - generalGetString(R.string.waiting_for_file), - String.format(generalGetString(R.string.file_will_be_received_when_contact_is_online), MAX_FILE_SIZE) - ) + when (file.fileProtocol) { + FileProtocol.XFTP -> + AlertManager.shared.showAlertMsg( + generalGetString(R.string.waiting_for_file), + generalGetString(R.string.file_will_be_received_when_contact_completes_uploading) + ) + FileProtocol.SMP -> + AlertManager.shared.showAlertMsg( + generalGetString(R.string.waiting_for_file), + generalGetString(R.string.file_will_be_received_when_contact_is_online) + ) + } is CIFileStatus.RcvComplete -> { val filePath = getLoadedFilePath(context, file) if (filePath != null) { @@ -109,6 +119,20 @@ fun CIFileView( ) } + @Composable + fun progressCircle(progress: Long, total: Long) { + val angle = 360f * (progress.toDouble() / total.toDouble()).toFloat() + val strokeWidth = with(LocalDensity.current) { 3.dp.toPx() } + val strokeColor = if (isInDarkTheme()) FileDark else FileLight + Surface( + Modifier.drawRingModifier(angle, strokeColor, strokeWidth), + color = Color.Transparent, + shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50)) + ) { + Box(Modifier.size(32.dp)) + } + } + @Composable fun fileIndicator() { Box( @@ -120,8 +144,16 @@ fun CIFileView( ) { if (file != null) { when (file.fileStatus) { - is CIFileStatus.SndStored -> fileIcon() - is CIFileStatus.SndTransfer -> progressIndicator() + is CIFileStatus.SndStored -> + when (file.fileProtocol) { + FileProtocol.XFTP -> progressIndicator() + FileProtocol.SMP -> fileIcon() + } + is CIFileStatus.SndTransfer -> + when (file.fileProtocol) { + FileProtocol.XFTP -> progressCircle(file.fileStatus.sndProgress, file.fileStatus.sndTotal) + FileProtocol.SMP -> progressIndicator() + } is CIFileStatus.SndComplete -> fileIcon(innerIcon = Icons.Filled.Check) is CIFileStatus.SndCancelled -> fileIcon(innerIcon = Icons.Outlined.Close) is CIFileStatus.RcvInvitation -> @@ -130,7 +162,12 @@ fun CIFileView( else fileIcon(innerIcon = Icons.Outlined.PriorityHigh, color = WarningOrange) is CIFileStatus.RcvAccepted -> fileIcon(innerIcon = Icons.Outlined.MoreHoriz) - is CIFileStatus.RcvTransfer -> progressIndicator() + is CIFileStatus.RcvTransfer -> + if (file.fileProtocol == FileProtocol.XFTP && file.fileStatus.rcvProgress < file.fileStatus.rcvTotal) { + progressCircle(file.fileStatus.rcvProgress, file.fileStatus.rcvTotal) + } else { + progressIndicator() + } is CIFileStatus.RcvComplete -> fileIcon() is CIFileStatus.RcvCancelled -> fileIcon(innerIcon = Icons.Outlined.Close) } 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 7fb2e92f93..8599dc9137 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 @@ -27,8 +27,7 @@ import androidx.compose.ui.unit.dp import androidx.core.content.FileProvider import chat.simplex.app.* import chat.simplex.app.R -import chat.simplex.app.model.CIFile -import chat.simplex.app.model.CIFileStatus +import chat.simplex.app.model.* import chat.simplex.app.views.helpers.* import coil.ImageLoader import coil.compose.rememberAsyncImagePainter @@ -45,6 +44,15 @@ fun CIImageView( showMenu: MutableState, receiveFile: (Long) -> Unit ) { + @Composable + fun progressIndicator() { + CircularProgressIndicator( + Modifier.size(16.dp), + color = Color.White, + strokeWidth = 2.dp + ) + } + @Composable fun loadingIndicator() { if (file != null) { @@ -55,12 +63,13 @@ fun CIImageView( contentAlignment = Alignment.Center ) { when (file.fileStatus) { + is CIFileStatus.SndStored -> + when (file.fileProtocol) { + FileProtocol.XFTP -> progressIndicator() + FileProtocol.SMP -> {} + } is CIFileStatus.SndTransfer -> - CircularProgressIndicator( - Modifier.size(16.dp), - color = Color.White, - strokeWidth = 2.dp - ) + progressIndicator() is CIFileStatus.SndComplete -> Icon( Icons.Filled.Check, @@ -76,11 +85,7 @@ fun CIImageView( tint = Color.White ) is CIFileStatus.RcvTransfer -> - CircularProgressIndicator( - Modifier.size(16.dp), - color = Color.White, - strokeWidth = 2.dp - ) + progressIndicator() is CIFileStatus.RcvInvitation -> Icon( Icons.Outlined.ArrowDownward, @@ -136,7 +141,7 @@ fun CIImageView( fun fileSizeValid(): Boolean { if (file != null) { - return file.fileSize <= MAX_FILE_SIZE + return file.fileSize <= getMaxFileSize(file.fileProtocol) } return false } @@ -179,14 +184,22 @@ fun CIImageView( } else { AlertManager.shared.showAlertMsg( generalGetString(R.string.large_file), - String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(MAX_FILE_SIZE)) + String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol))) ) } CIFileStatus.RcvAccepted -> - AlertManager.shared.showAlertMsg( - generalGetString(R.string.waiting_for_image), - generalGetString(R.string.image_will_be_received_when_contact_is_online) - ) + when (file.fileProtocol) { + FileProtocol.XFTP -> + AlertManager.shared.showAlertMsg( + generalGetString(R.string.waiting_for_image), + generalGetString(R.string.image_will_be_received_when_contact_completes_uploading) + ) + FileProtocol.SMP -> + AlertManager.shared.showAlertMsg( + generalGetString(R.string.waiting_for_image), + generalGetString(R.string.image_will_be_received_when_contact_is_online) + ) + } CIFileStatus.RcvTransfer(rcvProgress = 7, rcvTotal = 10) -> {} // ? CIFileStatus.RcvComplete -> {} // ? CIFileStatus.RcvCancelled -> {} // TODO diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt index c20df776a2..cc873ac6ec 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt @@ -228,7 +228,7 @@ private fun VoiceMsgIndicator( } } -private fun Modifier.drawRingModifier(angle: Float, color: Color, strokeWidth: Float) = drawWithCache { +fun Modifier.drawRingModifier(angle: Float, color: Color, strokeWidth: Float) = drawWithCache { val brush = Brush.linearGradient( 0f to Color.Transparent, 0f to color, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt index cdb5675545..d361daa9af 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt @@ -237,8 +237,9 @@ const val MAX_VOICE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE const val MAX_VOICE_SIZE_FOR_SENDING: Long = 94680 // 6 chunks * 15780 bytes per chunk const val MAX_VOICE_MILLIS_FOR_SENDING: Int = 43_000 -//const val MAX_FILE_SIZE_SMP: Long = 8000000 // TODO distinguish between XFTP and SMP files -const val MAX_FILE_SIZE: Long = 1_073_741_824 +const val MAX_FILE_SIZE_SMP: Long = 8000000 + +const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824 fun getFilesDirectory(context: Context): String { return context.filesDir.toString() @@ -494,9 +495,9 @@ fun formatBytes(bytes: Long): String { return "0 bytes" } val bytesDouble = bytes.toDouble() - val k = 1000.toDouble() + val k = 1024.toDouble() val units = arrayOf("bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") - val i = kotlin.math.floor(log2(bytesDouble) / log2(k)) + val i = floor(log2(bytesDouble) / log2(k)) val size = bytesDouble / k.pow(i) val unit = units[i.toInt()] @@ -541,6 +542,13 @@ fun directoryFileCountAndSize(dir: String): Pair { // count, size in return fileCount to bytes } +fun getMaxFileSize(fileProtocol: FileProtocol): Long { + return when (fileProtocol) { + FileProtocol.XFTP -> MAX_FILE_SIZE_XFTP + FileProtocol.SMP -> MAX_FILE_SIZE_SMP + } +} + fun Color.darker(factor: Float = 0.1f): Color = Color(max(red * (1 - factor), 0f), max(green * (1 - factor), 0f), max(blue * (1 - factor), 0f), alpha) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/DeveloperView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/DeveloperView.kt index e0fd6d276f..55a6dee8ab 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/DeveloperView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/DeveloperView.kt @@ -1,6 +1,8 @@ package chat.simplex.app.views.usersettings import SectionDivider +import SectionSpacer +import SectionTextFooter import SectionView import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -14,7 +16,7 @@ import androidx.compose.ui.res.stringResource import chat.simplex.app.R import chat.simplex.app.model.ChatModel import chat.simplex.app.views.TerminalView -import chat.simplex.app.views.helpers.AppBarTitle +import chat.simplex.app.views.helpers.* @Composable fun DeveloperView( @@ -23,20 +25,34 @@ fun DeveloperView( withAuth: (block: () -> Unit) -> Unit ) { Column(Modifier.fillMaxWidth()) { - val developerTools = m.controller.appPrefs.developerTools - val confirmDBUpgrades = m.controller.appPrefs.confirmDBUpgrades val uriHandler = LocalUriHandler.current AppBarTitle(stringResource(R.string.settings_developer_tools)) + val developerTools = m.controller.appPrefs.developerTools + val devTools = remember { mutableStateOf(developerTools.get()) } SectionView() { + InstallTerminalAppItem(uriHandler) + SectionDivider() ChatConsoleItem { withAuth(showCustomModal { it, close -> TerminalView(it, close) }) } SectionDivider() - val devTools = remember { mutableStateOf(developerTools.get()) } - SettingsPreferenceItem(Icons.Outlined.Construction, stringResource(R.string.settings_developer_tools), developerTools, devTools) + SettingsPreferenceItem(Icons.Outlined.DriveFolderUpload, stringResource(R.string.confirm_database_upgrades), m.controller.appPrefs.confirmDBUpgrades) SectionDivider() - var confirm = remember { mutableStateOf(confirmDBUpgrades.get()) } - SettingsPreferenceItem(Icons.Outlined.DriveFolderUpload, stringResource(R.string.confirm_database_upgrades), confirmDBUpgrades, confirm) - SectionDivider() - InstallTerminalAppItem(uriHandler) + SettingsPreferenceItem(Icons.Outlined.Code, stringResource(R.string.show_developer_options), developerTools, devTools) + } + SectionTextFooter( + generalGetString(if (devTools.value) R.string.show_dev_options else R.string.hide_dev_options) + " " + + generalGetString(R.string.developer_options) + ) + SectionSpacer() + + val xftpSendEnabled = m.controller.appPrefs.xftpSendEnabled + val xftpEnabled = remember { mutableStateOf(xftpSendEnabled.get()) } + SectionView(generalGetString(R.string.settings_section_title_experimenta)) { + SettingsPreferenceItem(Icons.Outlined.UploadFile, stringResource(R.string.settings_send_files_via_xftp), xftpSendEnabled, xftpEnabled) { + withApi { m.controller.apiSetXFTPConfig(m.controller.getXFTPCfg()) } + } + } + if (xftpEnabled.value) { + SectionTextFooter(generalGetString(R.string.xftp_requires_v461)) } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt index f55eb941ba..e07b3443d0 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt @@ -205,10 +205,10 @@ fun SettingsLayout( SectionSpacer() SectionView(stringResource(R.string.settings_section_title_develop)) { - SettingsActionItem(Icons.Outlined.Construction, stringResource(R.string.settings_developer_tools), showSettingsModal { DeveloperView(it, showCustomModal, withAuth) }) - SectionDivider() - SettingsActionItem(Icons.Outlined.Science, stringResource(R.string.settings_experimental_features), showSettingsModal { ExperimentalFeaturesView(it) }) + SettingsActionItem(Icons.Outlined.Code, stringResource(R.string.settings_developer_tools), showSettingsModal { DeveloperView(it, showCustomModal, withAuth) }) SectionDivider() +// SettingsActionItem(Icons.Outlined.Science, stringResource(R.string.settings_experimental_features), showSettingsModal { ExperimentalFeaturesView(it) }) +// SectionDivider() AppVersionItem(showVersion) } } diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 2f8206c58a..8137e48ab4 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -238,6 +238,7 @@ Asked to receive the image Image sent Waiting for image + Image will be received when your contact completes uploading it. Image will be received when your contact is online, please wait or check later! Image saved to Gallery @@ -247,6 +248,7 @@ Your contact sent a file that is larger than currently supported maximum size (%1$s). Currently maximum supported file size is %1$s. Waiting for file + File will be received when your contact completes uploading it. File will be received when your contact is online, please wait or check later! File saved File not found @@ -509,6 +511,10 @@ Core version: v%s Core built at: %s simplexmq: v%s (%2s) + Show: + Hide: + Show developer options + Database IDs and Transport isolation option. Create address @@ -716,7 +722,9 @@ MESSAGES CALLS Incognito mode + EXPERIMENTAL Send files via XFTP + v4.6.1+ is required to receive via XFTP. Your chat database diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index e8f917af3f..6f33e42640 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -381,7 +381,7 @@ final class ChatModel: ObservableObject { markChatItemsRead(cInfo) } } - + func markChatUnread(_ cInfo: ChatInfo, unreadChat: Bool = true) { _updateChat(cInfo.id) { chat in chat.chatStats.unreadChat = unreadChat diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 170f2d7cd5..8e6e4af4f1 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -56,7 +56,7 @@ struct CIFileView: View { case .sndComplete: return false case .sndCancelled: return false case .rcvInvitation: return true - case .rcvAccepted: return file.isSMP + case .rcvAccepted: return true case .rcvTransfer: return false case .rcvComplete: return true case .rcvCancelled: return false @@ -92,7 +92,13 @@ struct CIFileView: View { ) } case .rcvAccepted: - if file.isSMP { + switch file.fileProtocol { + case .xftp: + AlertManager.shared.showAlertMsg( + title: "Waiting for file", + message: "File will be received when your contact completes uploading it." + ) + case .smp: AlertManager.shared.showAlertMsg( title: "Waiting for file", message: "File will be received when your contact is online, please wait or check later!" @@ -112,7 +118,11 @@ struct CIFileView: View { @ViewBuilder private func fileIndicator() -> some View { if let file = file { switch file.fileStatus { - case .sndStored: fileIcon("doc.fill") + case .sndStored: + switch file.fileProtocol { + case .xftp: progressView() + case .smp: fileIcon("doc.fill") + } case let .sndTransfer(sndProgress, sndTotal): switch file.fileProtocol { case .xftp: progressCircle(sndProgress, sndTotal) @@ -169,7 +179,7 @@ struct CIFileView: View { Circle() .trim(from: 0, to: Double(progress) / Double(total)) .stroke( - Color.accentColor, + Color(uiColor: .tertiaryLabel), style: StrokeStyle(lineWidth: 3) ) .rotationEffect(.degrees(-90)) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index a52cd72a22..d53639cd29 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -41,7 +41,13 @@ struct CIImageView: View { // TODO image accepted alert? } case .rcvAccepted: - if file.isSMP { + switch file.fileProtocol { + case .xftp: + AlertManager.shared.showAlertMsg( + title: "Waiting for image", + message: "Image will be received when your contact completes uploading it." + ) + case .smp: AlertManager.shared.showAlertMsg( title: "Waiting for image", message: "Image will be received when your contact is online, please wait or check later!" @@ -79,6 +85,11 @@ struct CIImageView: View { @ViewBuilder private func loadingIndicator() -> some View { if let file = chatItem.file { switch file.fileStatus { + case .sndStored: + switch file.fileProtocol { + case .xftp: progressView() + case .smp: EmptyView() + } case .sndTransfer: progressView() case .sndComplete: diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index 4174e5b439..763627fc47 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -20,6 +20,7 @@ struct TerminalView: View { @State var composeState: ComposeState = ComposeState() @FocusState private var keyboardVisible: Bool @State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) + @State private var terminalItem: TerminalItem? var body: some View { if authorized { @@ -38,19 +39,8 @@ struct TerminalView: View { ScrollView { LazyVStack { ForEach(chatModel.terminalItems) { item in - NavigationLink { - let s = item.details - ScrollView { - Text(s.prefix(maxItemSize)) - .padding() - } - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button { showShareSheet(items: [s]) } label: { - Image(systemName: "square.and.arrow.up") - } - } - } + Button { + terminalItem = item } label: { HStack { Text(item.id.formatted(date: .omitted, time: .standard)) @@ -70,6 +60,11 @@ struct TerminalView: View { } } } + .background(NavigationLink( + isActive: Binding(get: { terminalItem != nil }, set: { _ in }), + destination: terminalItemView, + label: { EmptyView() } + )) } } @@ -96,6 +91,22 @@ struct TerminalView: View { } } } + + func terminalItemView() -> some View { + let s = terminalItem?.details ?? "" + return ScrollView { + Text(s.prefix(maxItemSize)) + .padding() + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { showShareSheet(items: [s]) } label: { + Image(systemName: "square.and.arrow.up") + } + } + } + .onDisappear { terminalItem = nil } + } func sendMessage() { let cmd = ChatCommand.string(composeState.message) diff --git a/apps/ios/Shared/Views/UserSettings/CallSettings.swift b/apps/ios/Shared/Views/UserSettings/CallSettings.swift index 43c715523e..3409e7ab0e 100644 --- a/apps/ios/Shared/Views/UserSettings/CallSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/CallSettings.swift @@ -13,7 +13,6 @@ struct CallSettings: View { @AppStorage(DEFAULT_WEBRTC_POLICY_RELAY) private var webrtcPolicyRelay = true @AppStorage(GROUP_DEFAULT_CALL_KIT_ENABLED, store: groupDefaults) private var callKitEnabled = true @AppStorage(DEFAULT_CALL_KIT_CALLS_IN_RECENTS) private var callKitCallsInRecents = false - @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false private let allowChangingCallsHistory = false var body: some View { diff --git a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift index 0d7435d909..ce0fd5caeb 100644 --- a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift +++ b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift @@ -12,23 +12,13 @@ import SimpleXChat struct DeveloperView: View { @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @AppStorage(GROUP_DEFAULT_CONFIRM_DB_UPGRADES, store: groupDefaults) private var confirmDatabaseUpgrades = false + @AppStorage(GROUP_DEFAULT_XFTP_SEND_ENABLED, store: groupDefaults) private var xftpSendEnabled = false @Environment(\.colorScheme) var colorScheme var body: some View { VStack { List { Section { - NavigationLink { - TerminalView() - } label: { - settingsRow("terminal") { Text("Chat console") } - } - settingsRow("chevron.left.forwardslash.chevron.right") { - Toggle("Show developer options", isOn: $developerTools) - } - settingsRow("internaldrive") { - Toggle("Confirm database upgrades", isOn: $confirmDatabaseUpgrades) - } ZStack(alignment: .leading) { Image(colorScheme == .dark ? "github_light" : "github") .resizable() @@ -37,6 +27,38 @@ struct DeveloperView: View { Text("Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)") .padding(.leading, 36) } + NavigationLink { + TerminalView() + } label: { + settingsRow("terminal") { Text("Chat console") } + } + settingsRow("internaldrive") { + Toggle("Confirm database upgrades", isOn: $confirmDatabaseUpgrades) + } + settingsRow("chevron.left.forwardslash.chevron.right") { + Toggle("Show developer options", isOn: $developerTools) + } + } footer: { + (developerTools ? Text("Show:") : Text("Hide:")) + Text(" ") + Text("Database IDs and Transport isolation option.") + } + + Section { + settingsRow("arrow.up.doc") { + Toggle("Send files via XFTP", isOn: $xftpSendEnabled) + .onChange(of: xftpSendEnabled) { _ in + do { + try setXFTPConfig(getXFTPCfg()) + } catch { + logger.error("setXFTPConfig: cannot set XFTP config \(responseError(error))") + } + } + } + } header: { + Text("Experimental") + } footer: { + if xftpSendEnabled { + Text("v4.6.1+ is required to receive via XFTP.") + } } } } diff --git a/apps/ios/Shared/Views/UserSettings/HiddenProfileView.swift b/apps/ios/Shared/Views/UserSettings/HiddenProfileView.swift index d01fee92fa..509874619f 100644 --- a/apps/ios/Shared/Views/UserSettings/HiddenProfileView.swift +++ b/apps/ios/Shared/Views/UserSettings/HiddenProfileView.swift @@ -33,7 +33,7 @@ struct HiddenProfileView: View { } Section { - PassphraseField(key: $hidePassword, placeholder: "Password to show", valid: true, showStrength: true) + PassphraseField(key: $hidePassword, placeholder: "Password to show", valid: passwordValid, showStrength: true) PassphraseField(key: $confirmHidePassword, placeholder: "Confirm password", valid: confirmValid) settingsRow("lock") { @@ -72,9 +72,11 @@ struct HiddenProfileView: View { } } + var passwordValid: Bool { hidePassword == hidePassword.trimmingCharacters(in: .whitespaces) } + var confirmValid: Bool { confirmHidePassword == "" || hidePassword == confirmHidePassword } - var saveDisabled: Bool { hidePassword == "" || confirmHidePassword == "" || !confirmValid } + var saveDisabled: Bool { hidePassword == "" || !passwordValid || confirmHidePassword == "" || !confirmValid } } struct ProfilePrivacyView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 5b9cd49526..4c0084b68b 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -12,7 +12,6 @@ import SimpleXChat struct PrivacySettings: View { @AppStorage(DEFAULT_PRIVACY_ACCEPT_IMAGES) private var autoAcceptImages = true @AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true - @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @State private var simplexLinkMode = privacySimplexLinkModeDefault.get() @AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 715533481a..e885fc1f2d 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -264,12 +264,12 @@ struct SettingsView: View { } label: { settingsRow("chevron.left.forwardslash.chevron.right") { Text("Developer tools") } } - NavigationLink { - ExperimentalFeaturesView() - .navigationTitle("Experimental features") - } label: { - settingsRow("gauge") { Text("Experimental features") } - } +// NavigationLink { +// ExperimentalFeaturesView() +// .navigationTitle("Experimental features") +// } label: { +// settingsRow("gauge") { Text("Experimental features") } +// } NavigationLink { VersionView() .navigationBarTitle("App version") diff --git a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift index 01de1a8b33..ef41b9919e 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift @@ -13,15 +13,17 @@ struct UserProfilesView: View { @AppStorage(DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE) private var showHiddenProfilesNotice = true @AppStorage(DEFAULT_SHOW_MUTE_PROFILE_ALERT) private var showMuteProfileAlert = true @State private var showDeleteConfirmation = false - @State private var userToDelete: UserInfo? + @State private var userToDelete: User? @State private var alert: UserProfilesAlert? @State private var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) @State private var searchTextOrPassword = "" @State private var selectedUser: User? @State private var profileHidden = false + @State private var profileAction: UserProfileAction? + @State private var actionPassword = "" private enum UserProfilesAlert: Identifiable { - case deleteUser(userInfo: UserInfo, delSMPQueues: Bool) + case deleteUser(user: User, delSMPQueues: Bool) case cantDeleteLastUser case hiddenProfilesNotice case muteProfileAlert @@ -30,7 +32,7 @@ struct UserProfilesView: View { var id: String { switch self { - case let .deleteUser(userInfo, delSMPQueues): return "deleteUser \(userInfo.user.userId) \(delSMPQueues)" + case let .deleteUser(user, delSMPQueues): return "deleteUser \(user.userId) \(delSMPQueues)" case .cantDeleteLastUser: return "cantDeleteLastUser" case .hiddenProfilesNotice: return "hiddenProfilesNotice" case .muteProfileAlert: return "muteProfileAlert" @@ -40,6 +42,16 @@ struct UserProfilesView: View { } } + private enum UserProfileAction: Identifiable { + case deleteUser(user: User, delSMPQueues: Bool) + + var id: String { + switch self { + case let .deleteUser(user, delSMPQueues): return "deleteUser \(user.userId) \(delSMPQueues)" + } + } + } + var body: some View { if authorized { userProfilesView() @@ -69,7 +81,7 @@ struct UserProfilesView: View { if let i = indexSet.first { if m.users.count > 1 && (m.users[i].user.hidden || visibleUsersCount > 1) { showDeleteConfirmation = true - userToDelete = users[i] + userToDelete = users[i].user } else { alert = .cantDeleteLastUser } @@ -114,14 +126,17 @@ struct UserProfilesView: View { withAnimation { profileHidden = false } } } + .sheet(item: $profileAction) { action in + profileActionView(action) + } .alert(item: $alert) { alert in switch alert { - case let .deleteUser(userInfo, delSMPQueues): + case let .deleteUser(user, delSMPQueues): return Alert( title: Text("Delete user profile?"), message: Text("All chats and messages will be deleted - this cannot be undone!"), primaryButton: .destructive(Text("Delete")) { - Task { await removeUser(userInfo, delSMPQueues) } + Task { await removeUser(user, delSMPQueues, viewPwd: userViewPassword(user)) } }, secondaryButton: .cancel() ) @@ -179,36 +194,81 @@ struct UserProfilesView: View { m.users.filter({ u in !u.user.hidden }).count } - private func userViewPassword(_ user: User) -> String? { - user.activeUser || !user.hidden ? nil : searchTextOrPassword + private func correctPassword(_ user: User, _ pwd: String) -> Bool { + if let ph = user.viewPwdHash { + return pwd != "" && chatPasswordHash(pwd, ph.salt) == ph.hash + } + return false } - private func deleteModeButton(_ title: LocalizedStringKey, _ delSMPQueues: Bool) -> some View { - Button(title, role: .destructive) { - if let userInfo = userToDelete { - alert = .deleteUser(userInfo: userInfo, delSMPQueues: delSMPQueues) + private func userViewPassword(_ user: User) -> String? { + !user.hidden ? nil : searchTextOrPassword + } + + @ViewBuilder private func profileActionView(_ action: UserProfileAction) -> some View { + let passwordValid = actionPassword == actionPassword.trimmingCharacters(in: .whitespaces) + switch action { + case let .deleteUser(user, delSMPQueues): + let actionEnabled = actionPassword != "" && passwordValid && correctPassword(user, actionPassword) + List { + Text("Delete user") + .font(.title) + .bold() + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listRowBackground(Color.clear) + + Section() { + ProfilePreview(profileOf: user).padding(.leading, -8) + } + + Section { + PassphraseField(key: $actionPassword, placeholder: "Profile password", valid: passwordValid) + settingsRow("trash") { + Button("Delete user", role: .destructive) { + profileAction = nil + Task { await removeUser(user, delSMPQueues, viewPwd: actionPassword) } + } + .disabled(!actionEnabled) + } + } footer: { + if actionEnabled { + Text("All chats and messages will be deleted - this cannot be undone!") + .font(.callout) + } + } } } } - private func removeUser(_ userInfo: UserInfo, _ delSMPQueues: Bool) async { + private func deleteModeButton(_ title: LocalizedStringKey, _ delSMPQueues: Bool) -> some View { + Button(title, role: .destructive) { + if let user = userToDelete { + if user.hidden && user.activeUser && !correctPassword(user, searchTextOrPassword) { + profileAction = .deleteUser(user: user, delSMPQueues: delSMPQueues) + } else { + alert = .deleteUser(user: user, delSMPQueues: delSMPQueues) + } + } + } + } + + private func removeUser(_ user: User, _ delSMPQueues: Bool, viewPwd: String?) async { do { - let u = userInfo.user - if u.activeUser { + if user.activeUser { if let newActive = m.users.first(where: { u in !u.user.activeUser && !u.user.hidden }) { try await changeActiveUserAsync_(newActive.user.userId, viewPwd: nil) - try await deleteUser(u) + try await deleteUser() } } else { - try await deleteUser(u) + try await deleteUser() } } catch let error { let a = getErrorAlert(error, "Error deleting user profile") alert = .error(title: a.title, error: a.message) } - func deleteUser(_ user: User) async throws { - try await apiDeleteUser(user.userId, delSMPQueues, viewPwd: userViewPassword(user)) + func deleteUser() async throws { + try await apiDeleteUser(user.userId, delSMPQueues, viewPwd: viewPwd) await MainActor.run { withAnimation { m.removeUser(user) } } } } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index f1e9ba0a4e..3b3b4acd22 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2221,13 +2221,6 @@ public struct CIFile: Decodable { CIFile(fileId: fileId, fileName: fileName, fileSize: fileSize, filePath: filePath, fileStatus: fileStatus, fileProtocol: .xftp) } - public var isSMP: Bool { - switch self.fileProtocol { - case .smp: return true - default: return false - } - } - public var loaded: Bool { get { switch self.fileStatus { diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 0fc3693dc2..1102a98b1e 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1390,8 +1390,9 @@ processChatCommand = \case CancelFile fileId -> withUser $ \user@User {userId} -> withChatLock "cancelFile" . procCmd $ withStore (\db -> getFileTransfer db user fileId) >>= \case - FTSnd ftm@FileTransferMeta {cancelled} fts -> do - unless cancelled $ do + FTSnd ftm@FileTransferMeta {cancelled} fts + | cancelled -> throwChatError $ CEFileAlreadyCancelled fileId + | otherwise -> do fileAgentConnIds <- cancelSndFile user ftm fts True deleteAgentConnectionsAsync user fileAgentConnIds sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId @@ -1403,12 +1404,14 @@ processChatCommand = \case Group gInfo ms <- withStore $ \db -> getGroup db user groupId void . sendGroupMessage user gInfo ms $ XFileCancel sharedMsgId _ -> throwChatError $ CEFileInternal "invalid chat ref for file transfer" - ci <- withStore $ \db -> getChatItemByFileId db user fileId - pure $ CRSndGroupFileCancelled user ci ftm fts - FTRcv ftr@RcvFileTransfer {cancelled} -> do - unless cancelled $ + ci <- withStore $ \db -> getChatItemByFileId db user fileId + pure $ CRSndFileCancelled user ci ftm fts + FTRcv ftr@RcvFileTransfer {cancelled} + | cancelled -> throwChatError $ CEFileAlreadyCancelled fileId + | otherwise -> do cancelRcvFileTransfer user ftr >>= mapM_ (deleteAgentConnectionAsync user) - pure $ CRRcvFileCancelled user ftr + ci <- withStore $ \db -> getChatItemByFileId db user fileId + pure $ CRRcvFileCancelled user ci ftr FileStatus fileId -> withUser $ \user -> do fileStatus <- withStore $ \db -> getFileTransferProgress db user fileId pure $ CRFileTransferStatus user fileStatus @@ -2819,7 +2822,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do FileChunkCancel -> unless cancelled $ do cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) - toView $ CRRcvFileSndCancelled user ft + ci <- withStore $ \db -> getChatItemByFileId db user fileId + toView $ CRRcvFileSndCancelled user ci ft FileChunk {chunkNo, chunkBytes = chunk} -> do case integrity of MsgOk -> pure () @@ -3238,7 +3242,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ft@RcvFileTransfer {cancelled} <- withStore (\db -> getRcvFileTransfer db user fileId) unless cancelled $ do cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) - toView $ CRRcvFileSndCancelled user ft + ci <- withStore $ \db -> getChatItemByFileId db user fileId + toView $ CRRcvFileSndCancelled user ci ft xFileAcptInv :: Contact -> SharedMsgId -> Maybe ConnReqInvitation -> String -> MsgMeta -> m () xFileAcptInv ct sharedMsgId fileConnReq_ fName msgMeta = do @@ -3314,7 +3319,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ft@RcvFileTransfer {cancelled} <- withStore (\db -> getRcvFileTransfer db user fileId) unless cancelled $ do cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) - toView $ CRRcvFileSndCancelled user ft + ci <- withStore $ \db -> getChatItemByFileId db user fileId + toView $ CRRcvFileSndCancelled user ci ft else messageError "x.file.cancel: group member attempted to cancel file of another member" -- shouldn't happen now that query includes group member id (SMDSnd, _) -> messageError "x.file.cancel: group member attempted invalid file cancel" diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 1b13fc34fc..7bd7d6fd4f 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -445,13 +445,12 @@ data ChatResponse | CRRcvFileStart {user :: User, chatItem :: AChatItem} | CRRcvFileProgressXFTP {user :: User, chatItem :: AChatItem, receivedSize :: Int64, totalSize :: Int64} | CRRcvFileComplete {user :: User, chatItem :: AChatItem} - | CRRcvFileCancelled {user :: User, rcvFileTransfer :: RcvFileTransfer} - | CRRcvFileSndCancelled {user :: User, rcvFileTransfer :: RcvFileTransfer} + | CRRcvFileCancelled {user :: User, chatItem :: AChatItem, rcvFileTransfer :: RcvFileTransfer} + | CRRcvFileSndCancelled {user :: User, chatItem :: AChatItem, rcvFileTransfer :: RcvFileTransfer} | CRSndFileStart {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} | CRSndFileComplete {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} - | CRSndFileCancelled {chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} | CRSndFileRcvCancelled {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} - | CRSndGroupFileCancelled {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta, sndFileTransfers :: [SndFileTransfer]} + | CRSndFileCancelled {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta, sndFileTransfers :: [SndFileTransfer]} | CRSndFileStartXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta} | CRSndFileProgressXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta, sentSize :: Int64, totalSize :: Int64} | CRSndFileCompleteXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta} @@ -771,6 +770,7 @@ data ChatErrorType | CEFileNotFound {message :: String} | CEFileAlreadyReceiving {message :: String} | CEFileCancelled {message :: String} + | CEFileAlreadyCancelled {fileId :: FileTransferId} | CEFileAlreadyExists {filePath :: FilePath} | CEFileRead {filePath :: FilePath, message :: String} | CEFileWrite {filePath :: FilePath, message :: String} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index a989a09bc5..2352b1e94b 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -137,8 +137,8 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts = \case CRRcvFileProgressXFTP _ _ _ _ -> [] CRRcvFileAccepted u ci -> ttyUser u $ savingFile' ci CRRcvFileAcceptedSndCancelled u ft -> ttyUser u $ viewRcvFileSndCancelled ft - CRSndGroupFileCancelled u _ ftm fts -> ttyUser u $ viewSndGroupFileCancelled ftm fts - CRRcvFileCancelled u ft -> ttyUser u $ receivingFile_ "cancelled" ft + CRSndFileCancelled u _ ftm fts -> ttyUser u $ viewSndFileCancelled ftm fts + CRRcvFileCancelled u _ ft -> ttyUser u $ receivingFile_ "cancelled" ft CRUserProfileUpdated u p p' -> ttyUser u $ viewUserProfileUpdated p p' CRContactPrefsUpdated {user = u, fromContact, toContact} -> ttyUser u $ viewUserContactPrefsUpdated u fromContact toContact CRContactAliasUpdated u c -> ttyUser u $ viewContactAliasUpdated c @@ -148,10 +148,9 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts = \case CRReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} -> ttyUser u $ viewReceivedContactRequest c profile CRRcvFileStart u ci -> ttyUser u $ receivingFile_' "started" ci CRRcvFileComplete u ci -> ttyUser u $ receivingFile_' "completed" ci - CRRcvFileSndCancelled u ft -> ttyUser u $ viewRcvFileSndCancelled ft + CRRcvFileSndCancelled u _ ft -> ttyUser u $ viewRcvFileSndCancelled ft CRSndFileStart u _ ft -> ttyUser u $ sendingFile_ "started" ft CRSndFileComplete u _ ft -> ttyUser u $ sendingFile_ "completed" ft - CRSndFileCancelled _ ft -> sendingFile_ "cancelled" ft CRSndFileStartXFTP _ _ _ -> [] CRSndFileProgressXFTP _ _ _ _ _ -> [] CRSndFileCompleteXFTP _ _ _ -> [] @@ -1054,8 +1053,8 @@ viewRcvFileSndCancelled :: RcvFileTransfer -> [StyledString] viewRcvFileSndCancelled ft@RcvFileTransfer {senderDisplayName = c} = [ttyContact c <> " cancelled sending " <> rcvFile ft] -viewSndGroupFileCancelled :: FileTransferMeta -> [SndFileTransfer] -> [StyledString] -viewSndGroupFileCancelled FileTransferMeta {fileId, fileName} fts = +viewSndFileCancelled :: FileTransferMeta -> [SndFileTransfer] -> [StyledString] +viewSndFileCancelled FileTransferMeta {fileId, fileName} fts = case filter (\SndFileTransfer {fileStatus = s} -> s /= FSCancelled && s /= FSComplete) fts of [] -> ["cancelled sending " <> fileTransferStr fileId fileName] ts -> ["cancelled sending " <> fileTransferStr fileId fileName <> " to " <> listRecipients ts] @@ -1274,6 +1273,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] 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]