diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index 2920ba6980..d0f012c6aa 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "chat.simplex.app" minSdk 29 targetSdk 32 - versionCode 85 - versionName "4.4.1-beta.0" + versionCode 86 + versionName "4.4.1-beta.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" ndk { 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 e3dcc4911b..2e689f24cd 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 @@ -180,7 +180,11 @@ class ChatModel(val controller: ChatController) { } // add to current chat if (chatId.value == cInfo.id) { - chatItems.add(cItem) + if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { + chatItems.add(kotlin.math.max(0, chatItems.lastIndex), cItem) + } else { + chatItems.add(cItem) + } } } @@ -255,6 +259,18 @@ class ChatModel(val controller: ChatController) { } } + fun addLiveDummy(chatInfo: ChatInfo): ChatItem { + val cItem = ChatItem.liveDummy(chatInfo is ChatInfo.Direct) + chatItems.add(cItem) + return cItem + } + + fun removeLiveDummy() { + if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { + chatItems.removeLast() + } + } + fun markChatItemsRead(cInfo: ChatInfo, range: CC.ItemRange? = null, unreadCountAfter: Int? = null) { val markedRead = markItemsReadInCurrentChat(cInfo, range) // update preview @@ -1278,7 +1294,8 @@ data class ChatItem ( } private const val TEMP_DELETED_CHAT_ITEM_ID = -1L - + const val TEMP_LIVE_CHAT_ITEM_ID = -2L + val deletedItemDummy: ChatItem get() = ChatItem( chatDir = CIDirection.DirectRcv(), @@ -1300,6 +1317,26 @@ data class ChatItem ( file = null ) + fun liveDummy(direct: Boolean): ChatItem = ChatItem( + chatDir = if (direct) CIDirection.DirectSnd() else CIDirection.GroupSnd(), + meta = CIMeta( + itemId = TEMP_LIVE_CHAT_ITEM_ID, + itemTs = Clock.System.now(), + itemText = "", + itemStatus = CIStatus.RcvRead(), + createdAt = Clock.System.now(), + updatedAt = Clock.System.now(), + itemDeleted = false, + itemEdited = false, + itemTimed = null, + itemLive = true, + editable = false + ), + content = CIContent.SndMsgContent(MsgContent.MCText("")), + quotedItem = null, + file = null + ) + fun invalidJSON(json: String): ChatItem = ChatItem( chatDir = CIDirection.DirectSnd(), diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt index acfb205d38..feed70ce75 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt @@ -147,8 +147,8 @@ fun TerminalLayout( sendMessage = sendCommand, sendLiveMessage = null, updateLiveMessage = null, - ::onMessageChange, - textStyle + onMessageChange = ::onMessageChange, + textStyle = textStyle ) } }, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index e774bb9420..7d15b5ff67 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -568,7 +568,7 @@ fun BoxWithConstraintsScope.ChatItemsList( scope.launch { if (composeState.value.editing) { composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) - } else { + } else if (cItem.id != ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) } } 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 e4581931f7..7d712a8a7b 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 @@ -67,7 +67,8 @@ sealed class ComposeContextItem { data class LiveMessage( val chatItem: ChatItem, val typedMsg: String, - val sentMsg: String + val sentMsg: String, + val sent: Boolean ) @Serializable @@ -103,6 +104,9 @@ data class ComposeState( } hasContent && !inProgress } + val endLiveDisabled: Boolean + get() = liveMessage != null && message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem + val linkPreviewAllowed: Boolean get() = when (preview) { @@ -352,6 +356,7 @@ fun ComposeView( chosenContent.value = emptyList() chosenAudio.value = null chosenFile.value = null + chatModel.removeLiveDummy() } suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: String? = null, live: Boolean = false): ChatItem? { @@ -430,7 +435,7 @@ fun ComposeView( if (cs.contextItem is ComposeContextItem.EditingItem) { val ei = cs.contextItem.chatItem sent = updateMessage(ei, cInfo, live) - } else if (liveMessage != null) { + } else if (liveMessage != null && liveMessage.sent) { sent = updateMessage(liveMessage.chatItem, cInfo, live) } else { val msgs: ArrayList = ArrayList() @@ -569,13 +574,16 @@ fun ComposeView( } suspend fun sendLiveMessage() { - val typedMsg = composeState.value.message - val sentMsg = truncateToWords(typedMsg) - if (composeState.value.liveMessage == null) { - val ci = sendMessageAsync(sentMsg, live = true) + val cs = composeState.value + val typedMsg = cs.message + if ((cs.sendEnabled() || cs.contextItem is ComposeContextItem.QuotedItem) && (cs.liveMessage == null || !cs.liveMessage?.sent)) { + val ci = sendMessageAsync(typedMsg, live = true) if (ci != null) { - composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg)) + composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = typedMsg, sent = true)) } + } else if (cs.liveMessage == null) { + val cItem = chatModel.addLiveDummy(chat.chatInfo) + composeState.value = composeState.value.copy(liveMessage = LiveMessage(cItem, typedMsg = typedMsg, sentMsg = typedMsg, sent = false)) } } @@ -592,7 +600,7 @@ fun ComposeView( if (sentMsg != null) { val ci = sendMessageAsync(sentMsg, live = true) if (ci != null) { - composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg)) + composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg, sent = true)) } } else if (liveMessage.typedMsg != typedMsg) { composeState.value = composeState.value.copy(liveMessage = liveMessage.copy(typedMsg = typedMsg)) @@ -701,9 +709,13 @@ fun ComposeView( DisposableEffect(Unit) { val orientation = activity.resources.configuration.orientation onDispose { - if (orientation == activity.resources.configuration.orientation && composeState.value.liveMessage != null) { - sendMessage() - resetLinkPreview() + if (orientation == activity.resources.configuration.orientation) { + val cs = composeState.value + if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) { + sendMessage() + resetLinkPreview() + } + chatModel.removeLiveDummy() } } } @@ -723,6 +735,10 @@ fun ComposeView( }, sendLiveMessage = ::sendLiveMessage, updateLiveMessage = ::updateLiveMessage, + cancelLiveMessage = { + composeState.value = composeState.value.copy(liveMessage = null) + chatModel.removeLiveDummy() + }, onMessageChange = ::onMessageChange, textStyle = textStyle ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index 1bf9b70383..e84af43f0b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -37,7 +37,6 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.* import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.content.ContextCompat.getSystemService import androidx.core.graphics.drawable.DrawableCompat import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.view.inputmethod.InputConnectionCompat @@ -63,14 +62,15 @@ fun SendMsgView( allowedVoiceByPrefs: Boolean, allowVoiceToContact: () -> Unit, sendMessage: () -> Unit, - sendLiveMessage: ( suspend () -> Unit)? = null, + sendLiveMessage: (suspend () -> Unit)? = null, updateLiveMessage: (suspend () -> Unit)? = null, + cancelLiveMessage: (() -> Unit)? = null, onMessageChange: (String) -> Unit, textStyle: MutableState ) { Box(Modifier.padding(vertical = 8.dp)) { val cs = composeState.value - val showProgress = cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview) + val showProgress = cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview) val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) NativeKeyboard(composeState, textStyle, onMessageChange) @@ -109,7 +109,10 @@ fun SendMsgView( else -> RecordVoiceView(recState, stopRecOnNextClick) } - if (sendLiveMessage != null && updateLiveMessage != null && (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value)) { + if (sendLiveMessage != null + && updateLiveMessage != null + && (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value) + && cs.contextItem is ComposeContextItem.NoContextItem) { Spacer(Modifier.width(10.dp)) StartLiveMessageButton { if (composeState.value.preview is ComposePreview.NoPreview) { @@ -119,15 +122,24 @@ fun SendMsgView( } } } + cs.liveMessage?.sent == false && cs.message.isEmpty() -> { + CancelLiveMessageButton { + cancelLiveMessage?.invoke() + } + } else -> { + val cs = composeState.value val icon = if (cs.editing || cs.liveMessage != null) Icons.Filled.Check else Icons.Outlined.ArrowUpward - val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight - if (composeState.value.liveMessage == null && + val disabled = !cs.sendEnabled() || + (!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) || + cs.endLiveDisabled + if (cs.liveMessage == null && cs.preview !is ComposePreview.VoicePreview && !cs.editing && + cs.contextItem is ComposeContextItem.NoContextItem && sendLiveMessage != null && updateLiveMessage != null ) { var showDropdown by rememberSaveable { mutableStateOf(false) } - SendTextButton(icon, color, sendButtonSize, sendButtonAlpha, cs.sendEnabled(), sendMessage) { showDropdown = true } + SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage) { showDropdown = true } DropdownMenu( expanded = showDropdown, @@ -144,7 +156,7 @@ fun SendMsgView( ) } } else { - SendTextButton(icon, color, sendButtonSize, sendButtonAlpha, cs.sendEnabled(), sendMessage) + SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage) } } } @@ -166,7 +178,6 @@ private fun NativeKeyboard( val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() } val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() } val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() } - var showKeyboard by remember { mutableStateOf(false) } LaunchedEffect(cs.contextItem) { if (cs.contextItem is ComposeContextItem.QuotedItem) { @@ -187,6 +198,7 @@ private fun NativeKeyboard( ) { super.setOnReceiveContentListener(mimeTypes, listener) } + override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection { val connection = super.onCreateInputConnection(editorInfo) EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*")) @@ -339,7 +351,6 @@ private fun LockToCurrentOrientationUntilDispose() { } } - @Composable private fun StopRecordButton(onClick: () -> Unit) { IconButton(onClick, Modifier.size(36.dp)) { @@ -374,9 +385,24 @@ private fun ProgressIndicator() { } @Composable -private fun SendTextButton( +private fun CancelLiveMessageButton( + onClick: () -> Unit +) { + IconButton(onClick, Modifier.size(36.dp)) { + Icon( + Icons.Filled.Close, + stringResource(R.string.icon_descr_cancel_live_message), + tint = MaterialTheme.colors.primary, + modifier = Modifier + .size(36.dp) + .padding(4.dp) + ) + } +} + +@Composable +private fun SendMsgButton( icon: ImageVector, - backgroundColor: Color, sizeDp: Animatable, alpha: Animatable, enabled: Boolean, @@ -405,7 +431,7 @@ private fun SendTextButton( .padding(4.dp) .alpha(alpha.value) .clip(CircleShape) - .background(backgroundColor) + .background(if (enabled) MaterialTheme.colors.primary else HighOrLowlight) .padding(3.dp) ) } @@ -552,7 +578,7 @@ fun PreviewSendMsgViewEditing() { SendMsgView( composeState = remember { mutableStateOf(composeStateEditing) }, showVoiceRecordIcon = false, - recState = remember { mutableStateOf(RecordingState.NotStarted) }, + recState = remember { mutableStateOf(RecordingState.NotStarted) }, isDirectChat = true, liveMessageAlertShown = SharedPreference(get = { true }, set = { }), needToAllowVoiceToContact = false, 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 1eb4f21cee..4749390646 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 @@ -54,6 +54,7 @@ fun ChatItemView( val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) } val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file) val onLinkLongClick = { _: String -> showMenu.value = true } + val live = composeState.value.liveMessage != null Box( modifier = Modifier @@ -97,7 +98,7 @@ fun ChatItemView( onDismissRequest = { showMenu.value = false }, Modifier.width(220.dp) ) { - if (!cItem.meta.itemDeleted) { + if (!cItem.meta.itemDeleted && !live) { ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = { if (composeState.value.editing) { composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) @@ -133,7 +134,7 @@ fun ChatItemView( }) } } - if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice) { + if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = { composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews) showMenu.value = false @@ -149,7 +150,9 @@ fun ChatItemView( } ) } - DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage) + if (!(live && cItem.meta.isLive)) { + DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage) + } } } diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 7fd343c223..e40198c80a 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -271,6 +271,7 @@ Live message! Send a live message - it will update for the recipient(s) as you type it Send + Cancel live message Back diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index d55c7d5a37..5a15c4946a 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -219,7 +219,7 @@ final class ChatModel: ObservableObject { private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool { if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) { let ci = reversedChatItems[i] - withAnimation(.default) { + withAnimation { self.reversedChatItems[i] = cItem self.reversedChatItems[i].viewTimestamp = .now // on some occasions the confirmation of message being accepted by the server (tick) @@ -230,9 +230,18 @@ final class ChatModel: ObservableObject { } return false } else { - withAnimation { reversedChatItems.insert(cItem, at: 0) } + withAnimation(itemAnimation()) { + reversedChatItems.insert(cItem, at: hasLiveDummy ? 1 : 0) + } return true } + + func itemAnimation() -> Animation? { + switch cItem.chatDir { + case .directSnd, .groupSnd: return cItem.meta.isLive ? nil : .default + default: return .default + } + } } func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { @@ -274,6 +283,28 @@ final class ChatModel: ObservableObject { return nil } + func addLiveDummy(_ chatInfo: ChatInfo) -> ChatItem { + let cItem = ChatItem.liveDummy(chatInfo.chatType) + withAnimation { + reversedChatItems.insert(cItem, at: 0) + } + return cItem + } + + func removeLiveDummy(animated: Bool = true) { + if hasLiveDummy { + if animated { + withAnimation { _ = reversedChatItems.removeFirst() } + } else { + _ = reversedChatItems.removeFirst() + } + } + } + + private var hasLiveDummy: Bool { + reversedChatItems.first?.isLiveDummy == true + } + func markChatItemsRead(_ cInfo: ChatInfo) { // update preview _updateChat(cInfo.id) { chat in diff --git a/apps/ios/Shared/Model/ImageUtils.swift b/apps/ios/Shared/Model/ImageUtils.swift index 6de6dd024f..79382e4d6d 100644 --- a/apps/ios/Shared/Model/ImageUtils.swift +++ b/apps/ios/Shared/Model/ImageUtils.swift @@ -49,8 +49,9 @@ func saveAnimImage(_ image: UIImage) -> String? { } func saveImage(_ uiImage: UIImage) -> String? { - if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE) { - let ext = imageHasAlpha(uiImage) ? "png" : "jpg" + let hasAlpha = imageHasAlpha(uiImage) + let ext = hasAlpha ? "png" : "jpg" + if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE, hasAlpha: hasAlpha) { let fileName = generateNewFileName("IMG", ext) return saveFile(imageDataResized, fileName) } @@ -67,19 +68,18 @@ func cropToSquare(_ image: UIImage) -> UIImage { } else if size.height > side { origin.y -= (size.height - side) / 2 } - return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size)) + return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size), hasAlpha: imageHasAlpha(image)) } -func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64) -> Data? { +func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64, hasAlpha: Bool) -> Data? { var img = image - let usePng = imageHasAlpha(image) - var data = usePng ? img.pngData() : img.jpegData(compressionQuality: 0.85) + var data = hasAlpha ? img.pngData() : img.jpegData(compressionQuality: 0.85) var dataSize = data?.count ?? 0 while dataSize != 0 && dataSize > maxDataSize { let ratio = sqrt(Double(dataSize) / Double(maxDataSize)) let clippedRatio = min(ratio, 2.0) - img = reduceSize(img, ratio: clippedRatio) - data = usePng ? img.pngData() : img.jpegData(compressionQuality: 0.85) + img = reduceSize(img, ratio: clippedRatio, hasAlpha: hasAlpha) + data = hasAlpha ? img.pngData() : img.jpegData(compressionQuality: 0.85) dataSize = data?.count ?? 0 } logger.debug("resizeImageToDataSize final \(dataSize)") @@ -88,45 +88,61 @@ func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64) -> Data? { func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String? { var img = image - var str = compressImageStr(img) + let hasAlpha = imageHasAlpha(image) + var str = compressImageStr(img, hasAlpha: hasAlpha) var dataSize = str?.count ?? 0 while dataSize != 0 && dataSize > maxDataSize { let ratio = sqrt(Double(dataSize) / Double(maxDataSize)) let clippedRatio = min(ratio, 2.0) - img = reduceSize(img, ratio: clippedRatio) - str = compressImageStr(img) + img = reduceSize(img, ratio: clippedRatio, hasAlpha: hasAlpha) + str = compressImageStr(img, hasAlpha: hasAlpha) dataSize = str?.count ?? 0 } logger.debug("resizeImageToStrSize final \(dataSize)") return str } -func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85) -> String? { - let ext = imageHasAlpha(image) ? "png" : "jpg" - if let data = imageHasAlpha(image) ? image.pngData() : image.jpegData(compressionQuality: compressionQuality) { +func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85, hasAlpha: Bool) -> String? { + let ext = hasAlpha ? "png" : "jpg" + if let data = hasAlpha ? image.pngData() : image.jpegData(compressionQuality: compressionQuality) { return "data:image/\(ext);base64,\(data.base64EncodedString())" } return nil } -private func reduceSize(_ image: UIImage, ratio: CGFloat) -> UIImage { +private func reduceSize(_ image: UIImage, ratio: CGFloat, hasAlpha: Bool) -> UIImage { let newSize = CGSize(width: floor(image.size.width / ratio), height: floor(image.size.height / ratio)) let bounds = CGRect(origin: .zero, size: newSize) - return resizeImage(image, newBounds: bounds, drawIn: bounds) + return resizeImage(image, newBounds: bounds, drawIn: bounds, hasAlpha: hasAlpha) } -private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect) -> UIImage { +private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect, hasAlpha: Bool) -> UIImage { let format = UIGraphicsImageRendererFormat() format.scale = 1.0 - format.opaque = !imageHasAlpha(image) + format.opaque = !hasAlpha return UIGraphicsImageRenderer(bounds: newBounds, format: format).image { _ in image.draw(in: drawIn) } } func imageHasAlpha(_ img: UIImage) -> Bool { - let alpha = img.cgImage?.alphaInfo - return alpha == .first || alpha == .last || alpha == .premultipliedFirst || alpha == .premultipliedLast || alpha == .alphaOnly + if let cgImage = img.cgImage { + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue) + if let context = CGContext(data: nil, width: cgImage.width, height: cgImage.height, bitsPerComponent: 8, bytesPerRow: cgImage.width * 4, space: colorSpace, bitmapInfo: bitmapInfo.rawValue) { + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height)) + if let data = context.data { + let data = data.assumingMemoryBound(to: UInt8.self) + let size = cgImage.width * cgImage.height + var i = 0 + while i < size { + if data[i] < 255 { return true } + i += 4 + } + } + } + } + return false } func saveFileFromURL(_ url: URL) -> String? { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index affd9fe797..fd94e96a33 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -442,9 +442,13 @@ struct ChatView: View { var body: some View { let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading - + let uiMenu: Binding = Binding( + get: { UIMenu(title: "", children: menu(live: composeState.liveMessage != nil)) }, + set: { _ in } + ) + ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed) - .uiKitContextMenu(actions: menu()) + .uiKitContextMenu(menu: uiMenu) .confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) { Button("Delete for me", role: .destructive) { deleteMessage(.cidmInternal) @@ -459,10 +463,10 @@ struct ChatView: View { .frame(minWidth: 0, maxWidth: .infinity, alignment: alignment) } - private func menu() -> [UIAction] { + private func menu(live: Bool) -> [UIAction] { var menu: [UIAction] = [] if let mc = ci.content.msgContent, !ci.meta.itemDeleted || revealed { - if !ci.meta.itemDeleted { + if !ci.meta.itemDeleted && !ci.isLiveDummy && !live { menu.append(replyUIAction()) } menu.append(shareUIAction()) @@ -478,13 +482,15 @@ struct ChatView: View { menu.append(saveFileAction(filePath)) } } - if ci.meta.editable && !mc.isVoice { + if ci.meta.editable && !mc.isVoice && !live { menu.append(editAction()) } if revealed { menu.append(hideUIAction()) } - menu.append(deleteUIAction()) + if !live || !ci.meta.isLive { + menu.append(deleteUIAction()) + } } else if ci.meta.itemDeleted { menu.append(revealUIAction()) menu.append(deleteUIAction()) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index fff6f5a178..5ca4627917 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -34,7 +34,7 @@ enum VoiceMessageRecordingState { struct LiveMessage { var chatItem: ChatItem var typedMsg: String - var sentMsg: String + var sentMsg: String? } struct ComposeState { @@ -96,6 +96,13 @@ struct ComposeState { } } + var quoting: Bool { + switch contextItem { + case .quotedItem: return true + default: return false + } + } + var sendEnabled: Bool { switch preview { case .imagePreviews: return true @@ -105,6 +112,10 @@ struct ComposeState { } } + var endLiveDisabled: Bool { + liveMessage != nil && message.isEmpty && noPreview && !quoting + } + var linkPreviewAllowed: Bool { switch preview { case .imagePreviews: return false @@ -232,9 +243,9 @@ struct ComposeView: View { VStack(spacing: 0) { contextItemView() switch (composeState.editing, composeState.preview) { - case (true, .filePreview): EmptyView() - case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed - default: previewView() + case (true, .filePreview): EmptyView() + case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed + default: previewView() } HStack (alignment: .bottom) { Button { @@ -255,6 +266,10 @@ struct ComposeView: View { }, sendLiveMessage: sendLiveMessage, updateLiveMessage: updateLiveMessage, + cancelLiveMessage: { + composeState.liveMessage = nil + chatModel.removeLiveDummy() + }, voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice), showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert, startVoiceMessageRecording: { @@ -371,10 +386,11 @@ struct ComposeView: View { if let fileName = composeState.voiceMessageRecordingFileName { cancelVoiceMessageRecording(fileName) } - if composeState.liveMessage != nil { + if composeState.liveMessage != nil && (!composeState.message.isEmpty || composeState.liveMessage?.sentMsg != nil) { sendMessage() resetLinkPreview() } + chatModel.removeLiveDummy(animated: false) } .onChange(of: chatModel.stopPreviousRecPlay) { _ in if !startingRecording { @@ -395,11 +411,17 @@ struct ComposeView: View { private func sendLiveMessage() async { let typedMsg = composeState.message - let sentMsg = truncateToWords(typedMsg) - if composeState.liveMessage == nil, - let ci = await sendMessageAsync(sentMsg, live: true) { + let lm = composeState.liveMessage + if (composeState.sendEnabled || composeState.quoting) + && (lm == nil || lm?.sentMsg == nil), + let ci = await sendMessageAsync(typedMsg, live: true) { await MainActor.run { - composeState = composeState.copy(liveMessage: LiveMessage(chatItem: ci, typedMsg: typedMsg, sentMsg: sentMsg)) + composeState = composeState.copy(liveMessage: LiveMessage(chatItem: ci, typedMsg: typedMsg, sentMsg: typedMsg)) + } + } else if lm == nil { + let cItem = chatModel.addLiveDummy(chat.chatInfo) + await MainActor.run { + composeState = composeState.copy(liveMessage: LiveMessage(chatItem: cItem, typedMsg: typedMsg, sentMsg: nil)) } } } @@ -424,7 +446,7 @@ struct ComposeView: View { private func liveMessageToSend(_ lm: LiveMessage, _ t: String) -> String? { let s = t != lm.typedMsg ? truncateToWords(t) : t - return s != lm.sentMsg ? s : nil + return s != lm.sentMsg && (lm.sentMsg != nil || !s.isEmpty) ? s : nil } private func truncateToWords(_ s: String) -> String { @@ -512,7 +534,7 @@ struct ComposeView: View { } if case let .editingItem(ci) = composeState.contextItem { sent = await updateMessage(ci, live: live) - } else if let liveMessage = liveMessage { + } else if let liveMessage = liveMessage, liveMessage.sentMsg != nil { sent = await updateMessage(liveMessage.chatItem, live: live) } else { var quoted: Int64? = nil @@ -609,6 +631,7 @@ struct ComposeView: View { live: live ) { await MainActor.run { + chatModel.removeLiveDummy(animated: false) chatModel.addChatItem(chat.chatInfo, chatItem) } return chatItem diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 7995c8be41..b5f8e9b5fd 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -9,11 +9,14 @@ import SwiftUI import SimpleXChat +private let liveMsgInterval: UInt64 = 3000_000000 + struct SendMessageView: View { @Binding var composeState: ComposeState var sendMessage: () -> Void var sendLiveMessage: (() async -> Void)? = nil var updateLiveMessage: (() async -> Void)? = nil + var cancelLiveMessage: (() -> Void)? = nil var showVoiceMessageButton: Bool = true var voiceMessageAllowed: Bool = true var showEnableVoiceMessagesAlert: ChatInfo.ShowEnableVoiceMessagesAlert = .other @@ -97,12 +100,18 @@ struct SendMessageView: View { } else { voiceMessageNotAllowedButton() } - if let send = sendLiveMessage, let update = updateLiveMessage { + if let send = sendLiveMessage, + let update = updateLiveMessage, + case .noContextItem = composeState.contextItem { startLiveMessageButton(send: send, update: update) } } } else if vmrs == .recording && !holdingVMR { finishVoiceMessageRecordingButton() + } else if composeState.liveMessage != nil && composeState.liveMessage?.sentMsg == nil && composeState.message.isEmpty { + cancelLiveMessageButton { + cancelLiveMessage?() + } } else { sendMessageButton() } @@ -129,11 +138,13 @@ struct SendMessageView: View { .disabled( !composeState.sendEnabled || composeState.disabled || - (!voiceMessageAllowed && composeState.voicePreview) + (!voiceMessageAllowed && composeState.voicePreview) || + composeState.endLiveDisabled ) .frame(width: 29, height: 29) if composeState.liveMessage == nil, + case .noContextItem = composeState.contextItem, !composeState.voicePreview && !composeState.editing, let send = sendLiveMessage, let update = updateLiveMessage { @@ -220,6 +231,20 @@ struct SendMessageView: View { .padding([.bottom, .trailing], 4) } + private func cancelLiveMessageButton(cancel: @escaping () -> Void) -> some View { + return Button { + cancel() + } label: { + Image(systemName: "multiply") + .resizable() + .scaledToFit() + .foregroundColor(.accentColor) + .frame(width: 15, height: 15) + } + .frame(width: 29, height: 29) + .padding([.bottom, .horizontal], 4) + } + private func startLiveMessageButton(send: @escaping () async -> Void, update: @escaping () async -> Void) -> some View { return Button { switch composeState.preview { @@ -271,9 +296,12 @@ struct SendMessageView: View { sendButtonOpacity = 1 } } - Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { t in - if composeState.liveMessage == nil { t.invalidate() } - Task { await update() } + Task { + _ = try? await Task.sleep(nanoseconds: liveMsgInterval) + while composeState.liveMessage != nil { + await update() + _ = try? await Task.sleep(nanoseconds: liveMsgInterval) + } } } } diff --git a/apps/ios/Shared/Views/Helpers/ContextMenu.swift b/apps/ios/Shared/Views/Helpers/ContextMenu.swift index e2342c274b..4efc114032 100644 --- a/apps/ios/Shared/Views/Helpers/ContextMenu.swift +++ b/apps/ios/Shared/Views/Helpers/ContextMenu.swift @@ -11,10 +11,10 @@ import UIKit import SwiftUI extension View { - func uiKitContextMenu(title: String = "", actions: [UIAction]) -> some View { + func uiKitContextMenu(menu: Binding) -> some View { self.overlay(Color(uiColor: .systemBackground)) .overlay( - InteractionView(content: self, menu: UIMenu(title: title, children: actions)) + InteractionView(content: self, menu: menu) ) } } @@ -26,7 +26,7 @@ private struct InteractionConfig { private struct InteractionView: UIViewRepresentable { let content: Content - let menu: UIMenu + @Binding var menu: UIMenu func makeUIView(context: Context) -> UIView { let view = UIView() diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 1d46f24ed0..97ba41184c 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -1305,7 +1305,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 111; + CURRENT_PROJECT_VERSION = 112; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1347,7 +1347,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 111; + CURRENT_PROJECT_VERSION = 112; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1426,7 +1426,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 111; + CURRENT_PROJECT_VERSION = 112; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1456,7 +1456,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 111; + CURRENT_PROJECT_VERSION = 112; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 65fbd7a663..0c2f071e0f 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1670,6 +1670,7 @@ public struct ChatItem: Identifiable, Decodable { public var file: CIFile? public var viewTimestamp = Date.now + public var isLiveDummy: Bool = false private enum CodingKeys: String, CodingKey { case chatDir, meta, content, formattedText, quotedItem, file @@ -1862,6 +1863,29 @@ public struct ChatItem: Identifiable, Decodable { ) } + public static func liveDummy(_ chatType: ChatType) -> ChatItem { + var item = ChatItem( + chatDir: chatType == ChatType.direct ? CIDirection.directSnd : CIDirection.groupSnd, + meta: CIMeta( + itemId: -2, + itemTs: .now, + itemText: "", + itemStatus: .rcvRead, + createdAt: .now, + updatedAt: .now, + itemDeleted: false, + itemEdited: false, + itemLive: true, + editable: false + ), + content: .sndMsgContent(msgContent: .text("")), + quotedItem: nil, + file: nil + ) + item.isLiveDummy = true + return item + } + public static func invalidJSON(_ json: String) -> ChatItem { ChatItem( chatDir: CIDirection.directSnd, diff --git a/package.yaml b/package.yaml index c78393562c..725f901fb4 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 4.4.0 +version: 4.4.1 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/scripts/android/download_libs_aarch64.sh b/scripts/android/download_libs_aarch64.sh index 07541c69ca..d54a6fd23d 100755 --- a/scripts/android/download_libs_aarch64.sh +++ b/scripts/android/download_libs_aarch64.sh @@ -7,7 +7,7 @@ function readlink() { } if [ -z ${1} ]; then - echo "Job repo is unset. Provide it via first argument like: $(readlink $0)/download_libs_aarch64.sh https://something.com/job/something" + echo "Job repo is unset. Provide it via first argument like: $(readlink $0)/download_libs_aarch64.sh https://something.com/job/something/{master,stable}" exit 1 fi @@ -22,12 +22,12 @@ output_dir="$root_dir/apps/android/app/src/main/cpp/libs/$output_arch/" mkdir -p "$output_dir" 2> /dev/null -curl --location -o libsupport.zip $job_repo/simplex-chat/$arch-android:lib:support.x86_64-linux/latest/download/1 && \ +curl --location -o libsupport.zip $job_repo/$arch-android:lib:support.x86_64-linux/latest/download/1 && \ unzip -o libsupport.zip && \ mv libsupport.so "$output_dir" && \ rm libsupport.zip -curl --location -o libsimplex.zip $job_repo/simplex-chat/$arch-android:lib:simplex-chat.x86_64-linux/latest/download/1 && \ +curl --location -o libsimplex.zip $job_repo/$arch-android:lib:simplex-chat.x86_64-linux/latest/download/1 && \ unzip -o libsimplex.zip && \ mv libsimplex.so "$output_dir" && \ rm libsimplex.zip diff --git a/scripts/ios/download_libs.sh b/scripts/ios/download_libs.sh index fa50a5b124..b6d253a9c4 100755 --- a/scripts/ios/download_libs.sh +++ b/scripts/ios/download_libs.sh @@ -7,7 +7,7 @@ function readlink() { } if [ -z ${1} ]; then - echo "Job repo is unset. Provide it via first argument like: $(readlink $0)/download_libs_aarch64.sh https://something.com/job/something" + echo "Job repo is unset. Provide it via first argument like: $(readlink $0)/download_libs_aarch64.sh https://something.com/job/something/{master,stable}" exit 1 fi @@ -15,10 +15,10 @@ job_repo=$1 root_dir="$(dirname $(dirname $(readlink $0)))" -curl --location -o ~/Downloads/pkg-ios-aarch64-swift-json.zip $job_repo/simplex-chat/aarch64-darwin-ios:lib:simplex-chat.aarch64-darwin/latest/download/1 && \ +curl --location -o ~/Downloads/pkg-ios-aarch64-swift-json.zip $job_repo/aarch64-darwin-ios:lib:simplex-chat.aarch64-darwin/latest/download/1 && \ unzip -o ~/Downloads/pkg-ios-aarch64-swift-json.zip -d ~/Downloads/pkg-ios-aarch64-swift-json -curl --location -o ~/Downloads/pkg-ios-x86_64-swift-json.zip $job_repo/simplex-chat/x86_64-darwin-ios:lib:simplex-chat.x86_64-darwin/latest/download/1 && \ +curl --location -o ~/Downloads/pkg-ios-x86_64-swift-json.zip $job_repo/x86_64-darwin-ios:lib:simplex-chat.x86_64-darwin/latest/download/1 && \ unzip -o ~/Downloads/pkg-ios-x86_64-swift-json.zip -d ~/Downloads/pkg-ios-x86_64-swift-json sh $root_dir/scripts/ios/prepare-x86_64.sh diff --git a/simplex-chat.cabal b/simplex-chat.cabal index b8bf55e631..93c734e06a 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 4.4.0 +version: 4.4.1 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 92c24d47d4..2642a856ea 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -228,13 +228,21 @@ restoreCalls user = do calls <- asks currentCalls atomically $ writeTVar calls callsMap -stopChatController :: MonadUnliftIO m => ChatController -> m () -stopChatController ChatController {smpAgent, agentAsync = s, expireCIs} = do +stopChatController :: forall m. MonadUnliftIO m => ChatController -> m () +stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIs} = do disconnectAgentClient smpAgent readTVarIO s >>= mapM_ (\(a1, a2) -> uninterruptibleCancel a1 >> mapM_ uninterruptibleCancel a2) + closeFiles sndFiles + closeFiles rcvFiles atomically $ do writeTVar expireCIs False writeTVar s Nothing + where + closeFiles :: TVar (Map Int64 Handle) -> m () + closeFiles files = do + fs <- readTVarIO files + mapM_ hClose fs + atomically $ writeTVar files M.empty execChatCommand :: (MonadUnliftIO m, MonadReader ChatController m) => ByteString -> m ChatResponse execChatCommand s = case parseChatCommand s of @@ -1831,7 +1839,7 @@ subscribeUserConnections agentBatchSubscribe user = do pendingConnSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId PendingContactConnection -> m () pendingConnSubsToView rs = toView . CRPendingSubSummary . map (uncurry PendingSubStatus) . resultsFor rs withStore_ :: (DB.Connection -> User -> IO [a]) -> m [a] - withStore_ a = withStore' (`a` user) `catchError` \_ -> pure [] + withStore_ a = withStore' (`a` user) `catchError` \e -> toView (CRChatError (Just user) e) >> pure [] filterErrors :: [(a, Maybe ChatError)] -> [(a, ChatError)] filterErrors = mapMaybe (\(a, e_) -> (a,) <$> e_) resultsFor :: Map ConnId (Either AgentErrorType ()) -> Map ConnId a -> [(a, Maybe ChatError)] @@ -3357,8 +3365,7 @@ getFileHandle fileId filePath files ioMode = do maybe (newHandle fs) pure h_ where newHandle fs = do - -- TODO handle errors - h <- liftIO (openFile filePath ioMode) + h <- liftIO (openFile filePath ioMode) `E.catch` (throwChatError . CEFileInternal . (show :: E.SomeException -> String)) atomically . modifyTVar fs $ M.insert fileId h pure h diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 0e6d302044..c415f3b949 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -1292,7 +1292,7 @@ getLiveSndFileTransfers db User {userId} = do FROM files f JOIN snd_files s USING (file_id) WHERE f.user_id = ? AND s.file_status IN (?, ?, ?) AND s.file_inline IS NULL - AND created_at > ? + AND s.created_at > ? |] (userId, FSNew, FSAccepted, FSConnected, cutoffTs) concatMap (filter liveTransfer) . rights <$> mapM (getSndFileTransfers_ db userId) fileIds @@ -1312,7 +1312,7 @@ getLiveRcvFileTransfers db user@User {userId} = do FROM files f JOIN rcv_files r USING (file_id) WHERE f.user_id = ? AND r.file_status IN (?, ?) AND r.rcv_file_inline IS NULL - AND created_at > ? + AND r.created_at > ? |] (userId, FSAccepted, FSConnected, cutoffTs) rights <$> mapM (runExceptT . getRcvFileTransfer db user) fileIds diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 6ca6f940bf..580b206967 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -159,10 +159,12 @@ chatTests = do -- it "v1 to v2" testFullAsyncV1toV2 -- it "v2 to v1" testFullAsyncV2toV1 describe "async sending and receiving files" $ do + it "send and receive file, sender restarts" testAsyncFileTransferSenderRestarts + it "send and receive file, receiver restarts" testAsyncFileTransferReceiverRestarts xdescribe "send and receive file, fully asynchronous" $ do it "v2" testAsyncFileTransfer it "v1" testAsyncFileTransferV1 - xit "send and receive file to group, fully asynchronous" testAsyncGroupFileTransfer + it "send and receive file to group, fully asynchronous" testAsyncGroupFileTransfer describe "webrtc calls api" $ do it "negotiate call" testNegotiateCall describe "maintenance mode" $ do @@ -4013,6 +4015,34 @@ testFullAsyncV2toV1 = withTmpFiles $ do withNewBob = withNewTestChat "bob" bobProfile withBob = withTestChat "bob" +testAsyncFileTransferSenderRestarts :: IO () +testAsyncFileTransferSenderRestarts = withTmpFiles $ do + withNewTestChat "bob" bobProfile $ \bob -> do + withNewTestChat "alice" aliceProfile $ \alice -> do + connectUsers alice bob + startFileTransfer' alice bob "test_1MB.pdf" "1017.7 KiB / 1042157 bytes" + threadDelay 100000 + withTestChatContactConnected "alice" $ \alice -> do + alice <## "completed sending file 1 (test_1MB.pdf) to bob" + bob <## "completed receiving file 1 (test_1MB.pdf) from alice" + src <- B.readFile "./tests/fixtures/test_1MB.pdf" + dest <- B.readFile "./tests/tmp/test_1MB.pdf" + dest `shouldBe` src + +testAsyncFileTransferReceiverRestarts :: IO () +testAsyncFileTransferReceiverRestarts = withTmpFiles $ do + withNewTestChat "alice" aliceProfile $ \alice -> do + withNewTestChat "bob" bobProfile $ \bob -> do + connectUsers alice bob + startFileTransfer' alice bob "test_1MB.pdf" "1017.7 KiB / 1042157 bytes" + threadDelay 100000 + withTestChatContactConnected "bob" $ \bob -> do + alice <## "completed sending file 1 (test_1MB.pdf) to bob" + bob <## "completed receiving file 1 (test_1MB.pdf) from alice" + src <- B.readFile "./tests/fixtures/test_1MB.pdf" + dest <- B.readFile "./tests/tmp/test_1MB.pdf" + dest `shouldBe` src + testAsyncFileTransfer :: IO () testAsyncFileTransfer = withTmpFiles $ do withNewTestChat "alice" aliceProfile $ \alice ->