From a90641c1d1c335be62391bcd892fbded92b02a6e Mon Sep 17 00:00:00 2001 From: Seth For Privacy Date: Thu, 7 Sep 2023 05:03:32 -0400 Subject: [PATCH 1/5] Fix broken RSS feed source URL (#3033) --- website/src/blogs-atom-feed.njk | 2 +- website/src/blogs-rss-feed.njk | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/blogs-atom-feed.njk b/website/src/blogs-atom-feed.njk index ea58fa2d1d..849cedcf1c 100644 --- a/website/src/blogs-atom-feed.njk +++ b/website/src/blogs-atom-feed.njk @@ -5,7 +5,7 @@ metadata: title: SimpleX Chat Blog subtitle: It allows you to stay up to date with the latest Blogs from SimpleX Chat. language: en - url: https://simplex.chat/, + url: https://simplex.chat/ author: name: SimpleX Chat email: chat@simplex.chat diff --git a/website/src/blogs-rss-feed.njk b/website/src/blogs-rss-feed.njk index 5163a7520e..c84362eabd 100644 --- a/website/src/blogs-rss-feed.njk +++ b/website/src/blogs-rss-feed.njk @@ -5,7 +5,7 @@ metadata: title: SimpleX Chat Blog subtitle: It allows you to stay up to date with the latest Blogs from SimpleX Chat. language: en - url: https://simplex.chat/, + url: https://simplex.chat/ author: name: SimpleX Chat email: chat@simplex.chat From a27f30ce12f26f130646f79251be6dad1e887a41 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 7 Sep 2023 12:59:37 +0300 Subject: [PATCH 2/5] android: changing a chat on user change (#3027) * android: changing a chat on user change * test * test2 * Revert "test2" This reverts commit 198873ecad601c0acfba8f8bf3c7aaa274cb54e8. * Revert "test" This reverts commit 6e0e3d49309171b38bebb9f55f855db7b85836c5. * style --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../kotlin/chat/simplex/common/App.kt | 17 ++++++++------- .../simplex/common/views/chat/ChatView.kt | 21 +++++++++---------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index 6b9770c09e..c08ad5f914 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -197,24 +197,25 @@ fun AndroidScreen(settingsState: SettingsViewState) { StartPartOfScreen(settingsState) } val scope = rememberCoroutineScope() - val onComposed: () -> Unit = { + val onComposed: suspend (chatId: String?) -> Unit = { chatId -> + // coroutine, scope and join() because: + // - it should be run from coroutine to wait until this function finishes + // - without using scope.launch it throws CancellationException when changing user + // - join allows to wait until completion scope.launch { offset.animateTo( - if (chatModel.chatId.value == null) 0f else maxWidth.value, + if (chatId == null) 0f else maxWidth.value, chatListAnimationSpec() ) - if (offset.value == 0f) { - currentChatId = null - } - } + }.join() } LaunchedEffect(Unit) { launch { snapshotFlow { chatModel.chatId.value } .distinctUntilChanged() .collect { - if (it != null) currentChatId = it - else onComposed() + if (it == null) onComposed(null) + currentChatId = it } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 3370d34e73..e8afdfeb21 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -43,7 +43,7 @@ import java.net.URI import kotlin.math.sign @Composable -fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { +fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: String) -> Unit) { val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }) } val searchText = rememberSaveable { mutableStateOf("") } val user = chatModel.currentUser.value @@ -66,12 +66,11 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { launch { snapshotFlow { chatModel.chatId.value } .distinctUntilChanged() + .filter { it != null && activeChat.value?.id != it } .collect { chatId -> - if (activeChat.value?.id != chatId && chatId != null) { - // Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly - // Also for situation when chatId changes after clicking in notification, etc - activeChat.value = chatModel.getChat(chatId) - } + // Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly + // Also for situation when chatId changes after clicking in notification, etc + activeChat.value = chatModel.getChat(chatId!!) markUnreadChatAsRead(activeChat, chatModel) } } @@ -91,7 +90,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { } .distinctUntilChanged() // Only changed chatInfo is important thing. Other properties can be skipped for reducing recompositions - .filter { it?.chatInfo != activeChat.value?.chatInfo && it != null } + .filter { it != null && it?.chatInfo != activeChat.value?.chatInfo } .collect { activeChat.value = it } } } @@ -422,7 +421,7 @@ fun ChatLayout( markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, changeNtfsState: (Boolean, currentValue: MutableState) -> Unit, onSearchValueChanged: (String) -> Unit, - onComposed: () -> Unit, + onComposed: suspend (chatId: String) -> Unit, ) { val scope = rememberCoroutineScope() val attachmentDisabled = remember { derivedStateOf { composeState.value.attachmentDisabled } } @@ -672,7 +671,7 @@ fun BoxWithConstraintsScope.ChatItemsList( showItemDetails: (ChatInfo, ChatItem) -> Unit, markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, setFloatingButton: (@Composable () -> Unit) -> Unit, - onComposed: () -> Unit, + onComposed: suspend (chatId: String) -> Unit, ) { val listState = rememberLazyListState() val scope = rememberCoroutineScope() @@ -703,13 +702,13 @@ fun BoxWithConstraintsScope.ChatItemsList( scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.lastIndex, index + 1), -maxHeightRounded) } } } - LaunchedEffect(Unit) { + LaunchedEffect(chat.id) { var stopListening = false snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastIndex } .distinctUntilChanged() .filter { !stopListening } .collect { - onComposed() + onComposed(chat.id) stopListening = true } } From 748572ace91eb842c2941e8673d525dfcc5ae23c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 7 Sep 2023 11:28:37 +0100 Subject: [PATCH 3/5] ui: types and stubs to encrypt local files (#3003) * ui: types and stubs to encrypt local files * ios: encrypt automatically received images in local storage * encrypt sent images, marked to be received via NSE * ios: encrypt sent and received local voice files * encrypt sent and received local files * fix NSE * remove comment * decrypt files in background thread --- apps/ios/Shared/Model/AudioRecPlay.swift | 12 ++- apps/ios/Shared/Model/ImageUtils.swift | 79 ++++++++++--------- apps/ios/Shared/Model/SimpleXAPI.swift | 24 ++---- .../Views/Chat/ChatItem/CIFileView.swift | 36 +++++++-- .../Views/Chat/ChatItem/CIImageView.swift | 6 +- .../Views/Chat/ChatItem/CIMetaView.swift | 23 +++--- .../Chat/ChatItem/CIRcvDecryptionError.swift | 4 +- .../Views/Chat/ChatItem/CIVideoView.swift | 9 ++- .../Views/Chat/ChatItem/CIVoiceView.swift | 15 ++-- .../Views/Chat/ChatItem/FramedItemView.swift | 2 +- .../Views/Chat/ChatItem/MsgContentView.swift | 2 +- apps/ios/Shared/Views/Chat/ChatView.swift | 13 ++- .../Chat/ComposeMessage/ComposeView.swift | 45 +++++++---- .../ComposeMessage/ComposeVoiceView.swift | 2 +- .../ios/Shared/Views/Helpers/ShareSheet.swift | 6 +- .../Views/UserSettings/PrivacySettings.swift | 4 + .../ios/SimpleX NSE/NotificationService.swift | 16 ++-- apps/ios/SimpleX.xcodeproj/project.pbxproj | 6 +- apps/ios/SimpleXChat/APITypes.swift | 21 ++--- apps/ios/SimpleXChat/AppGroup.swift | 4 +- apps/ios/SimpleXChat/ChatTypes.swift | 40 +++++++++- apps/ios/SimpleXChat/CryptoFile.swift | 64 +++++++++++++++ apps/ios/SimpleXChat/FileUtils.swift | 25 +++++- apps/ios/SimpleXChat/SimpleX.h | 14 ++++ .../chat/simplex/common/model/ChatModel.kt | 17 +++- .../chat/simplex/common/model/SimpleXAPI.kt | 21 ++--- .../chat/simplex/common/platform/Files.kt | 5 +- .../simplex/common/views/chat/ChatView.kt | 12 +-- .../simplex/common/views/chat/ComposeView.kt | 13 +-- .../common/views/chat/item/CIFileView.kt | 4 +- .../common/views/chat/item/CIImageView.kt | 5 +- .../common/views/chat/item/CIVIdeoView.kt | 10 +-- .../common/views/chat/item/CIVoiceView.kt | 20 ++--- .../common/views/chat/item/ChatItemView.kt | 6 +- .../common/views/chat/item/FramedItemView.kt | 2 +- .../simplex/common/views/helpers/Utils.kt | 17 ++-- 36 files changed, 407 insertions(+), 197 deletions(-) create mode 100644 apps/ios/SimpleXChat/CryptoFile.swift diff --git a/apps/ios/Shared/Model/AudioRecPlay.swift b/apps/ios/Shared/Model/AudioRecPlay.swift index 698799457e..973d79ab3c 100644 --- a/apps/ios/Shared/Model/AudioRecPlay.swift +++ b/apps/ios/Shared/Model/AudioRecPlay.swift @@ -103,9 +103,15 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate { self.onFinishPlayback = onFinishPlayback } - func start(fileName: String, at: TimeInterval?) { - let url = getAppFilePath(fileName) - audioPlayer = try? AVAudioPlayer(contentsOf: url) + func start(fileSource: CryptoFile, at: TimeInterval?) { + let url = getAppFilePath(fileSource.filePath) + if let cfArgs = fileSource.cryptoArgs { + if let data = try? readCryptoFile(path: url.path, cryptoArgs: cfArgs) { + audioPlayer = try? AVAudioPlayer(data: data) + } + } else { + audioPlayer = try? AVAudioPlayer(contentsOf: url) + } audioPlayer?.delegate = self audioPlayer?.prepareToPlay() if let at = at { diff --git a/apps/ios/Shared/Model/ImageUtils.swift b/apps/ios/Shared/Model/ImageUtils.swift index 4987f5a6f7..90070e74d3 100644 --- a/apps/ios/Shared/Model/ImageUtils.swift +++ b/apps/ios/Shared/Model/ImageUtils.swift @@ -11,42 +11,43 @@ import SimpleXChat import SwiftUI import AVKit -func getLoadedFilePath(_ file: CIFile?) -> String? { - if let fileName = getLoadedFileName(file) { - return getAppFilePath(fileName).path - } - return nil -} - -func getLoadedFileName(_ file: CIFile?) -> String? { - if let file = file, - file.loaded, - let fileName = file.filePath { - return fileName +func getLoadedFileSource(_ file: CIFile?) -> CryptoFile? { + if let file = file, file.loaded { + return file.fileSource } return nil } func getLoadedImage(_ file: CIFile?) -> UIImage? { - let loadedFilePath = getLoadedFilePath(file) - if let loadedFilePath = loadedFilePath, let fileName = file?.filePath { - let filePath = getAppFilePath(fileName) + if let fileSource = getLoadedFileSource(file) { + let filePath = getAppFilePath(fileSource.filePath) do { - let data = try Data(contentsOf: filePath) + let data = try getFileData(filePath, fileSource.cryptoArgs) let img = UIImage(data: data) - try img?.setGifFromData(data, levelOfIntegrity: 1.0) - return img + do { + try img?.setGifFromData(data, levelOfIntegrity: 1.0) + return img + } catch { + return UIImage(data: data) + } } catch { - return UIImage(contentsOfFile: loadedFilePath) + return nil } } return nil } +func getFileData(_ path: URL, _ cfArgs: CryptoFileArgs?) throws -> Data { + if let cfArgs = cfArgs { + return try readCryptoFile(path: path.path, cryptoArgs: cfArgs) + } else { + return try Data(contentsOf: path) + } +} + func getLoadedVideo(_ file: CIFile?) -> URL? { - let loadedFilePath = getLoadedFilePath(file) - if loadedFilePath != nil, let fileName = file?.filePath { - let filePath = getAppFilePath(fileName) + if let fileSource = getLoadedFileSource(file) { + let filePath = getAppFilePath(fileSource.filePath) if FileManager.default.fileExists(atPath: filePath.path) { return filePath } @@ -54,18 +55,18 @@ func getLoadedVideo(_ file: CIFile?) -> URL? { return nil } -func saveAnimImage(_ image: UIImage) -> String? { +func saveAnimImage(_ image: UIImage) -> CryptoFile? { let fileName = generateNewFileName("IMG", "gif") guard let imageData = image.imageData else { return nil } - return saveFile(imageData, fileName) + return saveFile(imageData, fileName, encrypted: privacyEncryptLocalFilesGroupDefault.get()) } -func saveImage(_ uiImage: UIImage) -> String? { +func saveImage(_ uiImage: UIImage) -> CryptoFile? { 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) + return saveFile(imageDataResized, fileName, encrypted: privacyEncryptLocalFilesGroupDefault.get()) } return nil } @@ -157,13 +158,19 @@ func imageHasAlpha(_ img: UIImage) -> Bool { return false } -func saveFileFromURL(_ url: URL) -> String? { - let savedFile: String? +func saveFileFromURL(_ url: URL, encrypted: Bool) -> CryptoFile? { + let savedFile: CryptoFile? if url.startAccessingSecurityScopedResource() { do { - let fileData = try Data(contentsOf: url) let fileName = uniqueCombine(url.lastPathComponent) - savedFile = saveFile(fileData, fileName) + let toPath = getAppFilePath(fileName).path + if encrypted { + let cfArgs = try encryptCryptoFile(fromPath: url.path, toPath: toPath) + savedFile = CryptoFile(filePath: fileName, cryptoArgs: cfArgs) + } else { + try FileManager.default.copyItem(atPath: url.path, toPath: toPath) + savedFile = CryptoFile.plain(fileName) + } } catch { logger.error("FileUtils.saveFileFromURL error: \(error.localizedDescription)") savedFile = nil @@ -176,18 +183,16 @@ func saveFileFromURL(_ url: URL) -> String? { return savedFile } -func saveFileFromURLWithoutLoad(_ url: URL) -> String? { - let savedFile: String? +func moveTempFileFromURL(_ url: URL) -> CryptoFile? { do { let fileName = uniqueCombine(url.lastPathComponent) try FileManager.default.moveItem(at: url, to: getAppFilePath(fileName)) ChatModel.shared.filesToDelete.remove(url) - savedFile = fileName + return CryptoFile.plain(fileName) } catch { - logger.error("FileUtils.saveFileFromURLWithoutLoad error: \(error.localizedDescription)") - savedFile = nil + logger.error("ImageUtils.moveTempFileFromURL error: \(error.localizedDescription)") + return nil } - return savedFile } func generateNewFileName(_ prefix: String, _ ext: String) -> String { @@ -288,4 +293,4 @@ extension UIImage { } return self } -} \ No newline at end of file +} diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 59524c2c39..7a625bae63 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -315,7 +315,7 @@ func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws - throw r } -func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false, ttl: Int? = nil) async -> ChatItem? { +func apiSendMessage(type: ChatType, id: Int64, file: CryptoFile?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false, ttl: Int? = nil) async -> ChatItem? { let chatModel = ChatModel.shared let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live, ttl: ttl) let r: ChatResponse @@ -807,14 +807,14 @@ func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws { try await sendCommandOkResp(.apiChatUnread(type: type, id: id, unreadChat: unreadChat)) } -func receiveFile(user: any UserLike, fileId: Int64, auto: Bool = false) async { - if let chatItem = await apiReceiveFile(fileId: fileId, auto: auto) { +func receiveFile(user: any UserLike, fileId: Int64, encrypted: Bool, auto: Bool = false) async { + if let chatItem = await apiReceiveFile(fileId: fileId, encrypted: encrypted, auto: auto) { await chatItemSimpleUpdate(user, chatItem) } } -func apiReceiveFile(fileId: Int64, inline: Bool? = nil, auto: Bool = false) async -> AChatItem? { - let r = await chatSendCmd(.receiveFile(fileId: fileId, inline: inline)) +func apiReceiveFile(fileId: Int64, encrypted: Bool, inline: Bool? = nil, auto: Bool = false) async -> AChatItem? { + let r = await chatSendCmd(.receiveFile(fileId: fileId, encrypted: encrypted, inline: inline)) let am = AlertManager.shared if case let .rcvFileAccepted(_, chatItem) = r { return chatItem } if case .rcvFileAcceptedSndCancelled = r { @@ -1357,7 +1357,7 @@ func processReceivedMsg(_ res: ChatResponse) async { } if let file = cItem.autoReceiveFile() { Task { - await receiveFile(user: user, fileId: file.fileId, auto: true) + await receiveFile(user: user, fileId: file.fileId, encrypted: cItem.encryptLocalFile, auto: true) } } if cItem.showNotification { @@ -1660,15 +1660,3 @@ private struct UserResponse: Decodable { var user: User? var error: String? } - -struct RuntimeError: Error { - let message: String - - init(_ message: String) { - self.message = message - } - - public var localizedDescription: String { - return message - } -} diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 0c43ebe41a..1c32f36c9c 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -16,8 +16,8 @@ struct CIFileView: View { var body: some View { let metaReserve = edited - ? " " - : " " + ? " " + : " " Button(action: fileAction) { HStack(alignment: .bottom, spacing: 6) { fileIndicator() @@ -84,7 +84,8 @@ struct CIFileView: View { Task { logger.debug("CIFileView fileAction - in .rcvInvitation, in Task") if let user = ChatModel.shared.currentUser { - await receiveFile(user: user, fileId: file.fileId) + let encrypted = file.fileProtocol == .xftp && privacyEncryptLocalFilesGroupDefault.get() + await receiveFile(user: user, fileId: file.fileId, encrypted: encrypted) } } } else { @@ -109,9 +110,8 @@ struct CIFileView: View { } case .rcvComplete: logger.debug("CIFileView fileAction - in .rcvComplete") - if let filePath = getLoadedFilePath(file) { - let url = URL(fileURLWithPath: filePath) - showShareSheet(items: [url]) + if let fileSource = getLoadedFileSource(file) { + saveCryptoFile(fileSource) } default: break } @@ -193,6 +193,30 @@ struct CIFileView: View { } } +func saveCryptoFile(_ fileSource: CryptoFile) { + if let cfArgs = fileSource.cryptoArgs { + let url = getAppFilePath(fileSource.filePath) + let tempUrl = getTempFilesDirectory().appendingPathComponent(fileSource.filePath) + Task { + do { + try decryptCryptoFile(fromPath: url.path, cryptoArgs: cfArgs, toPath: tempUrl.path) + await MainActor.run { + showShareSheet(items: [tempUrl]) { + removeFile(tempUrl) + } + } + } catch { + await MainActor.run { + AlertManager.shared.showAlertMsg(title: "Error decrypting file", message: "Error: \(error.localizedDescription)") + } + } + } + } else { + let url = getAppFilePath(fileSource.filePath) + showShareSheet(items: [url]) + } +} + struct CIFileView_Previews: PreviewProvider { static var previews: some View { let sentFile: ChatItem = ChatItem( diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index b13ee52829..bb43179577 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -16,6 +16,7 @@ struct CIImageView: View { let maxWidth: CGFloat @Binding var imgWidth: CGFloat? @State var scrollProxy: ScrollViewProxy? + @State var metaColor: Color @State private var showFullScreenImage = false var body: some View { @@ -36,9 +37,8 @@ struct CIImageView: View { case .rcvInvitation: Task { if let user = ChatModel.shared.currentUser { - await receiveFile(user: user, fileId: file.fileId) + await receiveFile(user: user, fileId: file.fileId, encrypted: chatItem.encryptLocalFile) } - // TODO image accepted alert? } case .rcvAccepted: switch file.fileProtocol { @@ -110,7 +110,7 @@ struct CIImageView: View { .resizable() .aspectRatio(contentMode: .fit) .frame(width: size, height: size) - .foregroundColor(.white) + .foregroundColor(metaColor) .padding(padding) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift index 996afd0485..30430dc19a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift @@ -21,27 +21,28 @@ struct CIMetaView: View { } else { let meta = chatItem.meta let ttl = chat.chatInfo.timedMessagesTTL + let encrypted = chatItem.encryptedFile switch meta.itemStatus { case let .sndSent(sndProgress): switch sndProgress { - case .complete: ciMetaText(meta, chatTTL: ttl, color: metaColor, sent: .sent) - case .partial: ciMetaText(meta, chatTTL: ttl, color: paleMetaColor, sent: .sent) + case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent) + case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent) } case let .sndRcvd(_, sndProgress): switch sndProgress { case .complete: ZStack { - ciMetaText(meta, chatTTL: ttl, color: metaColor, sent: .rcvd1) - ciMetaText(meta, chatTTL: ttl, color: metaColor, sent: .rcvd2) + ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1) + ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2) } case .partial: ZStack { - ciMetaText(meta, chatTTL: ttl, color: paleMetaColor, sent: .rcvd1) - ciMetaText(meta, chatTTL: ttl, color: paleMetaColor, sent: .rcvd2) + ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1) + ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2) } } default: - ciMetaText(meta, chatTTL: ttl, color: metaColor) + ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor) } } } @@ -53,7 +54,7 @@ enum SentCheckmark { case rcvd2 } -func ciMetaText(_ meta: CIMeta, chatTTL: Int?, color: Color = .clear, transparent: Bool = false, sent: SentCheckmark? = nil) -> Text { +func ciMetaText(_ meta: CIMeta, chatTTL: Int?, encrypted: Bool?, color: Color = .clear, transparent: Bool = false, sent: SentCheckmark? = nil) -> Text { var r = Text("") if meta.itemEdited { r = r + statusIconText("pencil", color) @@ -80,7 +81,11 @@ func ciMetaText(_ meta: CIMeta, chatTTL: Int?, color: Color = .clear, transparen } else if !meta.disappearing { r = r + statusIconText("circlebadge.fill", .clear) + Text(" ") } - return (r + meta.timestampText.foregroundColor(color)).font(.caption) + if let enc = encrypted { + r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ") + } + r = r + meta.timestampText.foregroundColor(color) + return r.font(.caption) } private func statusIconText(_ icon: String, _ color: Color) -> Text { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift index 2e0a19eada..e1a5c252ec 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift @@ -118,7 +118,7 @@ struct CIRcvDecryptionError: View { .foregroundColor(syncSupported ? .accentColor : .secondary) .font(.callout) + Text(" ") - + ciMetaText(chatItem.meta, chatTTL: nil, transparent: true) + + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true) ) } .padding(.horizontal, 12) @@ -139,7 +139,7 @@ struct CIRcvDecryptionError: View { .foregroundColor(.red) .italic() + Text(" ") - + ciMetaText(chatItem.meta, chatTTL: nil, transparent: true) + + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true) } .padding(.horizontal, 12) CIMetaView(chatItem: chatItem) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index 6de2e44b77..3807a11b4e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -59,7 +59,7 @@ struct CIVideoView: View { if let file = file { switch file.fileStatus { case .rcvInvitation: - receiveFileIfValidSize(file: file, receiveFile: receiveFile) + receiveFileIfValidSize(file: file, encrypted: false, receiveFile: receiveFile) case .rcvAccepted: switch file.fileProtocol { case .xftp: @@ -85,7 +85,7 @@ struct CIVideoView: View { } if let file = file, case .rcvInvitation = file.fileStatus { Button { - receiveFileIfValidSize(file: file, receiveFile: receiveFile) + receiveFileIfValidSize(file: file, encrypted: false, receiveFile: receiveFile) } label: { playPauseIcon("play.fill") } @@ -253,10 +253,11 @@ struct CIVideoView: View { .padding([.trailing, .top], 11) } - private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64, Bool) async -> Void) { + // TODO encrypt: where file size is checked? + private func receiveFileIfValidSize(file: CIFile, encrypted: Bool, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) { Task { if let user = ChatModel.shared.currentUser { - await receiveFile(user, file.fileId, false) + await receiveFile(user, file.fileId, encrypted, false) } } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index 167823934e..b0875abe8d 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -159,7 +159,8 @@ struct VoiceMessagePlayer: View { } } .onChange(of: chatModel.stopPreviousRecPlay) { it in - if let recordingFileName = getLoadedFileName(recordingFile), chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) { + if let recordingFileName = getLoadedFileSource(recordingFile)?.filePath, + chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) { audioPlayer?.stop() playbackState = .noPlayback playbackTime = TimeInterval(0) @@ -174,8 +175,8 @@ struct VoiceMessagePlayer: View { switch playbackState { case .noPlayback: Button { - if let recordingFileName = getLoadedFileName(recordingFile) { - startPlayback(recordingFileName) + if let recordingSource = getLoadedFileSource(recordingFile) { + startPlayback(recordingSource) } } label: { playPauseIcon("play.fill") @@ -219,7 +220,7 @@ struct VoiceMessagePlayer: View { Button { Task { if let user = ChatModel.shared.currentUser { - await receiveFile(user: user, fileId: recordingFile.fileId) + await receiveFile(user: user, fileId: recordingFile.fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get()) } } } label: { @@ -251,8 +252,8 @@ struct VoiceMessagePlayer: View { .clipShape(Circle()) } - private func startPlayback(_ recordingFileName: String) { - chatModel.stopPreviousRecPlay = getAppFilePath(recordingFileName) + private func startPlayback(_ recordingSource: CryptoFile) { + chatModel.stopPreviousRecPlay = getAppFilePath(recordingSource.filePath) audioPlayer = AudioPlayer( onTimer: { playbackTime = $0 }, onFinishPlayback: { @@ -260,7 +261,7 @@ struct VoiceMessagePlayer: View { playbackTime = TimeInterval(0) } ) - audioPlayer?.start(fileName: recordingFileName, at: playbackTime) + audioPlayer?.start(fileSource: recordingSource, at: playbackTime) playbackState = .playing } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index ceaf175f9b..aab0cd5f55 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -97,7 +97,7 @@ struct FramedItemView: View { } else { switch (chatItem.content.msgContent) { case let .image(text, image): - CIImageView(chatItem: chatItem, image: image, maxWidth: maxWidth, imgWidth: $imgWidth, scrollProxy: scrollProxy) + CIImageView(chatItem: chatItem, image: image, maxWidth: maxWidth, imgWidth: $imgWidth, scrollProxy: scrollProxy, metaColor: metaColor) .overlay(DetermineWidth()) if text == "" && !chatItem.meta.isLive { Color.clear diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 3ac908bb78..498b3cb2e0 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -80,7 +80,7 @@ struct MsgContentView: View { } private func reserveSpaceForMeta(_ mt: CIMeta) -> Text { - (rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, transparent: true) + (rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true) } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 4f1b4fe728..2a0cd4f2c2 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -601,15 +601,15 @@ struct ChatView: View { } menu.append(shareUIAction()) menu.append(copyUIAction()) - if let filePath = getLoadedFilePath(ci.file) { + if let fileSource = getLoadedFileSource(ci.file) { if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) { if image.imageData != nil { - menu.append(saveFileAction(filePath)) + menu.append(saveFileAction(fileSource)) } else { menu.append(saveImageAction(image)) } } else { - menu.append(saveFileAction(filePath)) + menu.append(saveFileAction(fileSource)) } } if ci.meta.editable && !mc.isVoice && !live { @@ -747,13 +747,12 @@ struct ChatView: View { } } - private func saveFileAction(_ filePath: String) -> UIAction { + private func saveFileAction(_ fileSource: CryptoFile) -> UIAction { UIAction( title: NSLocalizedString("Save", comment: "chat item action"), - image: UIImage(systemName: "square.and.arrow.down") + image: UIImage(systemName: fileSource.cryptoArgs == nil ? "square.and.arrow.down" : "lock.open") ) { _ in - let fileURL = URL(fileURLWithPath: filePath) - showShareSheet(items: [fileURL]) + saveCryptoFile(fileSource) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 674f31bf71..c999c9dca0 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -167,25 +167,23 @@ struct ComposeState { } func chatItemPreview(chatItem: ChatItem) -> ComposePreview { - let chatItemPreview: ComposePreview switch chatItem.content.msgContent { case .text: - chatItemPreview = .noPreview + return .noPreview case let .link(_, preview: preview): - chatItemPreview = .linkPreview(linkPreview: preview) + return .linkPreview(linkPreview: preview) case let .image(_, image): - chatItemPreview = .mediaPreviews(mediaPreviews: [(image, nil)]) + return .mediaPreviews(mediaPreviews: [(image, nil)]) case let .video(_, image, _): - chatItemPreview = .mediaPreviews(mediaPreviews: [(image, nil)]) + return .mediaPreviews(mediaPreviews: [(image, nil)]) case let .voice(_, duration): - chatItemPreview = .voicePreview(recordingFileName: chatItem.file?.fileName ?? "", duration: duration) + return .voicePreview(recordingFileName: chatItem.file?.fileName ?? "", duration: duration) case .file: let fileName = chatItem.file?.fileName ?? "" - chatItemPreview = .filePreview(fileName: fileName, file: getAppFilePath(fileName)) + return .filePreview(fileName: fileName, file: getAppFilePath(fileName)) default: - chatItemPreview = .noPreview + return .noPreview } - return chatItemPreview } enum UploadContent: Equatable { @@ -656,10 +654,10 @@ struct ComposeView: View { } case let .voicePreview(recordingFileName, duration): stopPlayback.toggle() - chatModel.filesToDelete.remove(getAppFilePath(recordingFileName)) - sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: recordingFileName, ttl: ttl) + let file = voiceCryptoFile(recordingFileName) + sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl) case let .filePreview(_, file): - if let savedFile = saveFileFromURL(file) { + if let savedFile = saveFileFromURL(file, encrypted: privacyEncryptLocalFilesGroupDefault.get()) { sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl) } } @@ -727,13 +725,28 @@ struct ComposeView: View { func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { let (image, data) = imageData - if case let .video(_, url, duration) = data, let savedFile = saveFileFromURLWithoutLoad(url) { + if case let .video(_, url, duration) = data, let savedFile = moveTempFileFromURL(url) { return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live, ttl: ttl) } return nil } - func send(_ mc: MsgContent, quoted: Int64?, file: String? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { + func voiceCryptoFile(_ fileName: String) -> CryptoFile? { + if !privacyEncryptLocalFilesGroupDefault.get() { + return CryptoFile.plain(fileName) + } + let url = getAppFilePath(fileName) + let toFile = generateNewFileName("voice", "m4a") + let toUrl = getAppFilePath(toFile) + if let cfArgs = try? encryptCryptoFile(fromPath: url.path, toPath: toUrl.path) { + removeFile(url) + return CryptoFile(filePath: toFile, cryptoArgs: cfArgs) + } else { + return nil + } + } + + func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { if let chatItem = await apiSendMessage( type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, @@ -750,7 +763,7 @@ struct ComposeView: View { return chatItem } if let file = file { - removeFile(file) + removeFile(file.filePath) } return nil } @@ -770,7 +783,7 @@ struct ComposeView: View { } } - func saveAnyImage(_ img: UploadContent) -> String? { + func saveAnyImage(_ img: UploadContent) -> CryptoFile? { switch img { case let .simpleImage(image): return saveImage(image) case let .animatedImage(image): return saveAnimImage(image) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift index 2bd23f8ae7..2617bc77bc 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift @@ -188,7 +188,7 @@ struct ComposeVoiceView: View { playbackTime = recordingTime // animate progress bar to the end } ) - audioPlayer?.start(fileName: recordingFileName, at: playbackTime) + audioPlayer?.start(fileSource: CryptoFile.plain(recordingFileName), at: playbackTime) playbackState = .playing } } diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift index 15883f8340..936c6cb3ab 100644 --- a/apps/ios/Shared/Views/Helpers/ShareSheet.swift +++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift @@ -8,11 +8,15 @@ import SwiftUI -func showShareSheet(items: [Any]) { +func showShareSheet(items: [Any], completed: (() -> Void)? = nil) { let keyWindowScene = UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene if let keyWindow = keyWindowScene?.windows.filter(\.isKeyWindow).first, let presentedViewController = keyWindow.rootViewController?.presentedViewController ?? keyWindow.rootViewController { let activityViewController = UIActivityViewController(activityItems: items, applicationActivities: nil) + if let completed = completed { + let handler: UIActivityViewController.CompletionWithItemsHandler = { _,_,_,_ in completed() } + activityViewController.completionWithItemsHandler = handler + } presentedViewController.present(activityViewController, animated: true) } } diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 4b583caba9..34b6f147bd 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -15,6 +15,7 @@ struct PrivacySettings: View { @AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true @AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true @AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true + @AppStorage(GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES, store: groupDefaults) private var encryptLocalFiles = true @State private var simplexLinkMode = privacySimplexLinkModeDefault.get() @AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @@ -63,6 +64,9 @@ struct PrivacySettings: View { } Section { + settingsRow("lock.doc") { + Toggle("Encrypt local files", isOn: $encryptLocalFiles) + } settingsRow("photo") { Toggle("Auto-accept images", isOn: $autoAcceptImages) .onChange(of: autoAcceptImages) { diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index dc0af36981..f0a5c2a069 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -271,7 +271,7 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? { ntfBadgeCountGroupDefault.set(max(0, ntfBadgeCountGroupDefault.get() - 1)) } if let file = cItem.autoReceiveFile() { - cItem = autoReceiveFile(file) ?? cItem + cItem = autoReceiveFile(file, encrypted: cItem.encryptLocalFile) ?? cItem } let ntf: NSENotification = cInfo.ntfsEnabled ? .nse(notification: createMessageReceivedNtf(user, cInfo, cItem)) : .empty return cItem.showNotification ? (aChatItem.chatId, ntf) : nil @@ -367,25 +367,25 @@ func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? { return nil } -func apiReceiveFile(fileId: Int64, inline: Bool? = nil) -> AChatItem? { - let r = sendSimpleXCmd(.receiveFile(fileId: fileId, inline: inline)) +func apiReceiveFile(fileId: Int64, encrypted: Bool, inline: Bool? = nil) -> AChatItem? { + let r = sendSimpleXCmd(.receiveFile(fileId: fileId, encrypted: encrypted, inline: inline)) if case let .rcvFileAccepted(_, chatItem) = r { return chatItem } logger.error("receiveFile error: \(responseError(r))") return nil } -func apiSetFileToReceive(fileId: Int64) { - let r = sendSimpleXCmd(.setFileToReceive(fileId: fileId)) +func apiSetFileToReceive(fileId: Int64, encrypted: Bool) { + let r = sendSimpleXCmd(.setFileToReceive(fileId: fileId, encrypted: encrypted)) if case .cmdOk = r { return } logger.error("setFileToReceive error: \(responseError(r))") } -func autoReceiveFile(_ file: CIFile) -> ChatItem? { +func autoReceiveFile(_ file: CIFile, encrypted: Bool) -> ChatItem? { switch file.fileProtocol { case .smp: - return apiReceiveFile(fileId: file.fileId)?.chatItem + return apiReceiveFile(fileId: file.fileId, encrypted: false)?.chatItem case .xftp: - apiSetFileToReceive(fileId: file.fileId) + apiSetFileToReceive(fileId: file.fileId, encrypted: encrypted) return nil } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 8b814e3c0d..fc301a3ab8 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -77,6 +77,7 @@ 5C9CC7A928C532AB00BEF955 /* DatabaseErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9CC7A828C532AB00BEF955 /* DatabaseErrorView.swift */; }; 5C9CC7AD28C55D7800BEF955 /* DatabaseEncryptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9CC7AC28C55D7800BEF955 /* DatabaseEncryptionView.swift */; }; 5C9D13A3282187BB00AB8B43 /* WebRTC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9D13A2282187BB00AB8B43 /* WebRTC.swift */; }; + 5C9D811A2AA8727A001D49FD /* CryptoFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9D81182AA7A4F1001D49FD /* CryptoFile.swift */; }; 5C9F83F42A9A7D98009AD0AA /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9F83EF2A9A7D98009AD0AA /* libffi.a */; }; 5C9F83F52A9A7D98009AD0AA /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9F83F02A9A7D98009AD0AA /* libgmp.a */; }; 5C9F83F62A9A7D98009AD0AA /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9F83F12A9A7D98009AD0AA /* libgmpxx.a */; }; @@ -331,6 +332,7 @@ 5C9CC7A828C532AB00BEF955 /* DatabaseErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseErrorView.swift; sourceTree = ""; }; 5C9CC7AC28C55D7800BEF955 /* DatabaseEncryptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseEncryptionView.swift; sourceTree = ""; }; 5C9D13A2282187BB00AB8B43 /* WebRTC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTC.swift; sourceTree = ""; }; + 5C9D81182AA7A4F1001D49FD /* CryptoFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoFile.swift; sourceTree = ""; }; 5C9F83EF2A9A7D98009AD0AA /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; 5C9F83F02A9A7D98009AD0AA /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 5C9F83F12A9A7D98009AD0AA /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; @@ -723,10 +725,10 @@ 5CADE79929211BB900072E13 /* PreferencesView.swift */, 5C5DB70D289ABDD200730FFF /* AppearanceSettings.swift */, 5C05DF522840AA1D00C683F9 /* CallSettings.swift */, - 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */, 5CB924E027A867BA00ACCCDD /* UserProfile.swift */, 5CC036DF29C488D500C0EF20 /* HiddenProfileView.swift */, 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */, + 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */, 5C93292E29239A170090FFF9 /* ProtocolServersView.swift */, 5C93293029239BED0090FFF9 /* ProtocolServerView.swift */, 5C9329402929248A0090FFF9 /* ScanProtocolServer.swift */, @@ -779,6 +781,7 @@ 5CDCAD7D2818941F00503DA2 /* API.swift */, 5CDCAD80281A7E2700503DA2 /* Notifications.swift */, 64DAE1502809D9F5000DA960 /* FileUtils.swift */, + 5C9D81182AA7A4F1001D49FD /* CryptoFile.swift */, 5C00168028C4FE760094D739 /* KeyChain.swift */, 5CE2BA76284530BF00EC33A6 /* SimpleXChat.h */, 5CE2BA8A2845332200EC33A6 /* SimpleX.h */, @@ -1236,6 +1239,7 @@ 5CE2BA90284533A300EC33A6 /* JSON.swift in Sources */, 5CE2BA8B284533A300EC33A6 /* ChatTypes.swift in Sources */, 5CE2BA8F284533A300EC33A6 /* APITypes.swift in Sources */, + 5C9D811A2AA8727A001D49FD /* CryptoFile.swift in Sources */, 5CE2BA8C284533A300EC33A6 /* AppGroup.swift in Sources */, 5CE2BA8D284533A300EC33A6 /* CallTypes.swift in Sources */, 5CE2BA8E284533A300EC33A6 /* API.swift in Sources */, diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index d80626d6f1..585b4f29ef 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -9,7 +9,7 @@ import Foundation import SwiftUI -let jsonDecoder = getJSONDecoder() +public let jsonDecoder = getJSONDecoder() let jsonEncoder = getJSONEncoder() public enum ChatCommand { @@ -39,7 +39,7 @@ public enum ChatCommand { case apiGetChats(userId: Int64) case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String) case apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) - case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool, ttl: Int?) + case apiSendMessage(type: ChatType, id: Int64, file: CryptoFile?, quotedItemId: Int64?, msg: MsgContent, live: Bool, ttl: Int?) case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool) case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) case apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64) @@ -110,8 +110,8 @@ public enum ChatCommand { case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus) case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) - case receiveFile(fileId: Int64, inline: Bool?) - case setFileToReceive(fileId: Int64) + case receiveFile(fileId: Int64, encrypted: Bool, inline: Bool?) + case setFileToReceive(fileId: Int64, encrypted: Bool) case cancelFile(fileId: Int64) case showVersion case string(String) @@ -157,7 +157,7 @@ public enum ChatCommand { (search == "" ? "" : " search=\(search)") case let .apiGetChatItemInfo(type, id, itemId): return "/_get item info \(ref(type, id)) \(itemId)" case let .apiSendMessage(type, id, file, quotedItemId, mc, live, ttl): - let msg = encodeJSON(ComposedMessage(filePath: file, quotedItemId: quotedItemId, msgContent: mc)) + let msg = encodeJSON(ComposedMessage(fileSource: file, quotedItemId: quotedItemId, msgContent: mc)) let ttlStr = ttl != nil ? "\(ttl!)" : "default" return "/_send \(ref(type, id)) live=\(onOff(live)) ttl=\(ttlStr) json \(msg)" case let .apiUpdateChatItem(type, id, itemId, mc, live): return "/_update item \(ref(type, id)) \(itemId) live=\(onOff(live)) \(mc.cmdString)" @@ -239,12 +239,13 @@ public enum ChatCommand { case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)" case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)" case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id)) \(onOff(unreadChat))" - case let .receiveFile(fileId, inline): + case let .receiveFile(fileId, encrypted, inline): + let s = "/freceive \(fileId) encrypt=\(onOff(encrypted))" if let inline = inline { - return "/freceive \(fileId) inline=\(onOff(inline))" + return s + " inline=\(onOff(inline))" } - return "/freceive \(fileId)" - case let .setFileToReceive(fileId): return "/_set_file_to_receive \(fileId)" + return s + case let .setFileToReceive(fileId, encrypted): return "/_set_file_to_receive \(fileId) encrypt=\(onOff(encrypted))" case let .cancelFile(fileId): return "/fcancel \(fileId)" case .showVersion: return "/version" case let .string(str): return str @@ -853,7 +854,7 @@ public enum ChatPagination { } struct ComposedMessage: Encodable { - var filePath: String? + var fileSource: CryptoFile? var quotedItemId: Int64? var msgContent: MsgContent } diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index 335ba06183..e09b957171 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -17,6 +17,7 @@ public let GROUP_DEFAULT_NTF_ENABLE_LOCAL = "ntfEnableLocal" public let GROUP_DEFAULT_NTF_ENABLE_PERIODIC = "ntfEnablePeriodic" let GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" public let GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE = "privacyTransferImagesInline" // no longer used +public let GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES = "privacyEncryptLocalFiles" let GROUP_DEFAULT_NTF_BADGE_COUNT = "ntgBadgeCount" let GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS = "networkUseOnionHosts" let GROUP_DEFAULT_NETWORK_SESSION_MODE = "networkSessionMode" @@ -59,6 +60,7 @@ public func registerGroupDefaults() { GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE: false, GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES: true, GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE: false, + GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES: true, GROUP_DEFAULT_CONFIRM_DB_UPGRADES: false, GROUP_DEFAULT_CALL_KIT_ENABLED: true, ]) @@ -113,7 +115,7 @@ public let ntfEnablePeriodicGroupDefault = BoolDefault(defaults: groupDefaults, public let privacyAcceptImagesGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES) -public let privacyTransferImagesInlineGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE) +public let privacyEncryptLocalFilesGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES) public let ntfBadgeCountGroupDefault = IntDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_NTF_BADGE_COUNT) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 48996abb78..ce8bd426cc 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2112,6 +2112,17 @@ public struct ChatItem: Identifiable, Decodable { return nil } + public var encryptedFile: Bool? { + guard let fileSource = file?.fileSource else { return nil } + return fileSource.cryptoArgs != nil + } + + public var encryptLocalFile: Bool { + file?.fileProtocol == .xftp && + content.msgContent?.isVideo == false && + privacyEncryptLocalFilesGroupDefault.get() + } + public var memberDisplayName: String? { get { if case let .groupRcv(groupMember) = chatDir { @@ -2690,12 +2701,18 @@ public struct CIFile: Decodable { public var fileId: Int64 public var fileName: String public var fileSize: Int64 - public var filePath: String? + public var fileSource: CryptoFile? public var fileStatus: CIFileStatus public var fileProtocol: FileProtocol public static func getSample(fileId: Int64 = 1, fileName: String = "test.txt", fileSize: Int64 = 100, filePath: String? = "test.txt", fileStatus: CIFileStatus = .rcvComplete) -> CIFile { - CIFile(fileId: fileId, fileName: fileName, fileSize: fileSize, filePath: filePath, fileStatus: fileStatus, fileProtocol: .xftp) + let f: CryptoFile? + if let filePath = filePath { + f = CryptoFile.plain(filePath) + } else { + f = nil + } + return CIFile(fileId: fileId, fileName: fileName, fileSize: fileSize, fileSource: f, fileStatus: fileStatus, fileProtocol: .xftp) } public var loaded: Bool { @@ -2742,6 +2759,25 @@ public struct CIFile: Decodable { } } +public struct CryptoFile: Codable { + public var filePath: String // the name of the file, not a full path + public var cryptoArgs: CryptoFileArgs? + + public init(filePath: String, cryptoArgs: CryptoFileArgs?) { + self.filePath = filePath + self.cryptoArgs = cryptoArgs + } + + public static func plain(_ f: String) -> CryptoFile { + CryptoFile(filePath: f, cryptoArgs: nil) + } +} + +public struct CryptoFileArgs: Codable { + public var fileKey: String + public var fileNonce: String +} + public struct CancelAction { public var uiAction: String public var alert: AlertInfo diff --git a/apps/ios/SimpleXChat/CryptoFile.swift b/apps/ios/SimpleXChat/CryptoFile.swift new file mode 100644 index 0000000000..d641464ee0 --- /dev/null +++ b/apps/ios/SimpleXChat/CryptoFile.swift @@ -0,0 +1,64 @@ +// +// CryptoFile.swift +// SimpleX (iOS) +// +// Created by Evgeny on 05/09/2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +import Foundation + +enum WriteFileResult: Decodable { + case result(cryptoArgs: CryptoFileArgs) + case error(writeError: String) +} + +public func writeCryptoFile(path: String, data: Data) throws -> CryptoFileArgs { + let ptr: UnsafeMutableRawPointer = malloc(data.count) + memcpy(ptr, (data as NSData).bytes, data.count) + var cPath = path.cString(using: .utf8)! + let cjson = chat_write_file(&cPath, ptr, Int32(data.count))! + let d = fromCString(cjson).data(using: .utf8)! + switch try jsonDecoder.decode(WriteFileResult.self, from: d) { + case let .result(cfArgs): return cfArgs + case let .error(err): throw RuntimeError(err) + } +} + +enum ReadFileResult: Decodable { + case result(fileSize: Int) + case error(readError: String) +} + +public func readCryptoFile(path: String, cryptoArgs: CryptoFileArgs) throws -> Data { + var cPath = path.cString(using: .utf8)! + var cKey = cryptoArgs.fileKey.cString(using: .utf8)! + var cNonce = cryptoArgs.fileNonce.cString(using: .utf8)! + let r = chat_read_file(&cPath, &cKey, &cNonce)! + let d = String.init(cString: r).data(using: .utf8)! + switch try jsonDecoder.decode(ReadFileResult.self, from: d) { + case let .error(err): throw RuntimeError(err) + case let .result(size): return Data(bytes: r.advanced(by: d.count + 1), count: size) + } +} + +public func encryptCryptoFile(fromPath: String, toPath: String) throws -> CryptoFileArgs { + var cFromPath = fromPath.cString(using: .utf8)! + var cToPath = toPath.cString(using: .utf8)! + let cjson = chat_encrypt_file(&cFromPath, &cToPath)! + let d = fromCString(cjson).data(using: .utf8)! + switch try jsonDecoder.decode(WriteFileResult.self, from: d) { + case let .result(cfArgs): return cfArgs + case let .error(err): throw RuntimeError(err) + } +} + +public func decryptCryptoFile(fromPath: String, cryptoArgs: CryptoFileArgs, toPath: String) throws { + var cFromPath = fromPath.cString(using: .utf8)! + var cKey = cryptoArgs.fileKey.cString(using: .utf8)! + var cNonce = cryptoArgs.fileNonce.cString(using: .utf8)! + var cToPath = toPath.cString(using: .utf8)! + let cErr = chat_decrypt_file(&cFromPath, &cKey, &cNonce, &cToPath)! + let err = fromCString(cErr) + if err != "" { throw RuntimeError(err) } +} diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index 148ab12e29..60d281f146 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -173,11 +173,16 @@ public func getAppFilePath(_ fileName: String) -> URL { getAppFilesDirectory().appendingPathComponent(fileName) } -public func saveFile(_ data: Data, _ fileName: String) -> String? { +public func saveFile(_ data: Data, _ fileName: String, encrypted: Bool) -> CryptoFile? { let filePath = getAppFilePath(fileName) do { - try data.write(to: filePath) - return fileName + if encrypted { + let cfArgs = try writeCryptoFile(path: filePath.path, data: data) + return CryptoFile(filePath: fileName, cryptoArgs: cfArgs) + } else { + try data.write(to: filePath) + return CryptoFile.plain(fileName) + } } catch { logger.error("FileUtils.saveFile error: \(error.localizedDescription)") return nil @@ -210,7 +215,7 @@ public func cleanupFile(_ aChatItem: AChatItem) { let cItem = aChatItem.chatItem let mc = cItem.content.msgContent if case .file = mc, - let fileName = cItem.file?.filePath { + let fileName = cItem.file?.fileSource?.filePath { removeFile(fileName) } } @@ -221,3 +226,15 @@ public func getMaxFileSize(_ fileProtocol: FileProtocol) -> Int64 { case .smp: return MAX_FILE_SIZE_SMP } } + +public struct RuntimeError: Error { + let message: String + + public init(_ message: String) { + self.message = message + } + + public var localizedDescription: String { + return message + } +} diff --git a/apps/ios/SimpleXChat/SimpleX.h b/apps/ios/SimpleXChat/SimpleX.h index 199c688f26..55b44dee31 100644 --- a/apps/ios/SimpleXChat/SimpleX.h +++ b/apps/ios/SimpleXChat/SimpleX.h @@ -25,3 +25,17 @@ extern char *chat_parse_server(char *str); extern char *chat_password_hash(char *pwd, char *salt); extern char *chat_encrypt_media(char *key, char *frame, int len); extern char *chat_decrypt_media(char *key, char *frame, int len); + +// chat_write_file returns NUL-terminated string with JSON of WriteFileResult +extern char *chat_write_file(char *path, char *data, int len); + +// chat_read_file returns a buffer with: +// 1. NUL-terminated C string with JSON of ReadFileResult, followed by +// 2. file data, the length is defined in ReadFileResult +extern char *chat_read_file(char *path, char *key, char *nonce); + +// chat_encrypt_file returns NUL-terminated string with JSON of WriteFileResult +extern char *chat_encrypt_file(char *fromPath, char *toPath); + +// chat_decrypt_file returns NUL-terminated string with the error message +extern char *chat_decrypt_file(char *fromPath, char *key, char *nonce, char *toPath); diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 0eb35fccd5..a0120eb96e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -2024,7 +2024,7 @@ class CIFile( val fileId: Long, val fileName: String, val fileSize: Long, - val filePath: String? = null, + val fileSource: CryptoFile? = null, val fileStatus: CIFileStatus, val fileProtocol: FileProtocol ) { @@ -2072,10 +2072,23 @@ class CIFile( filePath: String? = "test.txt", fileStatus: CIFileStatus = CIFileStatus.RcvComplete ): CIFile = - CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, filePath = filePath, fileStatus = fileStatus, fileProtocol = FileProtocol.XFTP) + CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, fileSource = if (filePath == null) null else CryptoFile.plain(filePath), fileStatus = fileStatus, fileProtocol = FileProtocol.XFTP) } } +@Serializable +class CryptoFile( + val filePath: String, + val cryptoArgs: CryptoFileArgs? +) { + companion object { + fun plain(f: String): CryptoFile = CryptoFile(f, null) + } +} + +@Serializable +class CryptoFileArgs(val fileKey: String, val fileNonce: String) + class CancelAction( val uiActionId: StringResource, val alert: AlertInfo diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index c5b11ef6de..612c167bfe 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -586,7 +586,7 @@ object ChatController { return null } - suspend fun apiSendMessage(type: ChatType, id: Long, file: String? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false, ttl: Int? = null): AChatItem? { + suspend fun apiSendMessage(type: ChatType, id: Long, file: CryptoFile? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false, ttl: Int? = null): AChatItem? { val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc, live, ttl) val r = sendCmd(cmd) return when (r) { @@ -1079,8 +1079,8 @@ object ChatController { return false } - suspend fun apiReceiveFile(fileId: Long, inline: Boolean? = null, auto: Boolean = false): AChatItem? { - val r = sendCmd(CC.ReceiveFile(fileId, inline)) + suspend fun apiReceiveFile(fileId: Long, encrypted: Boolean, inline: Boolean? = null, auto: Boolean = false): AChatItem? { + val r = sendCmd(CC.ReceiveFile(fileId, encrypted, inline)) return when (r) { is CR.RcvFileAccepted -> r.chatItem is CR.RcvFileAcceptedSndCancelled -> { @@ -1413,7 +1413,8 @@ object ChatController { ((mc is MsgContent.MCImage && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV) || (mc is MsgContent.MCVideo && file.fileSize <= MAX_VIDEO_SIZE_AUTO_RCV) || (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileStatus !is CIFileStatus.RcvAccepted))) { - withApi { receiveFile(r.user, file.fileId, auto = true) } + // TODO encrypt images and voice + withApi { receiveFile(r.user, file.fileId, encrypted = false, auto = true) } } if (cItem.showNotification && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id)) { ntfManager.notifyMessageReceived(r.user, cInfo, cItem) @@ -1647,8 +1648,8 @@ object ChatController { } } - suspend fun receiveFile(user: UserLike, fileId: Long, auto: Boolean = false) { - val chatItem = apiReceiveFile(fileId, auto = auto) + suspend fun receiveFile(user: UserLike, fileId: Long, encrypted: Boolean, auto: Boolean = false) { + val chatItem = apiReceiveFile(fileId, encrypted = encrypted, auto = auto) if (chatItem != null) { chatItemSimpleUpdate(user, chatItem) } @@ -1804,7 +1805,7 @@ sealed class CC { class ApiGetChats(val userId: Long): CC() class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC() class ApiGetChatItemInfo(val type: ChatType, val id: Long, val itemId: Long): CC() - class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent, val live: Boolean, val ttl: Int?): CC() + class ApiSendMessage(val type: ChatType, val id: Long, val file: CryptoFile?, val quotedItemId: Long?, val mc: MsgContent, val live: Boolean, val ttl: Int?): CC() class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC() class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC() class ApiDeleteMemberChatItem(val groupId: Long, val groupMemberId: Long, val itemId: Long): CC() @@ -1867,7 +1868,7 @@ sealed class CC { class ApiRejectContact(val contactReqId: Long): CC() class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC() class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC() - class ReceiveFile(val fileId: Long, val inline: Boolean?): CC() + class ReceiveFile(val fileId: Long, val encrypted: Boolean, val inline: Boolean?): CC() class CancelFile(val fileId: Long): CC() class ShowVersion(): CC() @@ -1972,7 +1973,7 @@ sealed class CC { is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}" is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}" is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}" - is ReceiveFile -> if (inline == null) "/freceive $fileId" else "/freceive $fileId inline=${onOff(inline)}" + is ReceiveFile -> "/freceive $fileId encrypt=${onOff(encrypted)}" + (if (inline == null) "" else " inline=${onOff(inline)}") is CancelFile -> "/fcancel $fileId" is ShowVersion -> "/version" } @@ -2134,7 +2135,7 @@ sealed class ChatPagination { } @Serializable -class ComposedMessage(val filePath: String?, val quotedItemId: Long?, val msgContent: MsgContent) +class ComposedMessage(val fileSource: CryptoFile?, val quotedItemId: Long?, val msgContent: MsgContent) @Serializable class XFTPFileConfig(val minFileSize: Long) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt index 9c702df545..53b0f8bd96 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt @@ -62,8 +62,9 @@ fun getAppFilePath(fileName: String): String { } fun getLoadedFilePath(file: CIFile?): String? { - return if (file?.filePath != null && file.loaded) { - val filePath = getAppFilePath(file.filePath) + val f = file?.fileSource?.filePath + return if (f != null && file.loaded) { + val filePath = getAppFilePath(f) if (File(filePath).exists()) filePath else null } else { null diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index e8afdfeb21..f6e328afdb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -244,8 +244,8 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: } } }, - receiveFile = { fileId -> - withApi { chatModel.controller.receiveFile(user, fileId) } + receiveFile = { fileId, encrypted -> + withApi { chatModel.controller.receiveFile(user, fileId, encrypted) } }, cancelFile = { fileId -> withApi { chatModel.controller.cancelFile(user, fileId) } @@ -403,7 +403,7 @@ fun ChatLayout( showMemberInfo: (GroupInfo, GroupMember) -> Unit, loadPrevMessages: (ChatInfo) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, - receiveFile: (Long) -> Unit, + receiveFile: (Long, Boolean) -> Unit, cancelFile: (Long) -> Unit, joinGroup: (Long) -> Unit, startCall: (CallMediaType) -> Unit, @@ -656,7 +656,7 @@ fun BoxWithConstraintsScope.ChatItemsList( showMemberInfo: (GroupInfo, GroupMember) -> Unit, loadPrevMessages: (ChatInfo) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, - receiveFile: (Long) -> Unit, + receiveFile: (Long, Boolean) -> Unit, cancelFile: (Long) -> Unit, joinGroup: (Long) -> Unit, acceptCall: (Contact) -> Unit, @@ -1257,7 +1257,7 @@ fun PreviewChatLayout() { showMemberInfo = { _, _ -> }, loadPrevMessages = { _ -> }, deleteMessage = { _, _ -> }, - receiveFile = {}, + receiveFile = { _, _ -> }, cancelFile = {}, joinGroup = {}, startCall = {}, @@ -1324,7 +1324,7 @@ fun PreviewGroupChatLayout() { showMemberInfo = { _, _ -> }, loadPrevMessages = { _ -> }, deleteMessage = { _, _ -> }, - receiveFile = {}, + receiveFile = { _, _ -> }, cancelFile = {}, joinGroup = {}, startCall = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 6c66ee2b9b..01090705d7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -317,7 +317,7 @@ fun ComposeView( chatModel.filesToDelete.clear() } - suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: String? = null, live: Boolean = false, ttl: Int?): ChatItem? { + suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? { val aChatItem = chatModel.controller.apiSendMessage( type = cInfo.chatType, id = cInfo.apiId, @@ -331,7 +331,7 @@ fun ComposeView( chatModel.addChatItem(cInfo, aChatItem.chatItem) return aChatItem.chatItem } - if (file != null) removeFile(file) + if (file != null) removeFile(file.filePath) return null } @@ -404,7 +404,7 @@ fun ComposeView( sent = updateMessage(liveMessage.chatItem, cInfo, live) } else { val msgs: ArrayList = ArrayList() - val files: ArrayList = ArrayList() + val files: ArrayList = ArrayList() when (val preview = cs.preview) { ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText)) is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview()) @@ -413,7 +413,7 @@ fun ComposeView( val file = when (it) { is UploadContent.SimpleImage -> saveImage(it.uri) is UploadContent.AnimatedImage -> saveAnimImage(it.uri) - is UploadContent.Video -> saveFileFromUri(it.uri) + is UploadContent.Video -> saveFileFromUri(it.uri, encrypted = false) } if (file != null) { files.add(file) @@ -432,12 +432,13 @@ fun ComposeView( withContext(Dispatchers.IO) { Files.move(tmpFile.toPath(), actualFile.toPath()) } - files.add(actualFile.name) + // TODO encrypt voice files + files.add(CryptoFile.plain(actualFile.name)) deleteUnusedFiles() msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", preview.durationMs / 1000)) } is ComposePreview.FilePreview -> { - val file = saveFileFromUri(preview.uri) + val file = saveFileFromUri(preview.uri, encrypted = false) if (file != null) { files.add((file)) msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else "")) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index 773533ca7e..4642600fcb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -28,7 +28,7 @@ import java.net.URI fun CIFileView( file: CIFile?, edited: Boolean, - receiveFile: (Long) -> Unit + receiveFile: (Long, Boolean) -> Unit ) { val saveFileLauncher = rememberSaveFileLauncher(ciFile = file) @@ -71,7 +71,7 @@ fun CIFileView( when (file.fileStatus) { is CIFileStatus.RcvInvitation -> { if (fileSizeValid()) { - receiveFile(file.fileId) + receiveFile(file.fileId, false) } else { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.large_file), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index 73fc3f41ac..75d6a9c304 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -31,7 +31,7 @@ fun CIImageView( file: CIFile?, imageProvider: () -> ImageGalleryProvider, showMenu: MutableState, - receiveFile: (Long) -> Unit + receiveFile: (Long, Boolean) -> Unit ) { @Composable fun progressIndicator() { @@ -152,7 +152,8 @@ fun CIImageView( when (file.fileStatus) { CIFileStatus.RcvInvitation -> if (fileSizeValid()) { - receiveFile(file.fileId) + // TODO encrypt image + receiveFile(file.fileId, false) } else { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.large_file), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt index 5d2d581b1d..aad1e8a8f5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt @@ -31,7 +31,7 @@ fun CIVideoView( file: CIFile?, imageProvider: () -> ImageGalleryProvider, showMenu: MutableState, - receiveFile: (Long) -> Unit + receiveFile: (Long, Boolean) -> Unit ) { Box( Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID), @@ -54,7 +54,7 @@ fun CIVideoView( if (file != null) { when (file.fileStatus) { CIFileStatus.RcvInvitation -> - receiveFileIfValidSize(file, receiveFile) + receiveFileIfValidSize(file, encrypted = false, receiveFile) CIFileStatus.RcvAccepted -> when (file.fileProtocol) { FileProtocol.XFTP -> @@ -80,7 +80,7 @@ fun CIVideoView( DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/) } if (file?.fileStatus is CIFileStatus.RcvInvitation) { - PlayButton(error = false, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) } + PlayButton(error = false, { showMenu.value = true }) { receiveFileIfValidSize(file, encrypted = false, receiveFile) } } } } @@ -301,9 +301,9 @@ private fun fileSizeValid(file: CIFile?): Boolean { return false } -private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) { +private fun receiveFileIfValidSize(file: CIFile, encrypted: Boolean, receiveFile: (Long, Boolean) -> Unit) { if (fileSizeValid(file)) { - receiveFile(file.fileId) + receiveFile(file.fileId, encrypted) } else { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.large_file), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt index 9374225897..6ec39bb4f0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt @@ -37,18 +37,19 @@ fun CIVoiceView( ci: ChatItem, timedMessagesTTL: Int?, longClick: () -> Unit, - receiveFile: (Long) -> Unit, + receiveFile: (Long, Boolean) -> Unit, ) { Row( Modifier.padding(top = if (hasText) 14.dp else 4.dp, bottom = if (hasText) 14.dp else 6.dp, start = if (hasText) 6.dp else 0.dp, end = if (hasText) 6.dp else 0.dp), verticalAlignment = Alignment.CenterVertically ) { if (file != null) { - val filePath = remember(file.filePath, file.fileStatus) { getLoadedFilePath(file) } - var brokenAudio by rememberSaveable(file.filePath) { mutableStateOf(false) } - val audioPlaying = rememberSaveable(file.filePath) { mutableStateOf(false) } - val progress = rememberSaveable(file.filePath) { mutableStateOf(0) } - val duration = rememberSaveable(file.filePath) { mutableStateOf(providedDurationSec * 1000) } + val f = file.fileSource?.filePath + val filePath = remember(f, file.fileStatus) { getLoadedFilePath(file) } + var brokenAudio by rememberSaveable(f) { mutableStateOf(false) } + val audioPlaying = rememberSaveable(f) { mutableStateOf(false) } + val progress = rememberSaveable(f) { mutableStateOf(0) } + val duration = rememberSaveable(f) { mutableStateOf(providedDurationSec * 1000) } val play = { AudioPlayer.play(filePath, audioPlaying, progress, duration, true) brokenAudio = !audioPlaying.value @@ -94,7 +95,7 @@ private fun VoiceLayout( play: () -> Unit, pause: () -> Unit, longClick: () -> Unit, - receiveFile: (Long) -> Unit, + receiveFile: (Long, Boolean) -> Unit, onProgressChanged: (Int) -> Unit, ) { @Composable @@ -248,7 +249,7 @@ private fun VoiceMsgIndicator( play: () -> Unit, pause: () -> Unit, longClick: () -> Unit, - receiveFile: (Long) -> Unit, + receiveFile: (Long, Boolean) -> Unit, ) { val strokeWidth = with(LocalDensity.current) { 3.dp.toPx() } val strokeColor = MaterialTheme.colors.primary @@ -268,7 +269,8 @@ private fun VoiceMsgIndicator( } } else { if (file?.fileStatus is CIFileStatus.RcvInvitation) { - PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, { receiveFile(file.fileId) }, {}, longClick = longClick) + // TODO encrypt voice + PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, { receiveFile(file.fileId, false) }, {}, longClick = longClick) } else if (file?.fileStatus is CIFileStatus.RcvTransfer || file?.fileStatus is CIFileStatus.RcvAccepted ) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 2857d6acc8..cc2d97e3f5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -48,7 +48,7 @@ fun ChatItemView( useLinkPreviews: Boolean, linkMode: SimplexLinkMode, deleteMessage: (Long, CIDeleteMode) -> Unit, - receiveFile: (Long) -> Unit, + receiveFile: (Long, Boolean) -> Unit, cancelFile: (Long) -> Unit, joinGroup: (Long) -> Unit, acceptCall: (Contact) -> Unit, @@ -566,7 +566,7 @@ fun PreviewChatItemView() { linkMode = SimplexLinkMode.DESCRIPTION, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, deleteMessage = { _, _ -> }, - receiveFile = {}, + receiveFile = { _, _ -> }, cancelFile = {}, joinGroup = {}, acceptCall = { _ -> }, @@ -595,7 +595,7 @@ fun PreviewChatItemViewDeletedContent() { linkMode = SimplexLinkMode.DESCRIPTION, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, deleteMessage = { _, _ -> }, - receiveFile = {}, + receiveFile = { _, _ -> }, cancelFile = {}, joinGroup = {}, acceptCall = { _ -> }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index 9a28272805..92cf62a855 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -36,7 +36,7 @@ fun FramedItemView( imageProvider: (() -> ImageGalleryProvider)? = null, linkMode: SimplexLinkMode, showMenu: MutableState, - receiveFile: (Long) -> Unit, + receiveFile: (Long, Boolean) -> Unit, onLinkLongClick: (link: String) -> Unit = {}, scrollToItem: (Long) -> Unit = {}, ) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index e4670fef15..b9eeee12bc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -95,12 +95,13 @@ fun getThemeFromUri(uri: URI, withAlertOnException: Boolean = true): ThemeOverri return null } -fun saveImage(uri: URI): String? { +fun saveImage(uri: URI): CryptoFile? { val bitmap = getBitmapFromUri(uri) ?: return null return saveImage(bitmap) } -fun saveImage(image: ImageBitmap): String? { +fun saveImage(image: ImageBitmap): CryptoFile? { + // TODO encrypt image return try { val ext = if (image.hasAlpha()) "png" else "jpg" val dataResized = resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE) @@ -110,14 +111,15 @@ fun saveImage(image: ImageBitmap): String? { dataResized.writeTo(output) output.flush() output.close() - fileToSave + CryptoFile.plain(fileToSave) } catch (e: Exception) { Log.e(TAG, "Util.kt saveImage error: ${e.stackTraceToString()}") null } } -fun saveAnimImage(uri: URI): String? { +fun saveAnimImage(uri: URI): CryptoFile? { + // TODO encrypt image return try { val filename = getFileName(uri)?.lowercase() var ext = when { @@ -135,7 +137,7 @@ fun saveAnimImage(uri: URI): String? { input?.copyTo(output) } } - fileToSave + CryptoFile.plain(fileToSave) } catch (e: Exception) { Log.e(TAG, "Util.kt saveAnimImage error: ${e.message}") null @@ -144,15 +146,16 @@ fun saveAnimImage(uri: URI): String? { expect suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean): File? -fun saveFileFromUri(uri: URI): String? { +fun saveFileFromUri(uri: URI, encrypted: Boolean): CryptoFile? { return try { val inputStream = uri.inputStream() val fileToSave = getFileName(uri) + // TODO encrypt file if "encrypted" is true if (inputStream != null && fileToSave != null) { val destFileName = uniqueCombine(fileToSave) val destFile = File(getAppFilePath(destFileName)) Files.copy(inputStream, destFile.toPath()) - destFileName + CryptoFile.plain(destFileName) } else { Log.e(TAG, "Util.kt saveFileFromUri null inputStream") null From 7cd4a417e7d39c10292bce81cde0640bac24b40a Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 7 Sep 2023 11:51:17 +0100 Subject: [PATCH 4/5] ios: fix type that was preventing sent item status update --- apps/ios/SimpleXChat/APITypes.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 585b4f29ef..ad641810c2 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -482,7 +482,7 @@ public enum ChatResponse: Decodable, Error { case groupEmpty(user: UserRef, groupInfo: GroupInfo) case userContactLinkSubscribed case newChatItem(user: UserRef, chatItem: AChatItem) - case chatItemStatusUpdated(UserRef: User, chatItem: AChatItem) + case chatItemStatusUpdated(user: UserRef, chatItem: AChatItem) case chatItemUpdated(user: UserRef, chatItem: AChatItem) case chatItemNotChanged(user: UserRef, chatItem: AChatItem) case chatItemReaction(user: UserRef, added: Bool, reaction: ACIReaction) From b5a0269aa201c60fcc7fdfd9e0d85d45a7e2300f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 7 Sep 2023 13:44:37 +0100 Subject: [PATCH 5/5] core: support unicode filenames and catch IO exceptions in C API for local file encryption (#3035) * core: support unicode filenames in C API * catch IO exceptions and return as errors --- src/Simplex/Chat/Mobile/File.hs | 28 ++++++----- tests/MobileTests.hs | 86 ++++++++++++++++++++++++++------- 2 files changed, 84 insertions(+), 30 deletions(-) diff --git a/src/Simplex/Chat/Mobile/File.hs b/src/Simplex/Chat/Mobile/File.hs index 1c9219caba..a0fb3eb5b3 100644 --- a/src/Simplex/Chat/Mobile/File.hs +++ b/src/Simplex/Chat/Mobile/File.hs @@ -34,6 +34,7 @@ import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..), Cryp import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) +import Simplex.Messaging.Util (catchAll) import UnliftIO (Handle, IOMode (..), withFile) data WriteFileResult @@ -45,7 +46,7 @@ instance ToJSON WriteFileResult where toEncoding = J.genericToEncoding . sumType cChatWriteFile :: CString -> Ptr Word8 -> CInt -> IO CJSONString cChatWriteFile cPath ptr len = do - path <- peekCAString cPath + path <- peekCString cPath s <- getByteString ptr len r <- chatWriteFile path s newCAString $ LB'.unpack $ J.encode r @@ -54,8 +55,8 @@ chatWriteFile :: FilePath -> ByteString -> IO WriteFileResult chatWriteFile path s = do cfArgs <- CF.randomArgs let file = CryptoFile path $ Just cfArgs - either (WFError . show) (\_ -> WFResult cfArgs) - <$> runExceptT (CF.writeFile file $ LB.fromStrict s) + either WFError (\_ -> WFResult cfArgs) + <$> runCatchExceptT (withExceptT show $ CF.writeFile file $ LB.fromStrict s) data ReadFileResult = RFResult {fileSize :: Int} @@ -66,7 +67,7 @@ instance ToJSON ReadFileResult where toEncoding = J.genericToEncoding . sumTypeJ cChatReadFile :: CString -> CString -> CString -> IO (Ptr Word8) cChatReadFile cPath cKey cNonce = do - path <- peekCAString cPath + path <- peekCString cPath key <- B.packCString cKey nonce <- B.packCString cNonce (r, s) <- chatReadFile path key nonce @@ -78,7 +79,7 @@ cChatReadFile cPath cKey cNonce = do chatReadFile :: FilePath -> ByteString -> ByteString -> IO (ReadFileResult, ByteString) chatReadFile path keyStr nonceStr = do - either ((,"") . RFError) result <$> runExceptT readFile_ + either ((,"") . RFError) result <$> runCatchExceptT readFile_ where result s = let s' = LB.toStrict s in (RFResult $ B.length s', s') readFile_ :: ExceptT String IO LB.ByteString @@ -90,14 +91,14 @@ chatReadFile path keyStr nonceStr = do cChatEncryptFile :: CString -> CString -> IO CJSONString cChatEncryptFile cFromPath cToPath = do - fromPath <- peekCAString cFromPath - toPath <- peekCAString cToPath + fromPath <- peekCString cFromPath + toPath <- peekCString cToPath r <- chatEncryptFile fromPath toPath newCAString . LB'.unpack $ J.encode r chatEncryptFile :: FilePath -> FilePath -> IO WriteFileResult chatEncryptFile fromPath toPath = - either WFError WFResult <$> runExceptT encrypt + either WFError WFResult <$> runCatchExceptT encrypt where encrypt = do cfArgs <- liftIO $ CF.randomArgs @@ -114,15 +115,15 @@ chatEncryptFile fromPath toPath = cChatDecryptFile :: CString -> CString -> CString -> CString -> IO CString cChatDecryptFile cFromPath cKey cNonce cToPath = do - fromPath <- peekCAString cFromPath + fromPath <- peekCString cFromPath key <- B.packCString cKey nonce <- B.packCString cNonce - toPath <- peekCAString cToPath + toPath <- peekCString cToPath r <- chatDecryptFile fromPath key nonce toPath newCAString r - + chatDecryptFile :: FilePath -> ByteString -> ByteString -> FilePath -> IO String -chatDecryptFile fromPath keyStr nonceStr toPath = fromLeft "" <$> runExceptT decrypt +chatDecryptFile fromPath keyStr nonceStr toPath = fromLeft "" <$> runCatchExceptT decrypt where decrypt = do key <- liftEither $ strDecode keyStr @@ -143,6 +144,9 @@ chatDecryptFile fromPath keyStr nonceStr toPath = fromLeft "" <$> runExceptT dec liftIO $ B.hPut w ch when (size' > 0) $ decryptChunks r w size' +runCatchExceptT :: ExceptT String IO a -> IO (Either String a) +runCatchExceptT action = runExceptT action `catchAll` (pure . Left . show) + chunkSize :: Num a => a chunkSize = 65536 {-# INLINE chunkSize #-} diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 26b096086e..6746266d5d 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -18,6 +18,7 @@ import Data.Word (Word8) import Foreign.C import Foreign.Marshal.Alloc (mallocBytes) import Foreign.Ptr +import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEncoding) import Simplex.Chat.Mobile import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared @@ -27,21 +28,36 @@ import Simplex.Chat.Store.Profiles import Simplex.Chat.Types (AgentUserId (..), Profile (..)) import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..)) import qualified Simplex.Messaging.Crypto as C -import Simplex.Messaging.Crypto.File (CryptoFile(..), CryptoFileArgs (..), getFileContentsSize) +import Simplex.Messaging.Crypto.File (CryptoFile(..), CryptoFileArgs (..)) +import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) +import System.Directory (copyFile) import System.FilePath (()) +import System.IO (utf8) import Test.Hspec mobileTests :: HasCallStack => SpecWith FilePath mobileTests = do describe "mobile API" $ do + runIO $ do + setLocaleEncoding utf8 + setFileSystemEncoding utf8 + setForeignEncoding utf8 it "start new chat without user" testChatApiNoUser it "start new chat with existing user" testChatApi it "should encrypt/decrypt WebRTC frames" testMediaApi it "should encrypt/decrypt WebRTC frames via C API" testMediaCApi - it "should read/write encrypted files via C API" testFileCApi - it "should encrypt/decrypt files via C API" testFileEncryptionCApi + describe "should read/write encrypted files via C API" $ do + it "latin1 name" $ testFileCApi "test" + it "utf8 name 1" $ testFileCApi "тест" + it "utf8 name 2" $ testFileCApi "👍" + it "no exception on missing file" testMissingFileCApi + describe "should encrypt/decrypt files via C API" $ do + it "latin1 name" $ testFileEncryptionCApi "test" + it "utf8 name 1" $ testFileEncryptionCApi "тест" + it "utf8 name 2" $ testFileEncryptionCApi "👍" + it "no exception on missing file" testMissingFileEncryptionCApi noActiveUser :: String #if defined(darwin_HOST_OS) && defined(swiftJSON) @@ -176,16 +192,19 @@ instance FromJSON WriteFileResult where parseJSON = J.genericParseJSON . sumType instance FromJSON ReadFileResult where parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RF" -testFileCApi :: FilePath -> IO () -testFileCApi tmp = do +testFileCApi :: FilePath -> FilePath -> IO () +testFileCApi fileName tmp = do src <- B.readFile "./tests/fixtures/test.pdf" - cPath <- newCAString $ tmp "test.pdf" + let path = tmp (fileName <> ".pdf") + cPath <- newCString path let len = B.length src cLen = fromIntegral len ptr <- mallocBytes $ B.length src putByteString ptr src r <- peekCAString =<< cChatWriteFile cPath ptr cLen - Just (WFResult (CFArgs key nonce)) <- jDecode r + Just (WFResult cfArgs@(CFArgs key nonce)) <- jDecode r + let encryptedFile = CryptoFile path $ Just cfArgs + CF.getFileContentsSize encryptedFile `shouldReturn` fromIntegral (B.length src) cKey <- encodedCString key cNonce <- encodedCString nonce ptr' <- cChatReadFile cPath cKey cNonce @@ -196,22 +215,53 @@ testFileCApi tmp = do contents `shouldBe` src sz `shouldBe` len -testFileEncryptionCApi :: FilePath -> IO () -testFileEncryptionCApi tmp = do - src <- B.readFile "./tests/fixtures/test.pdf" - cFromPath <- newCAString "./tests/fixtures/test.pdf" - let toPath = tmp "test.encrypted.pdf" - cToPath <- newCAString toPath - r <- peekCAString =<< cChatEncryptFile cFromPath cToPath - Just (WFResult cfArgs@(CFArgs key nonce)) <- jDecode r - getFileContentsSize (CryptoFile toPath $ Just cfArgs) `shouldReturn` fromIntegral (B.length src) +testMissingFileCApi :: FilePath -> IO () +testMissingFileCApi tmp = do + let path = tmp "missing_file" + cPath <- newCString path + CFArgs key nonce <- CF.randomArgs cKey <- encodedCString key cNonce <- encodedCString nonce - let toPath' = tmp "test.decrypted.pdf" - cToPath' <- newCAString toPath' + ptr <- cChatReadFile cPath cKey cNonce + r <- peekCAString $ castPtr ptr + Just (RFError err) <- jDecode r + err `shouldContain` "missing_file: openBinaryFile: does not exist" + +testFileEncryptionCApi :: FilePath -> FilePath -> IO () +testFileEncryptionCApi fileName tmp = do + let fromPath = tmp (fileName <> ".source.pdf") + copyFile "./tests/fixtures/test.pdf" fromPath + src <- B.readFile fromPath + cFromPath <- newCString fromPath + let toPath = tmp (fileName <> ".encrypted.pdf") + cToPath <- newCString toPath + r <- peekCAString =<< cChatEncryptFile cFromPath cToPath + Just (WFResult cfArgs@(CFArgs key nonce)) <- jDecode r + CF.getFileContentsSize (CryptoFile toPath $ Just cfArgs) `shouldReturn` fromIntegral (B.length src) + cKey <- encodedCString key + cNonce <- encodedCString nonce + let toPath' = tmp (fileName <> ".decrypted.pdf") + cToPath' <- newCString toPath' "" <- peekCAString =<< cChatDecryptFile cToPath cKey cNonce cToPath' B.readFile toPath' `shouldReturn` src +testMissingFileEncryptionCApi :: FilePath -> IO () +testMissingFileEncryptionCApi tmp = do + let fromPath = tmp "missing_file.source.pdf" + toPath = tmp "missing_file.encrypted.pdf" + cFromPath <- newCString fromPath + cToPath <- newCString toPath + r <- peekCAString =<< cChatEncryptFile cFromPath cToPath + Just (WFError err) <- jDecode r + err `shouldContain` fromPath + CFArgs key nonce <- CF.randomArgs + cKey <- encodedCString key + cNonce <- encodedCString nonce + let toPath' = tmp "missing_file.decrypted.pdf" + cToPath' <- newCString toPath' + err' <- peekCAString =<< cChatDecryptFile cToPath cKey cNonce cToPath' + err' `shouldContain` toPath + jDecode :: FromJSON a => String -> IO (Maybe a) jDecode = pure . J.decode . LB.pack