diff --git a/README.md b/README.md index 561a322c62..d4103fd104 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![GitHub downloads](https://img.shields.io/github/downloads/simplex-chat/simplex-chat/total)](https://github.com/simplex-chat/simplex-chat/releases) [![GitHub release](https://img.shields.io/github/v/release/simplex-chat/simplex-chat)](https://github.com/simplex-chat/simplex-chat/releases) [![Join on Reddit](https://img.shields.io/reddit/subreddit-subscribers/SimpleXChat?style=social)](https://www.reddit.com/r/SimpleXChat) -[![Follow on Mastodon](https://img.shields.io/mastodon/follow/108619463746856738?domain=https%3A%2F%2Fmastodon.social&style=social)](https://mastodon.social/@simplex) +![Follow on Mastodon](https://img.shields.io/mastodon/follow/108619463746856738?domain=https%3A%2F%2Fmastodon.social&style=social) | 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md) | @@ -15,7 +15,7 @@ ## Welcome to SimpleX Chat! 1. 📲 [Install the app](#install-the-app). -2. ↔️ [Connect to the team](#connect-to-the-team-via-the-app) and [join user groups](#join-user-groups). +2. ↔️ [Connect to the team](#connect-to-the-team), [join user groups](#join-user-groups) and [follow our updates](#follow-our-updates). 3. 🤝 [Make a private connection](#make-a-private-connection) with a friend. 4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat). 5. ⚡️ [Contribute](#contribute) and [help us with donations](#help-us-with-donations). @@ -40,14 +40,22 @@ - 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**! - 🖥 Available as a terminal (console) [app / CLI](#zap-quick-installation-of-a-terminal-app) on Linux, MacOS, Windows. -## Connect to the team via the app +## Connect to the team + +You can connect to the team via the app using "chat with the developers button" available when you have no conversations in the profile, "Send questions and ideas" in the app settings or via our [SimpleX address](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). Please connect to: - to ask any questions - to suggest any improvements - to share anything relevant +We are replying the questions manually, so it is not instant – it can take up to 24 hours. + +If you are interested in helping us to integrate open-source language models, and in [joining our team](./docs/JOIN_TEAM.md), please get in touch. + ## Join user groups +You can join the groups created by other users via the new [directory service](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). We are not responsible for the content shared in these groups. + **Please note**: The groups below are created for the users to be able to ask questions, make suggestions and ask questions about SimpleX Chat only. You also can: @@ -79,7 +87,14 @@ There are groups in other languages, that we have the apps interface translated You can join either by opening these links in the app or by opening them in a desktop browser and scanning the QR code. -You can also join the group created by other users by searching for them via the [directory service](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). We are not responsible for the content shared in these groups. +## Follow our updates + +We publish our updates and releases via: + +- [Reddit](https://www.reddit.com/r/SimpleXChat/), [Twitter](https://twitter.com/SimpleXChat), [Lemmy](https://lemmy.ml/c/simplex), [Mastodon](https://mastodon.social/@simplex) and [Nostr](https://snort.social/p/npub1exv22uulqnmlluszc4yk92jhs2e5ajcs6mu3t00a6avzjcalj9csm7d828). +- SimpleX Chat [team profile](#connect-to-the-team). +- [blog](https://simplex.chat/blog/) and [RSS feed](https://simplex.chat/feed.rss). +- [mailing list](https://simplex.chat/#join-simplex), very rarely. ## Make a private connection 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 Localizations/ar.xcloc/Localized Contents/ar.xliff b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff index 980e511428..e0477899be 100644 --- a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff +++ b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff @@ -3655,6 +3655,26 @@ SimpleX servers cannot see your profile. %1$@ في %2$@: copied message info, <sender> at <time> + + # %@ + # %@ + copied message info title, # <title> + + + ## History + ## السجل + copied message info + + + ## In reply to + ## ردًا على + copied message info + + + %@ and %@ connected + %@ و %@ متصل + No comment provided by engineer. + diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index 5176829332..8e90ae4594 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -44,14 +44,17 @@ # %@ + # %@ copied message info title, # <title> ## History + ## Historie copied message info ## In reply to + ## Odpovídáno copied message info @@ -86,6 +89,7 @@ %@ and %@ connected + %@ a %@ připojen No comment provided by engineer. @@ -120,6 +124,7 @@ %@, %@ and %lld other members connected + %@, %@ a %lld ostatní členové připojeni No comment provided by engineer. @@ -477,7 +482,7 @@ Accept connection request? - Přijmout kontakt + Přijmout kontakt? No comment provided by engineer. @@ -1063,15 +1068,17 @@ Connect directly + Připojit přímo No comment provided by engineer. Connect incognito + Spojit se inkognito No comment provided by engineer. Connect via contact link - Připojit se přes kontaktní odkaz? + Připojit se přes odkaz No comment provided by engineer. @@ -1091,7 +1098,7 @@ Connect via one-time link - Připojit se jednorázovým odkazem? + Připojit se jednorázovým odkazem No comment provided by engineer. @@ -1569,6 +1576,7 @@ Delivery + Doručenka No comment provided by engineer. @@ -1811,6 +1819,10 @@ Šifrovat databázi? No comment provided by engineer. + + Encrypt local files + No comment provided by engineer. + Encrypted database Zašifrovaná databáze @@ -1941,6 +1953,10 @@ Chyba při vytváření profilu! No comment provided by engineer. + + Error decrypting file + No comment provided by engineer. + Error deleting chat database Chyba při mazání databáze chatu @@ -2583,6 +2599,7 @@ Incognito mode protects your privacy by using a new random profile for each contact. + Režim inkognito chrání vaše soukromí používáním nového náhodného profilu pro každý kontakt. No comment provided by engineer. @@ -2659,6 +2676,7 @@ Invalid status + Neplatný status item status text @@ -2744,12 +2762,12 @@ Join incognito - Připojte se inkognito + Připojit se inkognito No comment provided by engineer. Joining group - Připojení ke skupině + Připojování ke skupině No comment provided by engineer. @@ -3009,6 +3027,7 @@ Most likely this connection is deleted. + Pravděpodobně je toto spojení smazáno. item status description @@ -4623,7 +4642,7 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován They can be overridden in contact and group settings. - Mohou být přepsány v nastavení kontaktů + Mohou být přepsány v nastavení kontaktů. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/cs.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings deleted file mode 100644 index 8b13789179..0000000000 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index d9286fb4f5..fe164da9a9 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -1819,6 +1819,10 @@ Datenbank verschlüsseln? No comment provided by engineer. + + Encrypt local files + No comment provided by engineer. + Encrypted database Verschlüsselte Datenbank @@ -1949,6 +1953,10 @@ Fehler beim Erstellen des Profils! No comment provided by engineer. + + Error decrypting file + No comment provided by engineer. + Error deleting chat database Fehler beim Löschen der Chat-Datenbank diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/de.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings deleted file mode 100644 index 8b13789179..0000000000 --- a/apps/ios/SimpleX Localizations/de.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 2ef116055a..5374efbf0f 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -1819,6 +1819,11 @@ Encrypt database? No comment provided by engineer. + + Encrypt local files + Encrypt local files + No comment provided by engineer. + Encrypted database Encrypted database @@ -1949,6 +1954,11 @@ Error creating profile! No comment provided by engineer. + + Error decrypting file + Error decrypting file + No comment provided by engineer. + Error deleting chat database Error deleting chat database diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings deleted file mode 100644 index 8b13789179..0000000000 --- a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index 5df42bc48f..84325c1180 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -466,7 +466,7 @@ About SimpleX address - Acerca de dirección SimpleX + Acerca de la dirección SimpleX No comment provided by engineer. @@ -1208,12 +1208,12 @@ Create SimpleX address - Crear dirección SimpleX + Crear tu dirección SimpleX No comment provided by engineer. Create an address to let people connect with you. - Crear una dirección para que otras personas se puedan conectar contigo. + Crea una dirección para que otras personas puedan conectar contigo. No comment provided by engineer. @@ -1248,7 +1248,7 @@ Create your profile - Crear tu perfil + Crea tu perfil No comment provided by engineer. @@ -1381,7 +1381,7 @@ Decentralized - Descentralizado + Descentralizada No comment provided by engineer. @@ -1706,7 +1706,7 @@ Don't create address - No crear dirección + No crear dirección SimpleX No comment provided by engineer. @@ -1819,6 +1819,10 @@ ¿Cifrar base de datos? No comment provided by engineer. + + Encrypt local files + No comment provided by engineer. + Encrypted database Base de datos cifrada @@ -1949,6 +1953,10 @@ ¡Error al crear perfil! No comment provided by engineer. + + Error decrypting file + No comment provided by engineer. + Error deleting chat database Error al eliminar base de datos @@ -2899,7 +2907,7 @@ Markdown in messages - Sintaxis markdown en los mensajes + Sintaxis Markdown No comment provided by engineer. @@ -3663,7 +3671,7 @@ Receiving address will be changed to a different server. Address change will complete after sender comes online. - La dirección de recepción se cambiará. El cambio se completará cuando el remitente esté en línea. + La dirección de recepción pasará a otro servidor. El cambio se completará cuando el remitente esté en línea. No comment provided by engineer. @@ -4383,7 +4391,7 @@ Stop chat to enable database actions - Para habilitar las acciones sobre la base de datos, previamente debes detener Chat + Detén SimpleX para habilitar las acciones sobre la base de datos No comment provided by engineer. @@ -4590,7 +4598,7 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. The next generation of private messaging - La próxima generación de mensajería privada + La nueva generación de mensajería privada No comment provided by engineer. @@ -5130,7 +5138,7 @@ Para conectarte, pide a tu contacto que cree otro enlace de conexión y comprueb You can create it later - Puedes crearlo más tarde + Puedes crearla más tarde No comment provided by engineer. @@ -5330,7 +5338,7 @@ Para conectarte, pide a tu contacto que cree otro enlace de conexión y comprueb Your chat database - Base de datos Chat + Base de datos No comment provided by engineer. @@ -5411,7 +5419,7 @@ Los servidores de SimpleX no pueden ver tu perfil. Your profile, contacts and delivered messages are stored on your device. - Tu perfil, contactos y mensajes entregados se almacenan en tu dispositivo. + Tu perfil, contactos y mensajes se almacenan en tu dispositivo. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/es.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings deleted file mode 100644 index 8b13789179..0000000000 --- a/apps/ios/SimpleX Localizations/es.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000000..e919fc253a --- /dev/null +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,15 @@ +{ + "colors" : [ + { + "idiom" : "universal", + "locale" : "fi" + } + ], + "properties" : { + "localizable" : true + }, + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/Shared/Assets.xcassets/Contents.json b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/Shared/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/Shared/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index 5a66f06cc0..a03c478767 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -953,792 +953,989 @@ Nykyinen tunnuslause… No comment provided by engineer. - + Currently maximum supported file size is %@. + Nykyinen tuettu enimmäistiedostokoko on %@. No comment provided by engineer. - + Dark + Tumma No comment provided by engineer. - + Database ID + Tietokannan tunnus No comment provided by engineer. - + Database encrypted! + Tietokanta salattu! No comment provided by engineer. - + Database encryption passphrase will be updated and stored in the keychain. + Tietokannan salaustunnuslause päivitetään ja tallennetaan avainnippuun. + No comment provided by engineer. - + Database encryption passphrase will be updated. + Tietokannan salauksen tunnuslause päivitetään. + No comment provided by engineer. - + Database error + Tietokantavirhe No comment provided by engineer. - + Database is encrypted using a random passphrase, you can change it. + Tietokanta on salattu satunnaisella tunnuslauseella, voit muuttaa sitä. No comment provided by engineer. - + Database is encrypted using a random passphrase. Please change it before exporting. + Tietokanta on salattu satunnaisella tunnuslauseella. Vaihda se ennen vientiä. No comment provided by engineer. - + Database passphrase + Tietokannan tunnuslause No comment provided by engineer. - + Database passphrase & export + Tietokannan tunnuslause ja vienti No comment provided by engineer. - + Database passphrase is different from saved in the keychain. + Tietokannan tunnuslause eroaa avainnippuun tallennetusta. No comment provided by engineer. - + Database passphrase is required to open chat. + Keskustelun avaamiseen tarvitaan tietokannan tunnuslause. No comment provided by engineer. - + Database will be encrypted and the passphrase stored in the keychain. + Tietokanta salataan ja tunnuslause tallennetaan avainnippuun. + No comment provided by engineer. - + Database will be encrypted. + Tietokanta salataan. + No comment provided by engineer. - + Database will be migrated when the app restarts + Tietokanta siirretään, kun sovellus käynnistyy uudelleen No comment provided by engineer. - + Decentralized + Hajautettu No comment provided by engineer. - + Delete + Poista chat item action - + Delete Contact + Poista kontakti No comment provided by engineer. - + Delete address + Poista osoite No comment provided by engineer. - + Delete address? + Poista osoite? No comment provided by engineer. - + Delete after + Poista jälkeen No comment provided by engineer. - + Delete all files + Poista kaikki tiedostot No comment provided by engineer. - + Delete archive + Poista arkisto No comment provided by engineer. - + Delete chat archive? + Poista keskusteluarkisto? No comment provided by engineer. - + Delete chat profile? + Poista keskusteluprofiili? No comment provided by engineer. - + Delete connection + Poista yhteys No comment provided by engineer. - + Delete contact + Poista kontakti No comment provided by engineer. - + Delete contact? + Poista kontakti? No comment provided by engineer. - + Delete database + Poista tietokanta No comment provided by engineer. - + Delete files and media? + Poista tiedostot ja media? No comment provided by engineer. - + Delete files for all chat profiles + Poista tiedostot kaikista keskusteluprofiileista No comment provided by engineer. - + Delete for everyone + Poista kaikilta chat feature - + Delete for me + Poista minulta No comment provided by engineer. - + Delete group + Poista ryhmä No comment provided by engineer. - + Delete group? + Poista ryhmä? No comment provided by engineer. - + Delete invitation + Poista kutsu No comment provided by engineer. - + Delete link + Poista linkki No comment provided by engineer. - + Delete link? + Poista linkki? No comment provided by engineer. - + Delete member message? + Poista jäsenviesti? No comment provided by engineer. - + Delete message? + Poista viesti? No comment provided by engineer. - + Delete messages + Poista viestit No comment provided by engineer. - + Delete messages after + Poista viestit tämän jälkeen No comment provided by engineer. - + Delete old database + Poista vanha tietokanta No comment provided by engineer. - + Delete old database? + Poista vanha tietokanta? No comment provided by engineer. - + Delete pending connection + Poista vireillä oleva yhteys No comment provided by engineer. - + Delete pending connection? + Poistetaanko odottava yhteys? No comment provided by engineer. - + Delete queue + Poista jono server test step - + Delete user profile? + Poista käyttäjäprofiili? No comment provided by engineer. - + Description + Kuvaus No comment provided by engineer. - + Develop + Kehitä No comment provided by engineer. - + Developer tools + Kehittäjätyökalut No comment provided by engineer. - + Device + Laite No comment provided by engineer. - + Device authentication is disabled. Turning off SimpleX Lock. + Laitteen todennus on poistettu käytöstä. SimpleX Lock kytketään pois päältä. No comment provided by engineer. - + Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication. + Laitteen todennus ei ole käytössä. Voit ottaa SimpleX Lockin käyttöön Asetuksista, kun olet ottanut laitteen todennuksen käyttöön. No comment provided by engineer. - + Different names, avatars and transport isolation. + Eri nimet, avatarit ja kuljetuseristys. No comment provided by engineer. - + Direct messages + Yksityisviestit chat feature - + Direct messages between members are prohibited in this group. + Yksityisviestit jäsenten välillä ovat kiellettyjä tässä ryhmässä. No comment provided by engineer. - + Disable SimpleX Lock + Poista SimpleX Lock käytöstä authentication reason - + Disappearing messages + Tuhoutuvat viestit chat feature - + Disappearing messages are prohibited in this chat. + Katoavat viestit ovat kiellettyjä tässä keskustelussa. No comment provided by engineer. - + Disappearing messages are prohibited in this group. + Katoavat viestit ovat kiellettyjä tässä ryhmässä. No comment provided by engineer. - + Disconnect + Katkaise server test step - + Display name + Näyttönimi No comment provided by engineer. - + Display name: + Näyttönimi: No comment provided by engineer. - + Do NOT use SimpleX for emergency calls. + Älä käytä SimpleX-sovellusta hätäpuheluihin. No comment provided by engineer. - + Do it later + Tee myöhemmin No comment provided by engineer. - + Don't show again + Älä näytä uudelleen No comment provided by engineer. - + Duplicate display name! + Päällekkäinen näyttönimi! No comment provided by engineer. - + Edit + Muokkaa chat item action - + Edit group profile + Muokkaa ryhmäprofiilia No comment provided by engineer. - + Enable + Salli No comment provided by engineer. - + Enable SimpleX Lock + Ota SimpleX Lock käyttöön authentication reason - + Enable TCP keep-alive + Ota TCP-säilytys käyttöön No comment provided by engineer. - + Enable automatic message deletion? + Ota automaattinen viestien poisto käyttöön? No comment provided by engineer. - + Enable instant notifications? + Salli välittömät ilmoitukset? No comment provided by engineer. - + Enable notifications + Salli ilmoitukset No comment provided by engineer. - + Enable periodic notifications? + Salli säännölliset ilmoitukset? No comment provided by engineer. - + Encrypt + Salaa No comment provided by engineer. - + Encrypt database? + Salaa tietokanta? No comment provided by engineer. - + Encrypted database + Salattu tietokanta No comment provided by engineer. - + Encrypted message or another event + Salattu viesti tai muu tapahtuma notification - + Encrypted message: database error + Salattu viesti: tietokantavirhe notification - + Encrypted message: keychain error + Salattu viesti: avainnipun virhe notification - + Encrypted message: no passphrase + Salattu viesti: ei tunnuslausetta notification - + Encrypted message: unexpected error + Salattu viesti: odottamaton virhe notification - + Enter correct passphrase. + Anna oikea tunnuslause. No comment provided by engineer. - + Enter passphrase… + Syötä tunnuslause… No comment provided by engineer. - + Enter password above to show! + Kirjoita yllä oleva salasana näyttääksesi! No comment provided by engineer. - + Enter server manually + Syötä palvelin manuaalisesti No comment provided by engineer. - + Error + Virhe No comment provided by engineer. - + Error accepting contact request + Virhe kontaktipyynnön hyväksymisessä No comment provided by engineer. - + Error accessing database file + Virhe tietokantatiedoston käyttämisessä No comment provided by engineer. - + Error adding member(s) + Virhe lisättäessä jäseniä No comment provided by engineer. - + Error changing address + Virhe osoitteenvaihdossa No comment provided by engineer. - + Error changing role + Virhe roolin vaihdossa No comment provided by engineer. - + Error changing setting + Virhe asetuksen muuttamisessa No comment provided by engineer. - + Error creating address + Virhe osoitteen luomisessa No comment provided by engineer. - + Error creating group + Virhe ryhmän luomisessa No comment provided by engineer. - + Error creating group link + Virhe ryhmälinkin luomisessa No comment provided by engineer. - + Error creating profile! + Virhe profiilin luomisessa! No comment provided by engineer. - + Error deleting chat database + Virhe keskustelujen tietokannan poistamisessa No comment provided by engineer. - + Error deleting chat! + Virhe keskutelun poistamisessa! No comment provided by engineer. - + Error deleting connection + Virhe yhteyden poistamisessa No comment provided by engineer. - + Error deleting contact + Virhe kontaktin poistamisessa No comment provided by engineer. - + Error deleting database + Virhe tietokannan poistamisessa No comment provided by engineer. - + Error deleting old database + Virhe vanhan tietokannan poistamisessa No comment provided by engineer. - + Error deleting token + Virhe tokenin poistamisessa No comment provided by engineer. - + Error deleting user profile + Virhe käyttäjäprofiilin poistamisessa No comment provided by engineer. - + Error enabling notifications + Virhe ilmoitusten käyttöönotossa No comment provided by engineer. - + Error encrypting database + Virhe tietokannan salauksessa No comment provided by engineer. - + Error exporting chat database + Virhe vietäessä keskustelujen tietokantaa No comment provided by engineer. - + Error importing chat database + Virhe keskustelujen tietokannan tuonnissa No comment provided by engineer. - + Error joining group + Virhe ryhmään liittymisessä No comment provided by engineer. - + Error receiving file + Virhe tiedoston vastaanottamisessa No comment provided by engineer. - + Error removing member + Virhe poistettaessa jäsentä No comment provided by engineer. - + Error saving ICE servers + Virhe ICE-palvelimien tallentamisessa No comment provided by engineer. Error saving SMP servers No comment provided by engineer. - + Error saving group profile + Virhe ryhmäprofiilin tallentamisessa No comment provided by engineer. - + Error saving passphrase to keychain + Virhe tunnuslauseen tallentamisessa avainnippuun No comment provided by engineer. - + Error saving user password + Virhe käyttäjän salasanan tallentamisessa No comment provided by engineer. - + Error sending message + Virhe viestin lähettämisessä No comment provided by engineer. - + Error starting chat + Virhe käynnistettäessä keskustelua No comment provided by engineer. - + Error stopping chat + Virhe keskustelun lopettamisessa No comment provided by engineer. - + Error switching profile! + Virhe profiilin vaihdossa! No comment provided by engineer. - + Error updating group link + Virhe ryhmälinkin päivittämisessä No comment provided by engineer. - + Error updating message + Virhe viestin päivityksessä No comment provided by engineer. - + Error updating settings + Virhe asetusten päivittämisessä No comment provided by engineer. - + Error updating user privacy + Virhe päivitettäessä käyttäjän tietosuojaa No comment provided by engineer. - + Error: %@ + Virhe: %@ No comment provided by engineer. - + Error: URL is invalid + Virhe: URL on virheellinen No comment provided by engineer. - + Error: no database file + Virhe: ei tietokantatiedostoa No comment provided by engineer. - + Exit without saving + Poistu tallentamatta No comment provided by engineer. - + Export database + Vie tietokanta No comment provided by engineer. - + Export error: + Vientivirhe: No comment provided by engineer. - + Exported database archive. + Viety tietokanta-arkisto. No comment provided by engineer. Exporting database archive... No comment provided by engineer. - + Failed to remove passphrase + Tunnuslauseen poisto epäonnistui No comment provided by engineer. - + File will be received when your contact is online, please wait or check later! + Tiedosto vastaanotetaan, kun kontakti on online-tilassa, odota tai tarkista myöhemmin! No comment provided by engineer. - + File: %@ + Tiedosto: %@ No comment provided by engineer. - + Files & media + Tiedostot & media No comment provided by engineer. - + For console + Konsoliin No comment provided by engineer. - + French interface + Ranskalainen käyttöliittymä No comment provided by engineer. - + Full link + Koko linkki No comment provided by engineer. - + Full name (optional) + Koko nimi (valinnainen) No comment provided by engineer. - + Full name: + Koko nimi: No comment provided by engineer. - + Fully re-implemented - work in background! + Täysin uudistettu - toimii taustalla! No comment provided by engineer. - + Further reduced battery usage + Entistä pienempi akun käyttö No comment provided by engineer. - + GIFs and stickers + GIFit ja tarrat No comment provided by engineer. - + Group + Ryhmä No comment provided by engineer. - + Group display name + Ryhmän näyttönimi No comment provided by engineer. - + Group full name (optional) + Ryhmän näyttönimi (valinnainen) No comment provided by engineer. - + Group image + Ryhmäkuva No comment provided by engineer. - + Group invitation + Ryhmän kutsu No comment provided by engineer. - + Group invitation expired + Vanhentunut ryhmäkutsu No comment provided by engineer. - + Group invitation is no longer valid, it was removed by sender. + Ryhmäkutsu ei ole enää voimassa, lähettäjä poisti sen. No comment provided by engineer. - + Group link + Ryhmälinkki No comment provided by engineer. - + Group links + Ryhmälinkit No comment provided by engineer. - + Group members can irreversibly delete sent messages. + Ryhmän jäsenet voivat poistaa lähetetyt viestit peruuttamattomasti. No comment provided by engineer. - + Group members can send direct messages. + Ryhmän jäsenet voivat lähettää suoraviestejä. No comment provided by engineer. - + Group members can send disappearing messages. + Ryhmän jäsenet voivat lähettää katoavia viestejä. No comment provided by engineer. - + Group members can send voice messages. + Ryhmän jäsenet voivat lähettää ääniviestejä. No comment provided by engineer. - + Group message: + Ryhmäviesti: notification - + Group moderation + Ryhmän moderointi No comment provided by engineer. - + Group preferences + Ryhmän asetukset No comment provided by engineer. - + Group profile + Ryhmäprofiili No comment provided by engineer. - + Group profile is stored on members' devices, not on the servers. + Ryhmäprofiili tallennetaan jäsenten laitteille, ei palvelimille. No comment provided by engineer. - + Group welcome message + Ryhmän tervetuloviesti No comment provided by engineer. - + Group will be deleted for all members - this cannot be undone! + Ryhmä poistetaan kaikilta jäseniltä - tätä ei voi kumota! No comment provided by engineer. - + Group will be deleted for you - this cannot be undone! + Ryhmä poistetaan sinulta - tätä ei voi perua! No comment provided by engineer. - + Help + Apua No comment provided by engineer. - + Hidden + Piilotettu No comment provided by engineer. - + Hidden chat profiles + Piilotetut keskusteluprofiilit No comment provided by engineer. - + Hidden profile password + Piilotettu profiilin salasana No comment provided by engineer. - + Hide + Piilota chat item action - + Hide app screen in the recent apps. + Piilota sovellusnäyttö viimeisimmissä sovelluksissa. No comment provided by engineer. - + Hide profile + Piilota profiili No comment provided by engineer. - + How SimpleX works + Miten SimpleX toimii No comment provided by engineer. - + How it works + Kuinka se toimii No comment provided by engineer. - + How to + Miten No comment provided by engineer. - + How to use it + Kuinka sitä käytetään No comment provided by engineer. - + How to use your servers + Miten käytät palvelimiasi No comment provided by engineer. - + ICE servers (one per line) + ICE-palvelimet (yksi per rivi) No comment provided by engineer. If you can't meet in person, **show QR code in the video call**, or share the link. No comment provided by engineer. - + If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link. + Jos et voi tavata henkilökohtaisesti, voit **skannata QR-koodin videopuhelussa** tai kontaktisi voi jakaa kutsulinkin. No comment provided by engineer. - + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). + Jos haluat käyttää keskustelua nyt, napauta **Tee se myöhemmin** alla (sinulle tarjotaan tietokannan siirtämistä, kun käynnistät sovelluksen uudelleen). No comment provided by engineer. - + Ignore + Sivuuta No comment provided by engineer. - + Image will be received when your contact is online, please wait or check later! + Kuva vastaanotetaan, kun kontaktisi on verkossa, odota tai tarkista myöhemmin! No comment provided by engineer. - + Immune to spam and abuse + Immuuni roskapostille ja väärinkäytöksille No comment provided by engineer. - + Import + Tuo No comment provided by engineer. - + Import chat database? + Tuo keskustelujen-tietokanta? No comment provided by engineer. - + Import database + Tuo tietokanta No comment provided by engineer. - + Improved privacy and security + Parannettu yksityisyys ja turvallisuus No comment provided by engineer. - + Improved server configuration + Parannettu palvelimen kokoonpano No comment provided by engineer. - + Incognito + Incognito No comment provided by engineer. - + Incognito mode + Incognito-tila No comment provided by engineer. @@ -1749,73 +1946,91 @@ Incognito mode protects the privacy of your main profile name and image — for each new contact a new random profile is created. No comment provided by engineer. - + Incoming audio call + Saapuva äänipuhelu notification - + Incoming call + Saapuva puhelu notification - + Incoming video call + Saapuva videopuhelu notification - + Incorrect security code! + Väärä turvakoodi! No comment provided by engineer. - + Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) + Asenna [SimpleX Chat terminaalille](https://github.com/simplex-chat/simplex-chat) No comment provided by engineer. - + Instant push notifications will be hidden! + Välittömät push-ilmoitukset ovat piilossa! + No comment provided by engineer. - + Instantly + Heti No comment provided by engineer. - + Interface + Käyttöliittymä No comment provided by engineer. - + Invalid connection link + Virheellinen yhteyslinkki No comment provided by engineer. - + Invalid server address! + Virheellinen palvelinosoite! No comment provided by engineer. - + Invitation expired! + Vanhentunut kutsu! No comment provided by engineer. - + Invite members + Kutsu jäseniä No comment provided by engineer. - + Invite to group + Kutsu ryhmään No comment provided by engineer. - + Irreversible message deletion + Peruuttamaton viestin poisto No comment provided by engineer. - + Irreversible message deletion is prohibited in this chat. + Viestien peruuttamaton poisto on kielletty tässä keskustelussa. No comment provided by engineer. - + Irreversible message deletion is prohibited in this group. + Viestien peruuttamaton poisto on kielletty tässä ryhmässä. No comment provided by engineer. - + It allows having many anonymous connections without any shared data between them in a single chat profile. + Se mahdollistaa useiden nimettömien yhteyksien muodostamisen yhdessä keskusteluprofiilissa ilman, että niiden välillä on jaettuja tietoja. No comment provided by engineer. @@ -1827,1400 +2042,1744 @@ Please connect to the developers via Settings to receive the updates about the s We will be adding server redundancy to prevent lost messages. No comment provided by engineer. - + It seems like you are already connected via this link. If it is not the case, there was an error (%@). + Näyttäisi, että olet jo yhteydessä tämän linkin kautta. Jos näin ei ole, tapahtui virhe (%@). No comment provided by engineer. - + Italian interface + Italialainen käyttöliittymä No comment provided by engineer. - + Join + Liity No comment provided by engineer. - + Join group + Liity ryhmään No comment provided by engineer. - + Join incognito + Liity incognito-tilassa No comment provided by engineer. - + Joining group + Liittyy ryhmään No comment provided by engineer. - + Keychain error + Avainnipun virhe No comment provided by engineer. - + LIVE + LIVE No comment provided by engineer. - + Large file! + Suuri tiedosto! No comment provided by engineer. - + Leave + Poistu No comment provided by engineer. - + Leave group + Poistu ryhmästä No comment provided by engineer. - + Leave group? + Poistu ryhmästä? No comment provided by engineer. - + Light + Vaalea No comment provided by engineer. - + Limitations + Rajoitukset No comment provided by engineer. - + Live message! + Live-viesti! No comment provided by engineer. - + Live messages + Live-viestit No comment provided by engineer. - + Local name + Paikallinen nimi No comment provided by engineer. - + Local profile data only + Vain paikalliset profiilitiedot No comment provided by engineer. - + Make a private connection + Luo yksityinen yhteys No comment provided by engineer. - + Make profile private! + Tee profiilista yksityinen! No comment provided by engineer. Make sure SMP server addresses are in correct format, line separated and are not duplicated (%@). No comment provided by engineer. - + Make sure WebRTC ICE server addresses are in correct format, line separated and are not duplicated. + Varmista, että WebRTC ICE -palvelinosoitteet ovat oikeassa muodossa, rivieroteltuina ja että ne eivät ole päällekkäisiä. No comment provided by engineer. - + Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?* + Monet ihmiset kysyivät: *Jos SimpleX:llä ei ole käyttäjätunnuksia, miten se voi toimittaa viestejä?* No comment provided by engineer. - + Mark deleted for everyone + Merkitse poistetuksi kaikilta No comment provided by engineer. - + Mark read + Merkitse luetuksi No comment provided by engineer. - + Mark verified + Merkitse vahvistetuksi No comment provided by engineer. - + Markdown in messages + Markdown viesteissä No comment provided by engineer. - + Max 30 seconds, received instantly. + Enintään 30 sekuntia, vastaanotetaan välittömästi. No comment provided by engineer. - + Member + Jäsen No comment provided by engineer. - + Member role will be changed to "%@". All group members will be notified. + Jäsenen rooli muuttuu muotoon "%@". Kaikille ryhmän jäsenille ilmoitetaan asiasta. No comment provided by engineer. - + Member role will be changed to "%@". The member will receive a new invitation. + Jäsenen rooli muutetaan muotoon "%@". Jäsen saa uuden kutsun. No comment provided by engineer. - + Member will be removed from group - this cannot be undone! + Jäsen poistetaan ryhmästä - tätä ei voi perua! No comment provided by engineer. - + Message delivery error + Viestin toimitusvirhe No comment provided by engineer. - + Message draft + Viestiluonnos No comment provided by engineer. - + Message text + Viestin teksti No comment provided by engineer. - + Messages + Viestit No comment provided by engineer. Migrating database archive... No comment provided by engineer. - + Migration error: + Siirtovirhe: No comment provided by engineer. - + Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat). + Siirto epäonnistui. Jatka nykyisen tietokannan käyttöä napauttamalla alla **Poistu**. Ilmoita ongelmasta sovelluskehittäjille keskustelussa tai sähköpostitse [chat@simplex.chat](mailto:chat@simplex.chat). No comment provided by engineer. - + Migration is completed + Siirto on valmis No comment provided by engineer. - + Moderate + Moderoi chat item action - + More improvements are coming soon! + Lisää parannuksia on tulossa pian! No comment provided by engineer. - + Most likely this contact has deleted the connection with you. + Todennäköisesti tämä kontakti on poistanut yhteyden sinuun. No comment provided by engineer. - + Multiple chat profiles + Useita keskusteluprofiileja No comment provided by engineer. - + Mute + Mykistä No comment provided by engineer. - + Muted when inactive! + Mykistetty ei-aktiivisena! No comment provided by engineer. - + Name + Nimi No comment provided by engineer. - + Network & servers + Verkko ja palvelimet No comment provided by engineer. - + Network settings + Verkkoasetukset No comment provided by engineer. - + Network status + Verkon tila No comment provided by engineer. - + New contact request + Uusi kontaktipyyntö notification - + New contact: + Uusi kontakti: notification - + New database archive + Uusi tietokanta-arkisto No comment provided by engineer. - + New in %@ + Uutta %@ No comment provided by engineer. - + New member role + Uusi jäsenrooli No comment provided by engineer. - + New message + Uusi viesti notification - + New passphrase… + Uusi tunnuslause… No comment provided by engineer. - + No + Ei No comment provided by engineer. - + No contacts selected + Kontakteja ei ole valittu No comment provided by engineer. - + No contacts to add + Ei lisättäviä kontakteja No comment provided by engineer. - + No device token! + Ei laitetunnusta! No comment provided by engineer. - + Group not found! + Ryhmää ei löydy! No comment provided by engineer. - + No permission to record voice message + Ei lupaa ääniviestin tallentamiseen No comment provided by engineer. - + No received or sent files + Ei vastaanotettuja tai lähetettyjä tiedostoja No comment provided by engineer. - + Notifications + Ilmoitukset No comment provided by engineer. - + Notifications are disabled! + Ilmoitukset on poistettu käytöstä! No comment provided by engineer. - + Now admins can: - delete members' messages. - disable members ("observer" role) + Nyt järjestelmänvalvojat voivat: +- poistaa jäsenten viestit. +- poista jäsenet käytöstä ("tarkkailija" rooli) No comment provided by engineer. - + Off (Local) + Pois (Paikallinen) No comment provided by engineer. - + Ok + Ok No comment provided by engineer. - + Old database + Vanha tietokanta No comment provided by engineer. - + Old database archive + Vanha tietokanta-arkisto No comment provided by engineer. - + One-time invitation link + Kertakutsulinkki No comment provided by engineer. - + Onion hosts will be required for connection. Requires enabling VPN. + Yhteyden muodostamiseen tarvitaan Onion-isäntiä. Edellyttää VPN:n sallimista. No comment provided by engineer. - + Onion hosts will be used when available. Requires enabling VPN. + Onion-isäntiä käytetään, kun niitä on saatavilla. Edellyttää VPN:n sallimista. No comment provided by engineer. - + Onion hosts will not be used. + Onion-isäntiä ei käytetä. No comment provided by engineer. - + Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**. + Vain asiakaslaitteet tallentavat käyttäjäprofiileja, yhteystietoja, ryhmiä ja viestejä, jotka on lähetetty **kaksinkertaisella päästä päähän -salauksella**. No comment provided by engineer. - + Only group owners can change group preferences. + Vain ryhmän omistajat voivat muuttaa ryhmän asetuksia. No comment provided by engineer. - + Only group owners can enable voice messages. + Vain ryhmän omistajat voivat ottaa ääniviestit käyttöön. No comment provided by engineer. - + Only you can irreversibly delete messages (your contact can mark them for deletion). + Vain sinä voit poistaa viestejä peruuttamattomasti (kontaktisi voi merkitä ne poistettavaksi). No comment provided by engineer. - + Only you can send disappearing messages. + Vain sinä voit lähettää katoavia viestejä. No comment provided by engineer. - + Only you can send voice messages. + Vain sinä voit lähettää ääniviestejä. No comment provided by engineer. - + Only your contact can irreversibly delete messages (you can mark them for deletion). + Vain kontaktisi voi poistaa viestejä peruuttamattomasti (voit merkitä ne poistettavaksi). No comment provided by engineer. - + Only your contact can send disappearing messages. + Vain kontaktisi voi lähettää katoavia viestejä. No comment provided by engineer. - + Only your contact can send voice messages. + Vain kontaktisi voi lähettää ääniviestejä. No comment provided by engineer. - + Open Settings + Avaa Asetukset No comment provided by engineer. - + Open chat + Avaa keskustelu No comment provided by engineer. - + Open chat console + Avaa keskustelukonsoli authentication reason - + Open user profiles + Avaa käyttäjäprofiilit authentication reason - + Open-source protocol and code – anybody can run the servers. + Avoimen lähdekoodin protokolla ja koodi - kuka tahansa voi käyttää palvelimia. No comment provided by engineer. - + Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. + Linkin avaaminen selaimessa voi heikentää yhteyden yksityisyyttä ja turvallisuutta. Epäluotetut SimpleX-linkit näkyvät punaisina. No comment provided by engineer. - + PING count + PING-määrä No comment provided by engineer. - + PING interval + PING-väli No comment provided by engineer. - + Password to show + Salasana näytettäväksi No comment provided by engineer. - + Paste + Liitä No comment provided by engineer. - + Paste image + Liitä kuva No comment provided by engineer. - + Paste received link + Liitä vastaanotettu linkki No comment provided by engineer. Paste the link you received into the box below to connect with your contact. No comment provided by engineer. - + People can connect to you only via the links you share. + Ihmiset voivat ottaa sinuun yhteyttä vain jakamiesi linkkien kautta. No comment provided by engineer. - + Periodically + Ajoittain No comment provided by engineer. - + Please ask your contact to enable sending voice messages. + Pyydä kontaktiasi sallimaan ääniviestien lähettäminen. No comment provided by engineer. - + Please check that you used the correct link or ask your contact to send you another one. + Tarkista, että käytit oikeaa linkkiä tai pyydä kontaktiasi lähettämään sinulle uusi linkki. No comment provided by engineer. - + Please check your network connection with %@ and try again. + Tarkista verkkoyhteytesi %@:lla ja yritä uudelleen. No comment provided by engineer. - + Please check yours and your contact preferences. + Tarkista omasi ja kontaktin asetukset. No comment provided by engineer. - + Please contact group admin. + Ota yhteyttä ryhmän ylläpitäjään. No comment provided by engineer. - + Please enter correct current passphrase. + Anna oikea nykyinen tunnuslause. No comment provided by engineer. - + Please enter the previous password after restoring database backup. This action can not be undone. + Anna edellinen salasana tietokannan varmuuskopion palauttamisen jälkeen. Tätä toimintoa ei voi kumota. No comment provided by engineer. - + Please restart the app and migrate the database to enable push notifications. + Käynnistä sovellus uudelleen ja siirrä tietokanta push-ilmoitusten ottamiseksi käyttöön. No comment provided by engineer. - + Please store passphrase securely, you will NOT be able to access chat if you lose it. + Säilytä tunnuslause turvallisesti, ET pääse keskusteluihin, jos kadotat sen. No comment provided by engineer. - + Please store passphrase securely, you will NOT be able to change it if you lose it. + Säilytä tunnuslause turvallisesti, ET voi muuttaa sitä, jos kadotat sen. No comment provided by engineer. - + Possibly, certificate fingerprint in server address is incorrect + Palvelimen osoitteen varmenteen sormenjälki on mahdollisesti virheellinen server test error - + Preserve the last message draft, with attachments. + Säilytä viimeinen viestiluonnos liitteineen. No comment provided by engineer. - + Preset server + Esiasetettu palvelin No comment provided by engineer. - + Preset server address + Esiasetettu palvelimen osoite No comment provided by engineer. - + Privacy & security + Yksityisyys ja turvallisuus No comment provided by engineer. - + Privacy redefined + Yksityisyys uudelleen määritettynä No comment provided by engineer. - + Private filenames + Yksityiset tiedostonimet No comment provided by engineer. - + Profile and server connections + Profiili- ja palvelinyhteydet No comment provided by engineer. - + Profile image + Profiilikuva No comment provided by engineer. - + Prohibit irreversible message deletion. + Estä peruuttamaton viestien poistaminen. No comment provided by engineer. - + Prohibit sending direct messages to members. + Estä suorien viestien lähettäminen jäsenille. No comment provided by engineer. - + Prohibit sending disappearing messages. + Estä katoavien viestien lähettäminen. No comment provided by engineer. - + Prohibit sending voice messages. + Estä ääniviestien lähettäminen. No comment provided by engineer. - + Protect app screen + Suojaa sovellusnäyttö No comment provided by engineer. - + Protect your chat profiles with a password! + Suojaa keskusteluprofiilisi salasanalla! No comment provided by engineer. - + Protocol timeout + Protokollan aikakatkaisu No comment provided by engineer. - + Push notifications + Push-ilmoitukset No comment provided by engineer. - + Rate the app + Arvioi sovellus No comment provided by engineer. - + Read + Lue No comment provided by engineer. - + Read more in our GitHub repository. + Lue lisää GitHub-tietovarastostamme. No comment provided by engineer. - + Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). + Lue lisää [GitHub-arkistosta](https://github.com/simplex-chat/simplex-chat#readme). No comment provided by engineer. - + Received file event + Tiedoston vastaanottotapahtuma notification - + Receiving via + Vastaanotto kautta No comment provided by engineer. - + Recipients see updates as you type them. + Vastaanottajat näkevät päivitykset, kun kirjoitat niitä. No comment provided by engineer. - + Reduced battery usage + Pienempi akun käyttö No comment provided by engineer. - + Reject + Hylkää reject incoming call via notification Reject contact (sender NOT notified) No comment provided by engineer. - + Reject contact request + Hylkää yhteyspyyntö No comment provided by engineer. - + Relay server is only used if necessary. Another party can observe your IP address. + Välityspalvelinta käytetään vain tarvittaessa. Toinen osapuoli voi tarkkailla IP-osoitettasi. No comment provided by engineer. - + Relay server protects your IP address, but it can observe the duration of the call. + Välityspalvelin suojaa IP-osoitteesi, mutta se voi tarkkailla puhelun kestoa. No comment provided by engineer. - + Remove + Poista No comment provided by engineer. - + Remove member + Poista jäsen No comment provided by engineer. - + Remove member? + Poista jäsen? No comment provided by engineer. - + Remove passphrase from keychain? + Poista tunnuslause avainnipusta? No comment provided by engineer. - + Reply + Vastaa chat item action - + Required + Pakollinen No comment provided by engineer. - + Reset + Oletustilaan No comment provided by engineer. - + Reset colors + Oletusvärit No comment provided by engineer. - + Reset to defaults + Palauta oletusasetukset No comment provided by engineer. - + Restart the app to create a new chat profile + Käynnistä sovellus uudelleen uuden keskusteluprofiilin luomiseksi No comment provided by engineer. - + Restart the app to use imported chat database + Käynnistä sovellus uudelleen käyttääksesi tuotua keskustelujen-tietokantaa No comment provided by engineer. - + Restore + Palauta No comment provided by engineer. - + Restore database backup + Palauta tietokannan varmuuskopio No comment provided by engineer. - + Restore database backup? + Palauta tietokannan varmuuskopio? No comment provided by engineer. - + Restore database error + Virhe tietokannan palauttamisessa No comment provided by engineer. - + Reveal + Paljasta chat item action - + Revert + Palauta No comment provided by engineer. - + Role + Rooli No comment provided by engineer. - + Run chat + Käynnistä chat No comment provided by engineer. - + SMP servers + SMP-palvelimet No comment provided by engineer. - + Save + Tallenna chat item action - + Save (and notify contacts) + Tallenna (ja ilmoita kontakteille) No comment provided by engineer. - + Save and notify contact + Tallenna ja ilmoita kontaktille No comment provided by engineer. - + Save and notify group members + Tallenna ja ilmoita ryhmän jäsenille No comment provided by engineer. - + Save and update group profile + Tallenna ja päivitä ryhmäprofiili No comment provided by engineer. - + Save archive + Tallenna arkisto No comment provided by engineer. - + Save group profile + Tallenna ryhmäprofiili No comment provided by engineer. - + Save passphrase and open chat + Tallenna tunnuslause ja avaa keskustelu No comment provided by engineer. - + Save passphrase in Keychain + Tallenna tunnuslause Avainnippuun No comment provided by engineer. - + Save preferences? + Tallenna asetukset? No comment provided by engineer. - + Save profile password + Tallenna profiilin salasana No comment provided by engineer. - + Save servers + Tallenna palvelimet No comment provided by engineer. - + Save servers? + Tallenna palvelimet? No comment provided by engineer. - + Save welcome message? + Tallenna tervetuloviesti? No comment provided by engineer. - + Saved WebRTC ICE servers will be removed + Tallennetut WebRTC ICE -palvelimet poistetaan No comment provided by engineer. - + Scan QR code + Skannaa QR-koodi No comment provided by engineer. - + Scan code + Skannaa koodi No comment provided by engineer. - + Scan security code from your contact's app. + Skannaa turvakoodi kontaktisi sovelluksesta. No comment provided by engineer. - + Scan server QR code + Skannaa palvelimen QR-koodi No comment provided by engineer. - + Search + Haku No comment provided by engineer. - + Secure queue + Turvallinen jono server test step - + Security assessment + Turvallisuusarviointi No comment provided by engineer. - + Security code + Turvakoodi No comment provided by engineer. - + Send + Lähetä No comment provided by engineer. - + Send a live message - it will update for the recipient(s) as you type it + Lähetä live-viesti - se päivittyy vastaanottajille, kun kirjoitat sitä No comment provided by engineer. - + Send direct message + Lähetä yksityisviesti No comment provided by engineer. - + Send link previews + Lähetä linkkien esikatselu No comment provided by engineer. - + Send live message + Lähetä live-viesti No comment provided by engineer. - + Send notifications + Lähetys ilmoitukset No comment provided by engineer. - + Send notifications: + Lähetys ilmoitukset: No comment provided by engineer. - + Send questions and ideas + Lähetä kysymyksiä ja ideoita No comment provided by engineer. - + Send them from gallery or custom keyboards. + Lähetä ne galleriasta tai mukautetuista näppäimistöistä. No comment provided by engineer. - + Sender cancelled file transfer. + Lähettäjä peruutti tiedoston siirron. No comment provided by engineer. - + Sender may have deleted the connection request. + Lähettäjä on saattanut poistaa yhteyspyynnön. No comment provided by engineer. - + Sending via + Lähetetään kautta No comment provided by engineer. - + Sent file event + Lähetetty tiedosto tapahtuma notification - + Sent messages will be deleted after set time. + Lähetetyt viestit poistetaan asetetun ajan kuluttua. No comment provided by engineer. - + Server requires authorization to create queues, check password + Palvelin vaatii valtuutuksen jonojen luomiseen, tarkista salasana server test error - + Server test failed! + Palvelintesti epäonnistui! No comment provided by engineer. - + Servers + Palvelimet No comment provided by engineer. - + Set 1 day + Aseta 1 päivä No comment provided by engineer. - + Set contact name… + Aseta kontaktin nimi… No comment provided by engineer. - + Set group preferences + Aseta ryhmän asetukset No comment provided by engineer. - + Set passphrase to export + Aseta tunnuslause vientiä varten No comment provided by engineer. - + Set the message shown to new members! + Aseta uusille jäsenille näytettävä viesti! No comment provided by engineer. - + Set timeouts for proxy/VPN + Aseta aikakatkaisut välityspalvelimelle/VPN:lle No comment provided by engineer. - + Settings + Asetukset No comment provided by engineer. - + Share + Jaa chat item action Share invitation link No comment provided by engineer. - + Share link + Jaa linkki No comment provided by engineer. - + Share one-time invitation link + Jaa kertakutsulinkki No comment provided by engineer. Show QR code No comment provided by engineer. - + Show calls in phone history + Näytä puhelut puhelinhistoriassa No comment provided by engineer. - + Show preview + Näytä esikatselu No comment provided by engineer. - + SimpleX Chat security was audited by Trail of Bits. + Trail of Bits on tarkastanut SimpleX Chatin tietoturvan. No comment provided by engineer. - + SimpleX Lock + SimpleX Lock No comment provided by engineer. - + SimpleX Lock turned on + SimpleX Lock päällä No comment provided by engineer. - + SimpleX contact address + SimpleX-yhteystiedot simplex link type - + SimpleX encrypted message or connection event + SimpleX-salattu viesti tai yhteystapahtuma notification - + SimpleX group link + SimpleX-ryhmän linkki simplex link type - + SimpleX links + SimpleX-linkit No comment provided by engineer. - + SimpleX one-time invitation + SimpleX-kertakutsu simplex link type - + Skip + Ohita No comment provided by engineer. - + Skipped messages + Ohitetut viestit No comment provided by engineer. - + Somebody + Joku notification title - + Start a new chat + Aloita uusi keskustelu No comment provided by engineer. - + Start chat + Aloita keskustelu No comment provided by engineer. - + Start migration + Aloita siirto No comment provided by engineer. - + Stop + Lopeta No comment provided by engineer. - + Stop SimpleX + Lopeta SimpleX authentication reason - + Stop chat to enable database actions + Pysäytä keskustelu tietokantatoimien mahdollistamiseksi No comment provided by engineer. - + Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped. + Pysäytä keskustelut viedäksesi, tuodaksesi tai poistaaksesi keskustelujen tietokannan. Et voi vastaanottaa ja lähettää viestejä, kun keskustelut on pysäytetty. No comment provided by engineer. - + Stop chat? + Lopeta keskustelu? No comment provided by engineer. - + Support SimpleX Chat + SimpleX Chat tuki No comment provided by engineer. - + System + Järjestelmä No comment provided by engineer. - + TCP connection timeout + TCP-yhteyden aikakatkaisu No comment provided by engineer. - + TCP_KEEPCNT + TCP_KEEPCNT No comment provided by engineer. - + TCP_KEEPIDLE + TCP_KEEPIDLE No comment provided by engineer. - + TCP_KEEPINTVL + TCP_KEEPINTVL No comment provided by engineer. - + Take picture + Ota kuva No comment provided by engineer. - + Tap button + Napauta painiketta No comment provided by engineer. - + Tap to activate profile. + Aktivoi profiili napauttamalla. No comment provided by engineer. - + Tap to join + Liity napauttamalla No comment provided by engineer. - + Tap to join incognito + Napauta liittyäksesi incognito-tilassa No comment provided by engineer. - + Tap to start a new chat + Aloita uusi keskustelu napauttamalla No comment provided by engineer. - + Test failed at step %@. + Testi epäonnistui vaiheessa %@. server test failure - + Test server + Testipalvelin No comment provided by engineer. - + Test servers + Testipalvelimet No comment provided by engineer. - + Tests failed! + Testit epäonnistuivat! No comment provided by engineer. - + Thank you for installing SimpleX Chat! + Kiitos SimpleX Chatin asentamisesta! No comment provided by engineer. - + Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! + Kiitos käyttäjille - [osallistu Weblaten avulla](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! No comment provided by engineer. - + Thanks to the users – contribute via Weblate! + Kiitokset käyttäjille – osallistu Weblaten kautta! No comment provided by engineer. - + The 1st platform without any user identifiers – private by design. + Ensimmäinen alusta ilman käyttäjätunnisteita – suunniteltu yksityiseksi. No comment provided by engineer. - + The app can notify you when you receive messages or contact requests - please open settings to enable. + Sovellus voi ilmoittaa sinulle, kun saat viestejä tai yhteydenottopyyntöjä - avaa asetukset ottaaksesi ne käyttöön. No comment provided by engineer. - + The attempt to change database passphrase was not completed. + Tietokannan tunnuslauseen muuttamista ei suoritettu loppuun. No comment provided by engineer. - + The connection you accepted will be cancelled! + Hyväksymäsi yhteys peruuntuu! No comment provided by engineer. - + The contact you shared this link with will NOT be able to connect! + Kontakti, jolle jaoit tämän linkin, EI voi muodostaa yhteyttä! No comment provided by engineer. - + The created archive is available via app Settings / Database / Old database archive. + Luotu arkisto on käytettävissä sovelluksen Asetukset / Tietokanta / Vanha tietokanta-arkisto kautta. No comment provided by engineer. - + The group is fully decentralized – it is visible only to the members. + Ryhmä on täysin hajautettu - se näkyy vain jäsenille. No comment provided by engineer. - + The message will be deleted for all members. + Viesti poistetaan kaikilta jäseniltä. No comment provided by engineer. - + The message will be marked as moderated for all members. + Viesti merkitään moderoiduksi kaikille jäsenille. No comment provided by engineer. - + The next generation of private messaging + Seuraavan sukupolven yksityisviestit No comment provided by engineer. - + The old database was not removed during the migration, it can be deleted. + Vanhaa tietokantaa ei poistettu siirron aikana, se voidaan kuitenkin poistaa. No comment provided by engineer. - + The profile is only shared with your contacts. + Profiili jaetaan vain kontaktiesi kanssa. No comment provided by engineer. - + The sender will NOT be notified + Lähettäjälle EI ilmoiteta No comment provided by engineer. - + The servers for new connections of your current chat profile **%@**. + Palvelimet nykyisen keskusteluprofiilisi uusille yhteyksille **%@**. No comment provided by engineer. - + Theme + Teema No comment provided by engineer. - + There should be at least one user profile. + Käyttäjäprofiileja tulee olla vähintään yksi. No comment provided by engineer. - + There should be at least one visible user profile. + Näkyviä käyttäjäprofiileja tulee olla vähintään yksi. No comment provided by engineer. - + This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain. + Tätä toimintoa ei voi kumota - kaikki vastaanotetut ja lähetetyt tiedostot ja media poistetaan. Matalan resoluution kuvat säilyvät. No comment provided by engineer. - + This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes. + Tätä toimintoa ei voi kumota - valittua aikaisemmin lähetetyt ja vastaanotetut viestit poistetaan. Tämä voi kestää useita minuutteja. No comment provided by engineer. - + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. + Tätä toimintoa ei voi kumota - profiilisi, kontaktisi, viestisi ja tiedostosi poistuvat peruuttamattomasti. No comment provided by engineer. This feature is experimental! It will only work if the other client has version 4.2 installed. You should see the message in the conversation once the address change is completed – please check that you can still receive messages from this contact (or group member). No comment provided by engineer. - + This group no longer exists. + Tätä ryhmää ei enää ole olemassa. No comment provided by engineer. - + This setting applies to messages in your current chat profile **%@**. + Tämä asetus koskee nykyisen keskusteluprofiilisi viestejä *%@**. No comment provided by engineer. - + To ask any questions and to receive updates: + Voit esittää kysymyksiä ja saada päivityksiä: No comment provided by engineer. To find the profile used for an incognito connection, tap the contact or group name on top of the chat. No comment provided by engineer. - + To make a new connection + Uuden yhteyden luominen No comment provided by engineer. - + To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts. + Yksityisyyden suojaamiseksi kaikkien muiden alustojen käyttämien käyttäjätunnusten sijaan SimpleX käyttää viestijonojen tunnisteita, jotka ovat kaikille kontakteille erillisiä. No comment provided by engineer. - + To protect timezone, image/voice files use UTC. + Aikavyöhykkeen suojaamiseksi kuva-/äänitiedostot käyttävät UTC:tä. No comment provided by engineer. - + To protect your information, turn on SimpleX Lock. You will be prompted to complete authentication before this feature is enabled. + Suojaa tietosi ottamalla SimpleX Lock käyttöön. +Sinua kehotetaan suorittamaan todennus loppuun, ennen kuin tämä ominaisuus otetaan käyttöön. No comment provided by engineer. - + To record voice message please grant permission to use Microphone. + Jos haluat nauhoittaa ääniviestin, anna lupa käyttää mikrofonia. No comment provided by engineer. - + To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page. + Voit paljastaa piilotetun profiilisi syöttämällä koko salasanan hakukenttään **Keskusteluprofiilisi** -sivulla. No comment provided by engineer. - + To support instant push notifications the chat database has to be migrated. + Keskustelujen-tietokanta on siirrettävä välittömien push-ilmoitusten tukemiseksi. No comment provided by engineer. - + To verify end-to-end encryption with your contact compare (or scan) the code on your devices. + Voit tarkistaa päästä päähän -salauksen kontaktisi kanssa vertaamalla (tai skannaamalla) laitteidenne koodia. No comment provided by engineer. - + Transport isolation + Kuljetuksen eristäminen No comment provided by engineer. - + Trying to connect to the server used to receive messages from this contact (error: %@). + Yritetään muodostaa yhteyttä palvelimeen, jota käytetään tämän kontaktin viestien vastaanottamiseen (virhe: %@). No comment provided by engineer. - + Trying to connect to the server used to receive messages from this contact. + Yritetään muodostaa yhteys palvelimeen, jota käytetään viestien vastaanottamiseen tältä kontaktilta. No comment provided by engineer. - + Turn off + Sammuta No comment provided by engineer. - + Turn off notifications? + Kytke ilmoitukset pois päältä? No comment provided by engineer. - + Turn on + Kytke päälle No comment provided by engineer. - + Unable to record voice message + Ääniviestiä ei voi tallentaa No comment provided by engineer. - + Unexpected error: %@ + Odottamaton virhe: %@ No comment provided by engineer. - + Unexpected migration state + Odottamaton siirtotila No comment provided by engineer. - + Unhide + Näytä No comment provided by engineer. - + Unknown caller + Tuntematon soittaja callkit banner - + Unknown database error: %@ + Tuntematon tietokantavirhe: %@ No comment provided by engineer. - + Unknown error + Tuntematon virhe No comment provided by engineer. - + Unless you use iOS call interface, enable Do Not Disturb mode to avoid interruptions. + Ellet käytä iOS:n puhelinkäyttöliittymää, ota Älä häiritse -tila käyttöön keskeytysten välttämiseksi. No comment provided by engineer. - + Unless your contact deleted the connection or this link was already used, it might be a bug - please report it. To connect, please ask your contact to create another connection link and check that you have a stable network connection. + Ellei yhteyshenkilösi poistanut yhteyttä tai tämä linkki oli jo käytössä, se voi olla virhe - ilmoita siitä. +Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja tarkista, että verkkoyhteytesi on vakaa. No comment provided by engineer. - + Unlock + Avaa authentication reason - + Unmute + Poista mykistys No comment provided by engineer. - + Unread + Lukematon No comment provided by engineer. - + Update + Päivitä No comment provided by engineer. - + Update .onion hosts setting? + Päivitä .onion-isäntien asetus? No comment provided by engineer. - + Update database passphrase + Päivitä tietokannan tunnuslause No comment provided by engineer. - + Update network settings? + Päivitä verkkoasetukset? No comment provided by engineer. - + Update transport isolation mode? + Päivitä kuljetuksen eristystila? No comment provided by engineer. - + Updating settings will re-connect the client to all servers. + Asetusten päivittäminen yhdistää asiakkaan uudelleen kaikkiin palvelimiin. No comment provided by engineer. - + Updating this setting will re-connect the client to all servers. + Tämän asetuksen päivittäminen yhdistää asiakkaan uudelleen kaikkiin palvelimiin. No comment provided by engineer. - + Use .onion hosts + Käytä .onion-isäntiä No comment provided by engineer. - + Use SimpleX Chat servers? + Käytä SimpleX Chat palvelimia? No comment provided by engineer. - + Use chat + Käytä chattia No comment provided by engineer. - + Use for new connections + Käytä uusiin yhteyksiin No comment provided by engineer. - + Use iOS call interface + Käytä iOS:n puhelujen käyttöliittymää No comment provided by engineer. - + Use server + Käytä palvelinta No comment provided by engineer. - + User profile + Käyttäjäprofiili No comment provided by engineer. - + Using .onion hosts requires compatible VPN provider. + .onion-isäntien käyttäminen vaatii yhteensopivan VPN-palveluntarjoajan. No comment provided by engineer. - + Using SimpleX Chat servers. + Käyttää SimpleX Chat -palvelimia. No comment provided by engineer. - + Verify connection security + Tarkista yhteyden suojaus No comment provided by engineer. - + Verify security code + Tarkista turvakoodi No comment provided by engineer. - + Via browser + Selaimella No comment provided by engineer. - + Video call + Videopuhelu No comment provided by engineer. - + View security code + Näytä turvakoodi No comment provided by engineer. - + Voice messages + Ääniviestit chat feature - + Voice messages are prohibited in this chat. + Ääniviestit ovat kiellettyjä tässä keskustelussa. No comment provided by engineer. - + Voice messages are prohibited in this group. + Ääniviestit ovat kiellettyjä tässä ryhmässä. No comment provided by engineer. - + Voice messages prohibited! + Ääniviestit kielletty! No comment provided by engineer. - + Voice message… + Ääniviesti… No comment provided by engineer. - + Waiting for file + Odottaa tiedostoa No comment provided by engineer. - + Waiting for image + Odottaa kuvaa No comment provided by engineer. - + WebRTC ICE servers + WebRTC ICE -palvelimet No comment provided by engineer. - + Welcome %@! + Tervetuloa %@! No comment provided by engineer. - + Welcome message + Tervetuloviesti No comment provided by engineer. - + What's new + Uusimmat No comment provided by engineer. - + When available + Kun saatavilla No comment provided by engineer. - + When you share an incognito profile with somebody, this profile will be used for the groups they invite you to. + Kun jaat inkognitoprofiilin jonkun kanssa, tätä profiilia käytetään ryhmissä, joihin tämä sinut kutsuu. No comment provided by engineer. - + With optional welcome message. + Valinnaisella tervetuloviestillä. No comment provided by engineer. - + Wrong database passphrase + Väärä tietokannan tunnuslause No comment provided by engineer. - + Wrong passphrase! + Väärä tunnuslause! No comment provided by engineer. - + You + Sinä No comment provided by engineer. - + You accepted connection + Hyväksyit yhteyden No comment provided by engineer. - + You allow + Sallit No comment provided by engineer. - + You already have a chat profile with the same display name. Please choose another name. + Sinulla on jo keskusteluprofiili samalla näyttönimellä. Valitse toinen nimi. No comment provided by engineer. - + You are already connected to %@. + Olet jo muodostanut yhteyden %@:n kanssa. No comment provided by engineer. - + You are connected to the server used to receive messages from this contact. + Olet yhteydessä palvelimeen, jota käytetään vastaanottamaan viestejä tältä kontaktilta. No comment provided by engineer. - + You are invited to group + Sinut on kutsuttu ryhmään No comment provided by engineer. - + You can accept calls from lock screen, without device and app authentication. + Voit vastaanottaa puheluita lukitusnäytöltä ilman laitteen ja sovelluksen todennusta. No comment provided by engineer. - + You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button. + Voit myös muodostaa yhteyden klikkaamalla linkkiä. Jos se avautuu selaimessa, napsauta **Avaa mobiilisovelluksessa**-painiketta. No comment provided by engineer. @@ -3228,148 +3787,180 @@ To connect, please ask your contact to create another connection link and check SimpleX Lock must be enabled. No comment provided by engineer. - + You can now send messages to %@ + Voit nyt lähettää viestejä %@:lle notification body - + You can set lock screen notification preview via settings. + Voit määrittää lukitusnäytön ilmoituksen esikatselun asetuksista. No comment provided by engineer. - + You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it. + Voit jakaa linkin tai QR-koodin - kuka tahansa voi liittyä ryhmään. Et menetä ryhmän jäseniä, jos poistat sen myöhemmin. No comment provided by engineer. You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it. No comment provided by engineer. - + You can start chat via app Settings / Database or by restarting the app + Voit aloittaa keskustelun sovelluksen Asetukset / Tietokanta kautta tai käynnistämällä sovelluksen uudelleen No comment provided by engineer. - + You can use markdown to format messages: + Voit käyttää markdownia viestien muotoiluun: No comment provided by engineer. - + You can't send messages! + Et voi lähettää viestejä! No comment provided by engineer. - + You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them. + Sinä hallitset, minkä palvelim(i)en kautta **viestit vastaanotetaan**, kontaktisi - palvelimet, joita käytät viestien lähettämiseen niille. No comment provided by engineer. - + You could not be verified; please try again. + Sinua ei voitu todentaa; yritä uudelleen. No comment provided by engineer. - + You have no chats + Sinulla ei ole keskusteluja No comment provided by engineer. - + You have to enter passphrase every time the app starts - it is not stored on the device. + Sinun on annettava tunnuslause aina, kun sovellus käynnistyy - sitä ei tallenneta laitteeseen. No comment provided by engineer. You invited your contact No comment provided by engineer. - + You joined this group + Liityit tähän ryhmään No comment provided by engineer. - + You joined this group. Connecting to inviting group member. + Liityit tähän ryhmään. Muodostetaan yhteyttä ryhmän jäsenten kutsumiseksi. No comment provided by engineer. - + You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts. + Sinun tulee käyttää keskustelujen-tietokannan uusinta versiota AINOSTAAN yhdessä laitteessa, muuten saatat lakata vastaanottamasta viestejä joiltakin kontakteilta. No comment provided by engineer. - + You need to allow your contact to send voice messages to be able to send them. + Sinun on sallittava kontaktiesi lähettää ääniviestejä, jotta voit lähettää niitä. No comment provided by engineer. - + You rejected group invitation + Hylkäsit ryhmäkutsun No comment provided by engineer. - + You sent group invitation + Lähetit ryhmäkutsun No comment provided by engineer. - + You will be connected to group when the group host's device is online, please wait or check later! + Sinut yhdistetään ryhmään, kun ryhmän isännän laite on online-tilassa, odota tai tarkista myöhemmin! No comment provided by engineer. - + You will be connected when your connection request is accepted, please wait or check later! + Sinut yhdistetään, kun yhteyspyyntösi on hyväksytty, odota tai tarkista myöhemmin! No comment provided by engineer. - + You will be connected when your contact's device is online, please wait or check later! + Sinut yhdistetään, kun kontaktisi laite on online-tilassa, odota tai tarkista myöhemmin! No comment provided by engineer. - + You will be required to authenticate when you start or resume the app after 30 seconds in background. + Sinun on tunnistauduttava, kun käynnistät sovelluksen tai jatkat sen käyttöä 30 sekunnin tauon jälkeen. No comment provided by engineer. - + You will join a group this link refers to and connect to its group members. + Liityt ryhmään, johon tämä linkki viittaa, ja muodostat yhteyden sen ryhmän jäseniin. No comment provided by engineer. - + You will still receive calls and notifications from muted profiles when they are active. + Saat edelleen puheluita ja ilmoituksia mykistetyiltä profiileilta, kun ne ovat aktiivisia. No comment provided by engineer. - + You will stop receiving messages from this group. Chat history will be preserved. + Et enää saa viestejä tästä ryhmästä. Keskusteluhistoria säilytetään. No comment provided by engineer. - + You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile + Yrität kutsua kontaktia, jonka kanssa olet jakanut inkognito-profiilin, ryhmään, jossa käytät pääprofiiliasi No comment provided by engineer. - + You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed + Käytät tässä ryhmässä incognito-profiilia. Kontaktien kutsuminen ei ole sallittua, jotta pääprofiilisi ei tule jaetuksi No comment provided by engineer. - + Your ICE servers + ICE-palvelimesi No comment provided by engineer. - + Your SMP servers + SMP-palvelimesi No comment provided by engineer. Your SimpleX contact address No comment provided by engineer. - + Your calls + Puhelusi No comment provided by engineer. - + Your chat database + Keskustelut-tietokantasi No comment provided by engineer. - + Your chat database is not encrypted - set passphrase to encrypt it. + Keskustelut-tietokantasi ei ole salattu - aseta tunnuslause sen salaamiseksi. No comment provided by engineer. - + Your chat profile will be sent to group members + Keskusteluprofiilisi lähetetään ryhmän jäsenille No comment provided by engineer. Your chat profile will be sent to your contact No comment provided by engineer. - + Your chat profiles + Keskusteluprofiilisi No comment provided by engineer. @@ -3384,142 +3975,177 @@ SimpleX Lock must be enabled. Your contact can scan it from the app. No comment provided by engineer. - + Your contact needs to be online for the connection to complete. You can cancel this connection and remove the contact (and try later with a new link). + Kontaktin tulee olla online-tilassa, jotta yhteys voidaan muodostaa. +Voit peruuttaa tämän yhteyden ja poistaa kontaktin (ja yrittää myöhemmin uudella linkillä). No comment provided by engineer. - + Your contact sent a file that is larger than currently supported maximum size (%@). + Yhteyshenkilösi lähetti tiedoston, joka on suurempi kuin tällä hetkellä tuettu enimmäiskoko (%@). No comment provided by engineer. - + Your contacts can allow full message deletion. + Kontaktisi voivat sallia viestien täydellisen poistamisen. No comment provided by engineer. - + Your current chat database will be DELETED and REPLACED with the imported one. + Nykyinen keskustelut-tietokantasi poistetaan ja korvataan tuodulla tietokannalla. No comment provided by engineer. - + Your current profile + Nykyinen profiilisi No comment provided by engineer. - + Your preferences + Asetuksesi No comment provided by engineer. - + Your privacy + Yksityisyytesi No comment provided by engineer. - + Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. + Profiilisi tallennetaan laitteeseesi ja jaetaan vain yhteystietojesi kanssa. +SimpleX-palvelimet eivät näe profiiliasi. No comment provided by engineer. Your profile will be sent to the contact that you received this link from No comment provided by engineer. - + Your profile, contacts and delivered messages are stored on your device. + Profiilisi, kontaktisi ja toimitetut viestit tallennetaan laitteellesi. No comment provided by engineer. - + Your random profile + Satunnainen profiilisi No comment provided by engineer. - + Your server + Palvelimesi No comment provided by engineer. - + Your server address + Palvelimesi osoite No comment provided by engineer. - + Your settings + Asetuksesi No comment provided by engineer. - + [Contribute](https://github.com/simplex-chat/simplex-chat#contribute) + [Osallistu](https://github.com/simplex-chat/simplex-chat#contribute) No comment provided by engineer. - + [Send us email](mailto:chat@simplex.chat) + [Lähetä meille sähköpostia](mailto:chat@simplex.chat) No comment provided by engineer. - + [Star on GitHub](https://github.com/simplex-chat/simplex-chat) + [Tähti GitHubissa](https://github.com/simplex-chat/simplex-chat) No comment provided by engineer. - + \_italic_ + \_italic_ No comment provided by engineer. - + \`a + b` + \`a + b` No comment provided by engineer. - + above, then choose: + edellä, valitse sitten: No comment provided by engineer. - + accepted call + hyväksytty puhelu call status - + admin + ylläpitäjä member role - + always + aina pref value - + audio call (not e2e encrypted) + äänipuhelu (ei e2e-salattu) No comment provided by engineer. - + bad message ID + virheellinen viestin tunniste integrity error chat item - + bad message hash + virheellinen viestin tarkiste integrity error chat item - + bold + lihavoitu No comment provided by engineer. - + call error + soittovirhe call status - + call in progress + puhelu käynnissä call status - + calling… + soittaa… call status - + cancelled %@ + peruutettu %@ feature offered item - + changed address for you + muuttunut osoite sinulle chat item text - + changed role of %1$@ to %2$@ + %1$@:n roolin muuttui %2$@:ksi rcv group event chat item - + changed your role to %@ + roolisi muuttui %@:ksi rcv group event chat item @@ -3530,409 +4156,510 @@ SimpleX servers cannot see your profile. changing address... chat item text - + colored + värillinen No comment provided by engineer. - + complete + valmis No comment provided by engineer. - + connect to SimpleX Chat developers. + ole yhteydessä SimpleX Chat -kehittäjiin. No comment provided by engineer. - + connected + yhdistetty No comment provided by engineer. - + connecting + yhdistää No comment provided by engineer. - + connecting (accepted) + yhdistäminen (hyväksytty) No comment provided by engineer. - + connecting (announced) + yhdistäminen (ilmoitettu) No comment provided by engineer. - + connecting (introduced) + yhdistäminen (esitelty) No comment provided by engineer. - + connecting (introduction invitation) + yhdistäminen (esittelykutsu) No comment provided by engineer. - + connecting call… + yhdistää puhelun… call status - + connecting… + yhdistää… chat list item title - + connection established + yhteys luotu chat list item title (it should not be shown - + connection:%@ + yhteys:%@ connection information - + contact has e2e encryption + kontaktilla on e2e-salaus No comment provided by engineer. - + contact has no e2e encryption + kontaktilla ei ole e2e-salausta No comment provided by engineer. - + creator + luoja No comment provided by engineer. - + default (%@) + oletusarvo (%@) pref value - + deleted + poistettu deleted chat item - + deleted group + poistettu ryhmä rcv group event chat item - + direct + suora connection level description - + duplicate message + päällekkäinen viesti integrity error chat item - + e2e encrypted + e2e-salattu No comment provided by engineer. - + enabled + käytössä enabled status - + enabled for contact + käytössä kontaktille enabled status - + enabled for you + käytössä sinulle enabled status - + ended + päättyi No comment provided by engineer. - + ended call %@ + puhelu päättyi %@:lle call status - + error + virhe No comment provided by engineer. - + group deleted + ryhmä poistettu No comment provided by engineer. - + group profile updated + ryhmäprofiili päivitetty snd group event chat item - + iOS Keychain is used to securely store passphrase - it allows receiving push notifications. + iOS-Avainnippua käytetään tunnuslauseen turvalliseen tallentamiseen - se mahdollistaa push-ilmoitusten vastaanottamisen. No comment provided by engineer. - + iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications. + iOS-Avainnippua käytetään tunnuslauseen turvalliseen tallentamiseen sen muuttamisen tai sovelluksen uudelleen käynnistämisen jälkeen - se mahdollistaa push-ilmoitusten vastaanottamisen. No comment provided by engineer. - + incognito via contact address link + incognito kontaktilinkin kautta chat list item description - + incognito via group link + incognito ryhmälinkin kautta chat list item description - + incognito via one-time link + incognito kertalinkillä chat list item description - + indirect (%d) + epäsuora (%d) connection level description - + invalid chat + virheellinen keskustelu invalid chat data - + invalid chat data + virheelliset keskustelu-tiedot No comment provided by engineer. - + invalid data + virheelliset tiedot invalid chat item - + invitation to group %@ + kutsu ryhmään %@ group name - + invited + kutsuttu No comment provided by engineer. - + invited %@ + kutsuttu %@ rcv group event chat item - + invited to connect + kutsuttu yhteydenpitoon chat list item title - + invited via your group link + kutsuttu ryhmäsi linkin kautta rcv group event chat item - + italic + kursivoitu No comment provided by engineer. - + join as %@ + Liity %@:nä No comment provided by engineer. - + left + poistunut rcv group event chat item - + marked deleted + merkitty poistetuksi marked deleted chat item preview text - + member + jäsen member role - + connected + yhdistetty rcv group event chat item - + message received + viesti vastaanotettu notification - + missed call + vastaamaton puhelu call status - + moderated + moderoitu moderated chat item - + moderated by %@ + %@ moderoi No comment provided by engineer. - + never + ei koskaan No comment provided by engineer. - + new message + uusi viesti notification - + no + ei pref value - + no e2e encryption + ei e2e-salausta No comment provided by engineer. - + observer + tarkkailija member role - + off + pois enabled status group pref value - + offered %@ + tarjottu %@ feature offered item - + offered %1$@: %2$@ + tarjottu %1$@: %2$@ feature offered item - + on + päällä group pref value - + or chat with the developers + tai keskustele kehittäjien kanssa No comment provided by engineer. - + owner + omistaja member role - + peer-to-peer + vertais No comment provided by engineer. - + received answer… + vastaus saatu… No comment provided by engineer. - + received confirmation… + vahvistus saatu… No comment provided by engineer. - + rejected call + hylätty puhelu call status - + removed + poistettu No comment provided by engineer. - + removed %@ + %@ poistettu rcv group event chat item - + removed you + poisti sinut rcv group event chat item - + sec + sek network option - + secret + salainen No comment provided by engineer. - + starting… + alkaa… No comment provided by engineer. - + strike + soita No comment provided by engineer. - + this contact + tämä kontakti notification title - + unknown + tuntematon connection info - + updated group profile + päivitetty ryhmäprofiili rcv group event chat item - + v%@ (%@) + v%@ (%@) No comment provided by engineer. - + via contact address link + kontaktiosoitelinkillä chat list item description - + via group link + ryhmälinkillä chat list item description - + via one-time link + kertalinkillä chat list item description - + via relay + releellä No comment provided by engineer. - + video call (not e2e encrypted) + videopuhelu (ei e2e-salattu) No comment provided by engineer. - + waiting for answer… + odottaa vastaamista… No comment provided by engineer. - + waiting for confirmation… + odottaa vahvistusta… No comment provided by engineer. - + wants to connect to you! + haluaa olla yhteydessä sinuun! No comment provided by engineer. - + yes + kyllä pref value - + you are invited to group + sinut on kutsuttu ryhmään No comment provided by engineer. - + you are observer + olet tarkkailija No comment provided by engineer. - + you changed address + muutit osoitetta chat item text - + you changed address for %@ + muutit osoitetta %@:ksi chat item text - + you changed role for yourself to %@ + vaihdoit roolin itsellesi %@:ksi snd group event chat item - + you changed role of %1$@ to %2$@ + olet vaihtanut %1$@:n roolin %2$@:ksi snd group event chat item - + you left + lähdit snd group event chat item - + you removed %@ + poistit %@ snd group event chat item - + you shared one-time link + jaoit kertalinkin chat list item description - + you shared one-time link incognito + jaoit kertalinkin incognito-tilassa chat list item description - + you: + sinä: No comment provided by engineer. - + \~strike~ + \~strike~ No comment provided by engineer. @@ -4117,7 +4844,7 @@ SimpleX servers cannot see your profile. 0s - 0s + 0s No comment provided by engineer. @@ -4246,6 +4973,1371 @@ SimpleX servers cannot see your profile. Kontaktit No comment provided by engineer. + + # %@ + # %@ + copied message info title, # <title> + + + ## History + ## Historia + copied message info + + + ## In reply to + ## vastauksena + copied message info + + + %@ and %@ connected + %@ ja %@ yhdistetty + No comment provided by engineer. + + + You can hide or mute a user profile - swipe it to the right. + Voit piilottaa tai mykistää käyttäjäprofiilin pyyhkäisemällä sitä oikealle. + No comment provided by engineer. + + + Database upgrade + Tietokannan päivitys + No comment provided by engineer. + + + Deleted at + Poistettu klo + No comment provided by engineer. + + + Deleted at: %@ + Poistettu klo: %@ + copied message info + + + Duration + Kesto + No comment provided by engineer. + + + Files and media are prohibited in this group. + Tiedostot ja media ovat tässä ryhmässä kiellettyjä. + No comment provided by engineer. + + + Incompatible database version + Yhteensopimaton tietokantaversio + No comment provided by engineer. + + + Moderated at: %@ + Moderoitu klo: %@ + copied message info + + + New display name + Uusi näyttönimi + No comment provided by engineer. + + + Only your contact can add message reactions. + Vain kontaktisi voi lisätä viestireaktioita. + No comment provided by engineer. + + + Only your contact can make calls. + Vain kontaktisi voi soittaa puheluita. + No comment provided by engineer. + + + Polish interface + Puolalainen käyttöliittymä + No comment provided by engineer. + + + Select + Valitse + No comment provided by engineer. + + + Sent at: %@ + Lähetetty klo: %@ + copied message info + + + Set passcode + Aseta pääsykoodi + No comment provided by engineer. + + + Share address + Jaa osoite + No comment provided by engineer. + + + Share with contacts + Jaa kontaktien kanssa + No comment provided by engineer. + + + no text + ei tekstiä + copied message info in history + + + seconds + sekuntia + time unit + + + weeks + viikkoa + time unit + + + Database IDs and Transport isolation option. + Tietokantatunnukset ja kuljetuseristysvaihtoehto. + No comment provided by engineer. + + + Database downgrade + Tietokannan alentaminen + No comment provided by engineer. + + + Downgrade and open chat + Alenna ja avaa keskustelu + No comment provided by engineer. + + + Enter Passcode + Syötä pääsykoodi + No comment provided by engineer. + + + File will be received when your contact completes uploading it. + Tiedosto vastaanotetaan, kun kontaktisi on ladannut sen. + No comment provided by engineer. + + + Image will be received when your contact completes uploading it. + Kuva vastaanotetaan, kun kontaktisi on ladannut sen. + No comment provided by engineer. + + + Immediately + Heti + No comment provided by engineer. + + + Incorrect passcode + Väärä pääsykoodi + PIN entry + + + KeyChain error + Avainnipun virhe + No comment provided by engineer. + + + Messages & files + Viestit ja tiedostot + No comment provided by engineer. + + + Migrations: %@ + Siirrot: %@ + No comment provided by engineer. + + + No app password + Ei sovelluksen salasanaa + Authentication unavailable + + + Passcode entry + Pääsykoodin syöttö + No comment provided by engineer. + + + Passcode not changed! + Pääsykoodia ei ole muutettu! + No comment provided by engineer. + + + Passcode set! + Pääsykoodi asetettu! + No comment provided by engineer. + + + Show developer options + Näytä kehittäjävaihtoehdot + No comment provided by engineer. + + + SimpleX Lock mode + SimpleX Lock -tila + No comment provided by engineer. + + + Upgrade and open chat + Päivitä ja avaa keskustelu + No comment provided by engineer. + + + Video will be received when your contact is online, please wait or check later! + Video vastaanotetaan, kun kontaktisi on online-tilassa, odota tai tarkista myöhemmin! + No comment provided by engineer. + + + Warning: you may lose some data! + Varoitus: saatat menettää joitain tietoja! + No comment provided by engineer. + + + XFTP servers + XFTP-palvelimet + No comment provided by engineer. + + + different migration in the app/database: %@ / %@ + eri siirtyminen sovelluksessa/tietokannassa: %@ / %@ + No comment provided by engineer. + + + A new random profile will be shared. + Uusi satunnainen profiili jaetaan. + No comment provided by engineer. + + + Accept connection request? + Hyväksy yhteyspyyntö? + No comment provided by engineer. + + + Connect directly + Yhdistä suoraan + No comment provided by engineer. + + + Connect incognito + Yhdistä Incognito + No comment provided by engineer. + + + Custom time + Mukautettu aika + No comment provided by engineer. + + + Don't create address + Älä luo osoitetta + No comment provided by engineer. + + + Encrypted message: database migration error + Salattu viesti: tietokannan siirtovirhe + notification + + + Fix connection + Korjaa yhteys + No comment provided by engineer. + + + Fix connection? + Korjaa yhteys? + No comment provided by engineer. + + + Fix not supported by contact + Kontakti ei tue korjausta + No comment provided by engineer. + + + Fix not supported by group member + Ryhmän jäsen ei tue korjausta + No comment provided by engineer. + + + Only you can add message reactions. + Vain sinä voit lisätä viestireaktioita. + No comment provided by engineer. + + + Only you can make calls. + Vain sinä voit soittaa puheluita. + No comment provided by engineer. + + + Paste the link you received to connect with your contact. + Liitä saamasi linkki, jonka avulla voit muodostaa yhteyden kontaktiisi. + placeholder + + + Please remember or store it securely - there is no way to recover a lost passcode! + Muista tai säilytä se turvallisesti - kadonnutta pääsykoodia ei voi palauttaa! + No comment provided by engineer. + + + Profile update will be sent to your contacts. + Profiilipäivitys lähetetään kontakteillesi. + No comment provided by engineer. + + + Prohibit sending files and media. + Estä tiedostojen ja median lähettäminen. + No comment provided by engineer. + + + Receipts are disabled + Kuittaukset pois käytöstä + No comment provided by engineer. + + + Record updated at: %@ + Tietue päivitetty klo: %@ + copied message info + + + Reject (sender NOT notified) + Hylkää (lähettäjälle EI ilmoiteta) + No comment provided by engineer. + + + Renegotiate encryption + Uudelleenneuvottele salaus + No comment provided by engineer. + + + Save settings? + Tallenna asetukset? + No comment provided by engineer. + + + Self-destruct + Itsetuho + No comment provided by engineer. + + + Send disappearing message + Lähetä katoava viesti + No comment provided by engineer. + + + Send receipts + Lähetä kuittaukset + No comment provided by engineer. + + + Sending receipts is disabled for %lld groups + Kuittien lähettäminen ei ole käytössä %lld ryhmille + No comment provided by engineer. + + + Show: + Näytä: + No comment provided by engineer. + + + SimpleX address + SimpleX-osoite + No comment provided by engineer. + + + Some non-fatal errors occurred during import - you may see Chat console for more details. + Tuonnin aikana tapahtui joitakin ei-vakavia virheitä – saatat nähdä Chat-konsolissa lisätietoja. + No comment provided by engineer. + + + They can be overridden in contact and group settings. + Ne voidaan ohittaa kontakti- ja ryhmäasetuksissa. + No comment provided by engineer. + + + This group has over %lld members, delivery receipts are not sent. + Tässä ryhmässä on yli %lld jäsentä, lähetyskuittauksia ei lähetetä. + No comment provided by engineer. + + + Use new incognito profile + Käytä uutta incognito-profiilia + No comment provided by engineer. + + + Waiting for video + Odottaa videota + No comment provided by engineer. + + + You invited a contact + Kutsuit kontaktin + No comment provided by engineer. + + + agreeing encryption… + hyväksyy salausta… + chat item text + + + disabled + ei käytössä + No comment provided by engineer. + + + encryption ok for %@ + salaus ok %@:lle + chat item text + + + encryption re-negotiation allowed + salauksen uudelleenneuvottelu sallittu + chat item text + + + minutes + minuuttia + time unit + + + Initial role + Alkuperäinen rooli + No comment provided by engineer. + + + Don't enable + Älä salli + No comment provided by engineer. + + + Enable lock + Ota lukitus käyttöön + No comment provided by engineer. + + + Enable self-destruct + Ota itsetuho käyttöön + No comment provided by engineer. + + + Error enabling delivery receipts! + Virhe toimituskuittauksien sallimisessa! + No comment provided by engineer. + + + Error setting delivery receipts! + Virhe toimituskuittauksien asettamisessa! + No comment provided by engineer. + + + Sent message + Lähetetty viesti + message info title + + + Server requires authorization to upload, check password + Palvelin vaatii valtuutuksen tiedoston lataamiseksi, tarkista salasana + server test error + + + Set it instead of system authentication. + Aseta se järjestelmän todennuksen sijaan. + No comment provided by engineer. + + + Share address with contacts? + Jaa osoite kontakteille? + No comment provided by engineer. + + + Share 1-time link + Jaa kertakäyttölinkki + No comment provided by engineer. + + + Show last messages + Näytä viimeiset viestit + No comment provided by engineer. + + + Stop receiving file? + Lopeta tiedoston vastaanottaminen? + No comment provided by engineer. + + + SimpleX Lock not enabled! + SimpleX Lock ei ole käytössä! + No comment provided by engineer. + + + Small groups (max 20) + Pienryhmät (max 20) + No comment provided by engineer. + + + Stop sending file? + Lopeta tiedoston lähettäminen? + No comment provided by engineer. + + + Submit + Lähetä + No comment provided by engineer. + + + System authentication + Järjestelmän todennus + No comment provided by engineer. + + + These settings are for your current profile **%@**. + Nämä asetukset koskevat nykyistä profiiliasi **%@**. + No comment provided by engineer. + + + Passcode + Pääsykoodi + No comment provided by engineer. + + + Please report it to the developers. + Ilmoita siitä kehittäjille. + No comment provided by engineer. + + + Profile password + Profiilin salasana + No comment provided by engineer. + + + Prohibit audio/video calls. + Estä ääni- ja videopuhelut. + No comment provided by engineer. + + + Prohibit message reactions. + Estä viestireaktiot. + No comment provided by engineer. + + + Prohibit messages reactions. + Estä viestireaktiot. + No comment provided by engineer. + + + React… + Reagoi… + chat item menu + + + Read more + Lue lisää + No comment provided by engineer. + + + Read more in [User Guide](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address). + Lue lisää [Käyttöoppaasta](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address). + No comment provided by engineer. + + + Received message + Vastaanotettu viesti + message info title + + + Invite friends + Kutsu ystäviä + No comment provided by engineer. + + + Invalid status + Virheellinen tila + item status text + + + Files and media + Tiedostot ja media + chat feature + + + Files and media prohibited! + Tiedostot ja media kielletty! + No comment provided by engineer. + + + Finally, we have them! 🚀 + Vihdoinkin meillä! 🚀 + No comment provided by engineer. + + + Filter unread and favorite chats. + Suodata lukemattomia- ja suosikkikeskusteluja. + No comment provided by engineer. + + + Fix + Korjaa + No comment provided by engineer. + + + Find chats faster + Löydä keskustelut nopeammin + No comment provided by engineer. + + + Group members can add message reactions. + Ryhmän jäsenet voivat lisätä viestireaktioita. + No comment provided by engineer. + + + If you enter your self-destruct passcode while opening the app: + Jos syötät itsetuhoutuvan pääsykoodin sovellusta avattaessa: + No comment provided by engineer. + + + Japanese interface + Japanilainen käyttöliittymä + No comment provided by engineer. + + + Make one message disappear + Hävitä yksi viesti + No comment provided by engineer. + + + Message reactions are prohibited in this group. + Viestireaktiot ovat kiellettyjä tässä ryhmässä. + No comment provided by engineer. + + + Sending delivery receipts will be enabled for all contacts in all visible chat profiles. + Toimituskuittauksien lähettäminen otetaan käyttöön kaikille kontakteille näkyvissä keskusteluprofiileissa. + No comment provided by engineer. + + + Sending delivery receipts will be enabled for all contacts. + Toimituskuittauksien lähettäminen otetaan käyttöön kaikille kontakteille. + No comment provided by engineer. + + + Sending receipts is disabled for %lld contacts + Kuittauksien lähettäminen ei ole käytössä %lld kontakteille + No comment provided by engineer. + + + Sent at + Lähetetty klo + No comment provided by engineer. + + + Unhide chat profile + Näytä keskusteluprofiili + No comment provided by engineer. + + + Upload file + Lataa tiedosto + server test step + + + Use current profile + Käytä nykyistä profiilia + No comment provided by engineer. + + + You can share your address as a link or QR code - anybody can connect to you. + Voit jakaa osoitteesi linkkinä tai QR-koodina - kuka tahansa voi muodostaa yhteyden sinuun. + No comment provided by engineer. + + + You can turn on SimpleX Lock via Settings. + Voit ottaa SimpleX Lockin käyttöön Asetusten kautta. + No comment provided by engineer. + + + Your contacts will remain connected. + Kontaktisi pysyvät yhdistettyinä. + No comment provided by engineer. + + + Decryption error + Salauksen purkuvirhe + message decrypt error item + + + Delete chat profile + Poista keskusteluprofiili + No comment provided by engineer. + + + Let's talk in SimpleX Chat + Jutellaan SimpleX Chatissa + email subject + + + Your SimpleX address + SimpleX-osoitteesi + No comment provided by engineer. + + + Unit + Yksikkö + No comment provided by engineer. + + + Enter welcome message… (optional) + Kirjoita tervetuloviesti... (valinnainen) + placeholder + + + The hash of the previous message is different. + Edellisen viestin tarkiste on erilainen. + No comment provided by engineer. + + + Unlock app + Avaa sovellus + authentication reason + + + You can create it later + Voit luoda sen myöhemmin + No comment provided by engineer. + + + Delete file + Poista tiedosto + server test step + + + Delivery receipts are disabled! + Toimituskuittaukset poissa käytöstä! + No comment provided by engineer. + + + Disable (keep overrides) + Poista käytöstä (pidä ohitukset) + No comment provided by engineer. + + + Disable for all + Poista käytöstä kaikilta + No comment provided by engineer. + + + Disappearing message + Tuhoutuva viesti + No comment provided by engineer. + + + Disappears at: %@ + Katoaa klo: %@ + copied message info + + + Enable (keep overrides) + Salli (pidä ohitukset) + No comment provided by engineer. + + + Error synchronizing connection + Virhe yhteyden synkronoinnissa + No comment provided by engineer. + + + Even when disabled in the conversation. + Jopa kun ei käytössä keskustelussa. + No comment provided by engineer. + + + Favorite + Suosikki + No comment provided by engineer. + + + File will be deleted from servers. + Tiedosto poistetaan palvelimilta. + No comment provided by engineer. + + + Fix encryption after restoring backups. + Korjaa salaus varmuuskopioiden palauttamisen jälkeen. + No comment provided by engineer. + + + If you enter this passcode when opening the app, all app data will be irreversibly removed! + Jos syötät tämän pääsykoodin sovellusta avatessasi, kaikki sovelluksen tiedot poistetaan peruuttamattomasti! + No comment provided by engineer. + + + Info + Tiedot + chat item action + + + Migrating database archive… + Siirretään tietokannan arkistoa… + No comment provided by engineer. + + + No filtered chats + Ei suodatettuja keskusteluja + No comment provided by engineer. + + + Only group owners can enable files and media. + Vain ryhmän omistajat voivat sallia tiedostoja ja mediaa. + No comment provided by engineer. + + + Passcode changed! + Pääsykoodi vaihdettu! + No comment provided by engineer. + + + Permanent decryption error + Pysyvä salauksen purkuvirhe + message decrypt error item + + + Protocol timeout per KB + Protokollan aikakatkaisu per KB + No comment provided by engineer. + + + Receiving address will be changed to a different server. Address change will complete after sender comes online. + Vastaanotto-osoite vaihdetaan toiseen palvelimeen. Osoitteenmuutos tehdään sen jälkeen, kun lähettäjä tulee verkkoon. + No comment provided by engineer. + + + Reconnect servers? + Yhdistä palvelimet uudelleen? + No comment provided by engineer. + + + Record updated at + Tietue päivitetty klo + No comment provided by engineer. + + + Renegotiate + Neuvottele uudelleen + No comment provided by engineer. + + + Send delivery receipts to + Lähetä toimituskuittaukset vastaanottajalle + No comment provided by engineer. + + + Self-destruct passcode changed! + Itsetuhoutuva pääsykoodi vaihdettu! + No comment provided by engineer. + + + Sending file will be stopped. + Tiedoston lähettäminen lopetetaan. + No comment provided by engineer. + + + Stop file + Pysäytä tiedosto + cancel file action + + + Stop sharing + Lopeta jakaminen + No comment provided by engineer. + + + Stop sharing address? + Lopeta osoitteen jakaminen? + No comment provided by engineer. + + + The second tick we missed! ✅ + Toinen kuittaus, joka uupui! ✅ + No comment provided by engineer. + + + To connect, your contact can scan QR code or use the link in the app. + Kontaktisi voi muodostaa yhteyden skannaamalla QR-koodin tai käyttämällä sovelluksessa olevaa linkkiä. + No comment provided by engineer. + + + Unfav. + Epäsuotuisa. + No comment provided by engineer. + + + Unhide profile + Näytä profiili + No comment provided by engineer. + + + Videos and files up to 1gb + Videot ja tiedostot 1 Gt asti + No comment provided by engineer. + + + When people request to connect, you can accept or reject it. + Kun ihmiset pyytävät yhteyden muodostamista, voit hyväksyä tai hylätä sen. + No comment provided by engineer. + + + You can enable them later via app Privacy & Security settings. + Voit ottaa ne käyttöön myöhemmin sovelluksen Yksityisyys & Turvallisuus -asetuksista. + No comment provided by engineer. + + + You won't lose your contacts if you later delete your address. + Et menetä kontaktejasi, jos poistat osoitteesi myöhemmin. + No comment provided by engineer. + + + Your %@ servers + %@-palvelimesi + No comment provided by engineer. + + + Your XFTP servers + XFTP-palvelimesi + No comment provided by engineer. + + + changing address for %@… + osoitteen muuttaminen %@:lle… + chat item text + + + changing address… + muuttamassa osoitetta… + chat item text + + + default (no) + oletusarvo (ei) + No comment provided by engineer. + + + default (yes) + oletusarvo (kyllä) + No comment provided by engineer. + + + database version is newer than the app, but no down migration for: %@ + tietokantaversio on uudempi kuin sovellus, mutta ei alaspäin siirtymistä varten: %@ + No comment provided by engineer. + + + encryption agreed for %@ + salaus sovittu %@:lle + chat item text + + + encryption ok + salaus ok + chat item text + + + encryption agreed + salaus sovittu + chat item text + + + encryption re-negotiation required for %@ + tarvitaan salauksen uudelleenneuvottelu %@:lle + chat item text + + + hours + tuntia + time unit + + + months + kuukautta + time unit + + + Enable self-destruct passcode + Ota itsetuhoava pääsykoodi käyttöön + set passcode view + + + Hide: + Piilota: + No comment provided by engineer. + + + Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends). + Lue lisää [Käyttöoppaasta](https://simplex.chat/docs/guide/readme.html#connect-to-friends). + No comment provided by engineer. + + + Received at + Vastaanotettu klo + No comment provided by engineer. + + + Received at: %@ + Vastaanotettu klo: %@ + copied message info + + + Delete profile + Poista profiili + No comment provided by engineer. + + + Make sure %@ server addresses are in correct format, line separated and are not duplicated (%@). + Varmista, että %@-palvelinosoitteet ovat oikeassa muodossa, että ne on erotettu toisistaan riveittäin ja että ne eivät ole päällekkäisiä (%@). + No comment provided by engineer. + + + Receiving file will be stopped. + Tiedoston vastaanotto pysäytetään. + No comment provided by engineer. + + + Revoke file + Peruuta tiedosto + cancel file action + + + Revoke file? + Peruuta tiedosto? + No comment provided by engineer. + + + %1$@ at %2$@: + %1$@ klo %2$@: + copied message info, <sender> at <time> + + + Delivery receipts! + Toimituskuittaukset! + No comment provided by engineer. + + + It can happen when: +1. The messages expired in the sending client after 2 days or on the server after 30 days. +2. Message decryption failed, because you or your contact used old database backup. +3. The connection was compromised. + Se voi tapahtua, kun: +1. Viestit vanhenivat lähettävässä päätelaitteessa kahden päivän päästä tai palvelimella 30 päivän kuluttua. +2. Viestin salauksen purku epäonnistui, koska sinä tai kontaktisi käytitte vanhaa varmuuskopiota tietokannasta. +3. Yhteys vaarantui. + No comment provided by engineer. + + + Preview + Esikatselu + No comment provided by engineer. + + + SimpleX Address + SimpleX-osoite + No comment provided by engineer. + + + %@, %@ and %lld other members connected + %@, %@ ja %lld muut jäsenet yhdistetty + No comment provided by engineer. + + + Connect via contact link + Yhdistä kontaktilinkillä + No comment provided by engineer. + + + Connect via one-time link + Yhdistä kertalinkillä + No comment provided by engineer. + + + Database ID: %d + Tietokannan tunnus: %d + copied message info + + + Delivery + Toimitus + No comment provided by engineer. + + + Disappears at + Katoaa klo + No comment provided by engineer. + + + Download file + Lataa tiedosto + server test step + + + Enable for all + Salli kaikille + No comment provided by engineer. + + + Enter welcome message… + Kirjoita tervetuloviesti… + placeholder + + + Error aborting address change + Virhe osoitteenmuutoksen keskeytyksessä + No comment provided by engineer. + + + Error loading %@ servers + Virhe %@-palvelimien lataamisessa + No comment provided by engineer. + + + Error saving %@ servers + Virhe %@ palvelimien tallentamisessa + No comment provided by engineer. + + + Error saving passcode + Virhe pääsykoodin tallentamisessa + No comment provided by engineer. + + + Error sending email + Virhe sähköpostin lähettämisessä + No comment provided by engineer. + + + Error: + Virhe: + No comment provided by engineer. + + + Exporting database archive… + Tietokanta-arkiston vienti… + No comment provided by engineer. + + + Fast and no wait until the sender is online! + Nopea ja ei odotusta, kunnes lähettäjä on online-tilassa! + No comment provided by engineer. + + + Group members can send files and media. + Ryhmän jäsenet voivat lähettää tiedostoja ja mediaa. + No comment provided by engineer. + + + History + Historia + No comment provided by engineer. + + + If you can't meet in person, show QR code in a video call, or share the link. + Jos et voi tavata henkilökohtaisesti, näytä QR-koodi videopuhelussa tai jaa linkki. + No comment provided by engineer. + + + In reply to + Vastauksena + No comment provided by engineer. + + + Incognito mode protects your privacy by using a new random profile for each contact. + Incognito-tila suojaa yksityisyyttäsi käyttämällä uutta satunnaista profiilia jokaiselle kontaktille. + No comment provided by engineer. + + + It can happen when you or your connection used the old database backup. + Se voi tapahtua, kun sinä tai kontaktisi käytitte vanhaa varmuuskopiota tietokannasta. + No comment provided by engineer. + + + Keep your connections + Pidä kontaktisi + No comment provided by engineer. + + + Learn more + Lue lisää + No comment provided by engineer. + + + Lock after + Lukitse jälkeen + No comment provided by engineer. + + + Lock mode + Lukitustila + No comment provided by engineer. + + + Message delivery receipts! + Viestien toimituskuittaukset! + No comment provided by engineer. + + + Message reactions + Viestireaktiot + chat feature + + + Message reactions are prohibited in this chat. + Viestireaktiot ovat kiellettyjä tässä keskustelussa. + No comment provided by engineer. + + + Moderated at + Moderoitu klo + No comment provided by engineer. + + + Most likely this connection is deleted. + Todennäköisesti tämä yhteys on poistettu. + item status description + + + New Passcode + Uusi pääsykoodi + No comment provided by engineer. + + + No delivery information + Ei toimitustietoja + No comment provided by engineer. + + + No history + Ei historiaa + No comment provided by engineer. + + + Off + Pois + No comment provided by engineer. + + + Opening database… + Avataan tietokantaa… + No comment provided by engineer. + + + Reconnect all connected servers to force message delivery. It uses additional traffic. + Yhdistä kaikki yhdistetyt palvelimet uudelleen pakottaaksesi viestin toimituksen. Tämä käyttää ylimääräistä liikennettä. + No comment provided by engineer. + + + Renegotiate encryption? + Uudelleenneuvottele salaus? + No comment provided by engineer. + + + Sending receipts is enabled for %lld contacts + Kuittauksien lähettäminen on käytössä %lld kontakteille + No comment provided by engineer. + + + Sending receipts is enabled for %lld groups + Kuittauksien lähettäminen on käytössä %lld ryhmille + No comment provided by engineer. + + + Revoke + Peruuta + No comment provided by engineer. + + + Save auto-accept settings + Tallenna automaattisen hyväksynnän asetukset + No comment provided by engineer. + + + Self-destruct passcode + Itsetuhoutuva pääsykoodi + No comment provided by engineer. + + + Self-destruct passcode enabled! + Itsetuhoutuva pääsykoodi käytössä! + No comment provided by engineer. + + + The ID of the next message is incorrect (less or equal to the previous). +It can happen because of some bug or when the connection is compromised. + Seuraavan viestin tunnus on väärä (pienempi tai yhtä suuri kuin edellisen). +Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut. + No comment provided by engineer. + + + The encryption is working and the new encryption agreement is not required. It may result in connection errors! + Salaus toimii ja uutta salaussopimusta ei tarvita. Tämä voi johtaa yhteysvirheisiin! + No comment provided by engineer. + + + Video will be received when your contact completes uploading it. + Video vastaanotetaan, kun kontaktisi on ladannut sen. + No comment provided by engineer. + + + You can enable later via Settings + Voit ottaa käyttöön myöhemmin asetusten kautta + No comment provided by engineer. + + + You can share this address with your contacts to let them connect with **%@**. + Voit jakaa tämän osoitteen kontaktiesi kanssa, jotta ne voivat muodostaa yhteyden **%@** kanssa. + No comment provided by engineer. + + + Your contacts in SimpleX will see it. +You can change it in Settings. + Kontaktisi SimpleX:ssä näkevät sen. +Voit muuttaa sitä Asetuksista. + No comment provided by engineer. + + + Your profile **%@** will be shared. + Profiilisi **%@** jaetaan. + No comment provided by engineer. + + + agreeing encryption for %@… + salauksesta sovitaan %@:lle… + chat item text + + + custom + mukautettu + dropdown time picker choice + + + days + päivää + time unit + + + encryption re-negotiation allowed for %@ + salauksen uudelleenneuvottelu sallittu %@:lle + chat item text + + + encryption re-negotiation required + tarvitaan salauksen uudelleenneuvottelu + chat item text + + + event happened + tapahtuma tapahtui + No comment provided by engineer. + + + security code changed + turvakoodi on muuttunut + chat item text + @@ -4253,24 +6345,29 @@ SimpleX servers cannot see your profile. - + SimpleX + SimpleX Bundle name - + SimpleX needs camera access to scan QR codes to connect to other users and for video calls. + SimpleX tarvitsee pääsyn kameraan, jotta se voi skannata QR-koodeja muodostaakseen yhteyden muihin käyttäjiin ja videopuheluita varten. Privacy - Camera Usage Description - + SimpleX uses Face ID for local authentication + SimpleX käyttää Face ID:tä paikalliseen todennukseen Privacy - Face ID Usage Description - + SimpleX needs microphone access for audio and video calls, and to record voice messages. + SimpleX tarvitsee mikrofonia ääni- ja videopuheluita ja ääniviestien tallentamista varten. Privacy - Microphone Usage Description - + SimpleX needs access to Photo Library for saving captured and received media + SimpleX tarvitsee pääsyn valokuvakirjastoon kuvattujen ja vastaanotettujen medioiden tallentamista varten Privacy - Photo Library Additions Usage Description @@ -4280,16 +6377,19 @@ SimpleX servers cannot see your profile. - + SimpleX NSE + SimpleX NSE Bundle display name - + SimpleX NSE + SimpleX NSE Bundle name - + Copyright © 2022 SimpleX Chat. All rights reserved. + Copyright © 2022 SimpleX Chat. Kaikki oikeudet pidätetään. Copyright (human-readable) diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000000..aaa7f79bc8 --- /dev/null +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,23 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "red" : "0.000", + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.533" + } + }, + "idiom" : "universal" + } + ], + "properties" : { + "localizable" : true + }, + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/Shared/Assets.xcassets/Contents.json b/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/Shared/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/Shared/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/SimpleX NSE/en.lproj/InfoPlist.strings b/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/SimpleX NSE/en.lproj/InfoPlist.strings new file mode 100644 index 0000000000..124ddbcc33 --- /dev/null +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/SimpleX NSE/en.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ +/* Bundle display name */ +"CFBundleDisplayName" = "SimpleX NSE"; +/* Bundle name */ +"CFBundleName" = "SimpleX NSE"; +/* Copyright (human-readable) */ +"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved."; diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/en.lproj/Localizable.strings new file mode 100644 index 0000000000..cf485752ea --- /dev/null +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/en.lproj/Localizable.strings @@ -0,0 +1,30 @@ +/* No comment provided by engineer. */ +"_italic_" = "\\_italic_"; + +/* No comment provided by engineer. */ +"**Add new contact**: to create your one-time QR Code for your contact." = "**Add new contact**: to create your one-time QR Code or link for your contact."; + +/* No comment provided by engineer. */ +"*bold*" = "\\*bold*"; + +/* No comment provided by engineer. */ +"`a + b`" = "\\`a + b`"; + +/* No comment provided by engineer. */ +"~strike~" = "\\~strike~"; + +/* call status */ +"connecting call" = "connecting call…"; + +/* No comment provided by engineer. */ +"Connecting server…" = "Connecting to server…"; + +/* No comment provided by engineer. */ +"Connecting server… (error: %@)" = "Connecting to server… (error: %@)"; + +/* rcv group event chat item */ +"member connected" = "connected"; + +/* No comment provided by engineer. */ +"No group!" = "Group not found!"; + diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings new file mode 100644 index 0000000000..3af673b19f --- /dev/null +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -0,0 +1,10 @@ +/* Bundle name */ +"CFBundleName" = "SimpleX"; +/* Privacy - Camera Usage Description */ +"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; +/* Privacy - Face ID Usage Description */ +"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Microphone Usage Description */ +"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; +/* Privacy - Photo Library Additions Usage Description */ +"NSPhotoLibraryAddUsageDescription" = "SimpleX needs access to Photo Library for saving captured and received media"; diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/contents.json b/apps/ios/SimpleX Localizations/fi.xcloc/contents.json new file mode 100644 index 0000000000..c46e0f6a71 --- /dev/null +++ b/apps/ios/SimpleX Localizations/fi.xcloc/contents.json @@ -0,0 +1,12 @@ +{ + "developmentRegion" : "en", + "project" : "SimpleX.xcodeproj", + "targetLocale" : "fi", + "toolInfo" : { + "toolBuildNumber" : "15A5219j", + "toolID" : "com.apple.dt.xcode", + "toolName" : "Xcode", + "toolVersion" : "15.0" + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index fcb2cdecc5..95de1b8b27 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -1819,6 +1819,10 @@ Chiffrer la base de données ? No comment provided by engineer. + + Encrypt local files + No comment provided by engineer. + Encrypted database Base de données chiffrée @@ -1949,6 +1953,10 @@ Erreur lors de la création du profil ! No comment provided by engineer. + + Error decrypting file + No comment provided by engineer. + Error deleting chat database Erreur lors de la suppression de la base de données du chat @@ -5725,7 +5733,7 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. encryption ok - chiffrement ok + chiffrement OK chat item text diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/fr.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings deleted file mode 100644 index 8b13789179..0000000000 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/ios/SimpleX Localizations/he.xcloc/Localized Contents/he.xliff b/apps/ios/SimpleX Localizations/he.xcloc/Localized Contents/he.xliff index 428cb8dfbb..ac71ed26bc 100644 --- a/apps/ios/SimpleX Localizations/he.xcloc/Localized Contents/he.xliff +++ b/apps/ios/SimpleX Localizations/he.xcloc/Localized Contents/he.xliff @@ -284,7 +284,7 @@ Available in v5.1 . - . + . No comment provided by engineer. @@ -1971,8 +1971,9 @@ Available in v5.1 חברי הקבוצה יכולים לשלוח הודעות קוליות. No comment provided by engineer. - + Group message: + הודעה קבוצתית: notification @@ -2377,262 +2378,327 @@ Available in v5.1 נתוני פרופיל מקומיים בלבד No comment provided by engineer. - + Lock after + נעל אחרי No comment provided by engineer. - + Lock mode + מצב נעילה No comment provided by engineer. - + Make a private connection + צור חיבור פרטי No comment provided by engineer. - + Make profile private! + הפוך את הפרופיל לפרטי! No comment provided by engineer. - + Make sure %@ server addresses are in correct format, line separated and are not duplicated (%@). + ודא שכתובות השרת %@ הן בפורמט הנכון, מופרדות בשורה ואינן משוכפלות (%@). No comment provided by engineer. - + Make sure WebRTC ICE server addresses are in correct format, line separated and are not duplicated. + ודאו שכתובות שרתי ה־WebRTC ICE הן בפורמט הנכון, מופרדות בשורה ולא משוכפלות. No comment provided by engineer. - + Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?* + אנשים רבים שאלו: *אם ל-SimpleX אין מזהי משתמש, איך הוא יכול לשלוח הודעות?* No comment provided by engineer. - + Mark deleted for everyone + לסמן נמחק לכולם No comment provided by engineer. - + Mark read + סמן כנקרא No comment provided by engineer. - + Mark verified + סמן מאומת No comment provided by engineer. - + Markdown in messages + מרקדאון בהודעות No comment provided by engineer. - + Max 30 seconds, received instantly. + מקסימום 30 שניות, התקבל באופן מיידי. No comment provided by engineer. - + Member + חבר קבוצה No comment provided by engineer. - + Member role will be changed to "%@". All group members will be notified. + תפקיד חבר הקבוצה ישתנה ל-"%@". כל חברי הקבוצה יקבלו הודעה. No comment provided by engineer. - + Member role will be changed to "%@". The member will receive a new invitation. + תפקיד חבר הקבוצה ישתנה ל-"%@". חבר הקבוצה יקבל הזמנה חדשה. No comment provided by engineer. - + Member will be removed from group - this cannot be undone! + חבר הקבוצה יוסר מהקבוצה – לא ניתן לבטל זאת! No comment provided by engineer. - + Message delivery error + שגיאת מסירת הודעה No comment provided by engineer. - + Message draft + טיוטת הודעה No comment provided by engineer. - + Message text + טקסט הודעה No comment provided by engineer. - + Messages + הודעות No comment provided by engineer. - + Messages & files + הודעות וקבצים No comment provided by engineer. Migrating database archive... No comment provided by engineer. - + Migration error: + שגיאת העברה: No comment provided by engineer. - + Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat). + ההעברה נכשלה. הקש על **דלג** למטה כדי להמשיך להשתמש במסד הנתונים הנוכחי. אנא דווח על הבעיה למפתחי האפליקציה באמצעות צ'אט או דוא"ל [chat@simplex.chat](mailto:chat@simplex.chat). No comment provided by engineer. - + Migration is completed + ההעברה הושלמה No comment provided by engineer. - + Migrations: %@ + העברות: %@ No comment provided by engineer. - + Moderate + חסימת הודעה chat item action - + More improvements are coming soon! + שיפורים נוספים יגיעו בקרוב! No comment provided by engineer. - + Most likely this contact has deleted the connection with you. + ככל הנראה איש קשר זה מחק את החיבור איתך. No comment provided by engineer. - + Multiple chat profiles + פרופילי צ׳אט מרובים No comment provided by engineer. - + Mute + השתק No comment provided by engineer. - + Muted when inactive! + מושתק כאשר אין פעילות! No comment provided by engineer. - + Name + שם No comment provided by engineer. - + Network & servers + רשת ושרתים No comment provided by engineer. - + Network settings + הגדרות רשת No comment provided by engineer. - + Network status + מצב רשת No comment provided by engineer. - + New Passcode + קוד גישה חדש No comment provided by engineer. - + New contact request + בקשה חדשה ליצירת קשר notification - + New contact: + איש קשר חדש: notification - + New database archive + ארכיון מסד נתונים חדש No comment provided by engineer. - + New in %@ + חדש ב %@ No comment provided by engineer. - + New member role + תפקיד חבר קבוצה חדש No comment provided by engineer. - + New message + הודעה חדשה notification - + New passphrase… + סיסמה חדשה… No comment provided by engineer. - + No + לא No comment provided by engineer. - + No app password + אין סיסמה לאפליקציה Authentication unavailable - + No contacts selected + לא נבחרו אנשי קשר No comment provided by engineer. - + No contacts to add + אין אנשי קשר להוסיף No comment provided by engineer. - + No device token! + אין אסימון מכשיר! No comment provided by engineer. - + Group not found! + קבוצה לא נמצאה! No comment provided by engineer. - + No permission to record voice message + אין הרשאה להקליט הודעה קולית No comment provided by engineer. - + No received or sent files + לא התקבלו או נשלחו קבצים No comment provided by engineer. - + Notifications + התראות No comment provided by engineer. - + Notifications are disabled! + ההתראות מושבתות! No comment provided by engineer. - + Now admins can: - delete members' messages. - disable members ("observer" role) + כעת מנהלים יכולים: +- למחוק הודעות של חברי קבוצה. +- להשבית חברי קבוצה (תפקיד ”צופה”) No comment provided by engineer. - + Off + כבוי No comment provided by engineer. - + Off (Local) + כבוי (מקומי) No comment provided by engineer. - + Ok + אישור No comment provided by engineer. - + Old database + מסד נתונים ישן No comment provided by engineer. - + Old database archive + ארכיון מסד נתונים ישן No comment provided by engineer. - + One-time invitation link + קישור הזמנה חד־פעמי No comment provided by engineer. - + Onion hosts will be required for connection. Requires enabling VPN. + לחיבור יידרשו מארחי Onion. דורש הפעלת VPN. No comment provided by engineer. - + Onion hosts will be used when available. Requires enabling VPN. + מארחי Onion ישומשו כאשר יהיו זמינים. דורש הפעלת VPN. No comment provided by engineer. - + Onion hosts will not be used. + לא ייעשה שימוש במארחי Onion. No comment provided by engineer. @@ -4981,6 +5047,270 @@ SimpleX servers cannot see your profile. %1$@ בזמן %2$@: copied message info, <sender> at <time> + + # %@ + # %@ + copied message info title, # <title> + + + ## History + ## היסטוריה + copied message info + + + ## In reply to + ## בתגובה ל + copied message info + + + - more stable message delivery. +- a bit better groups. +- and more! + - שליחת הודעות יציבה יותר. +- קבוצות קצת יותר טובות. +- ועוד! + No comment provided by engineer. + + + A few more things + עוד כמה דברים + No comment provided by engineer. + + + A new random profile will be shared. + ישותף פרופיל אקראי חדש. + No comment provided by engineer. + + + Accept connection request? + לאשר בקשת חיבור? + No comment provided by engineer. + + + Connect directly + התחבר ישירות + No comment provided by engineer. + + + Connect incognito + התחבר בזהות נסתרת + No comment provided by engineer. + + + Connect via one-time link + התחבר באמצעות קישור חד־פעמי + No comment provided by engineer. + + + Contacts + אנשי קשר + No comment provided by engineer. + + + Delivery + מסירה + No comment provided by engineer. + + + Error synchronizing connection + שגיאה בסנכרון החיבור + No comment provided by engineer. + + + Fix encryption after restoring backups. + תקן הצפנה לאחר שחזור גיבויים. + No comment provided by engineer. + + + Fix not supported by group member + תיקון אינו נתמך על ידי חבר הקבוצה + No comment provided by engineer. + + + Invalid status + סטטוס לא חוקי + item status text + + + Migrating database archive… + מעביר את ארכיון מסד הנתונים… + No comment provided by engineer. + + + Moderated at: %@ + נחסם ב: %@ + copied message info + + + Most likely this connection is deleted. + סביר להניח שהחיבור הזה נמחק. + item status description + + + %@ and %@ connected + %@ ו-%@ מחוברים + No comment provided by engineer. + + + Connect via contact link + התחבר באמצעות קישור איש קשר + No comment provided by engineer. + + + Delivery receipts! + קבלות על המשלוח! + No comment provided by engineer. + + + Disable for all + השבת לכולם + No comment provided by engineer. + + + Error enabling delivery receipts! + שגיאה בהפעלת קבלות משלוח! + No comment provided by engineer. + + + Even when disabled in the conversation. + גם אם הוא מושבת בשיחה. + No comment provided by engineer. + + + Fix + תקן + No comment provided by engineer. + + + Fix connection + תקן את החיבור + No comment provided by engineer. + + + Find chats faster + מצא צ'אטים מהר יותר + No comment provided by engineer. + + + Fix connection? + לתקן את החיבור? + No comment provided by engineer. + + + Make one message disappear + העלם הודעה אחת + No comment provided by engineer. + + + Fix not supported by contact + תיקון לא נתמך על ידי איש קשר + No comment provided by engineer. + + + Incognito mode protects your privacy by using a new random profile for each contact. + מצב זהות נסתרת מגן על הפרטיות שלך על ידי שימוש בפרופיל אקראי חדש עבור כל איש קשר. + No comment provided by engineer. + + + In reply to + בתגובה ל + No comment provided by engineer. + + + Keep your connections + שימרו על הקשרים שלכם + No comment provided by engineer. + + + Message delivery receipts! + קבלות על הודעות! + No comment provided by engineer. + + + Message reactions are prohibited in this chat. + תגובות אמוג׳י להודעות אסורות בצ׳אט זה. + No comment provided by engineer. + + + Message reactions are prohibited in this group. + תגובות אמוג׳י להודעות אסורות בקבוצה זו. + No comment provided by engineer. + + + Delivery receipts are disabled! + קבלות על משלוח מושבתות! + No comment provided by engineer. + + + Disable (keep overrides) + השבת (שמור עקיפות) + No comment provided by engineer. + + + Don't enable + אל תפעיל + No comment provided by engineer. + + + Enable (keep overrides) + הפעל (שמור עקיפות) + No comment provided by engineer. + + + Enable for all + הפעל עבור כולם + No comment provided by engineer. + + + Error setting delivery receipts! + שגיאה בהגדרת קבלות משלוח! + No comment provided by engineer. + + + Filter unread and favorite chats. + סנן צ'אטים שלא נקראו וצ'אטים מועדפים. + No comment provided by engineer. + + + Moderated at + נחסם + No comment provided by engineer. + + + Message reactions + תגובות אמוג׳י להודעות + chat feature + + + Exporting database archive… + מייצא את ארכיון מסד הנתונים… + No comment provided by engineer. + + + %@, %@ and %lld other members connected + %@, %@ ו-%lld חברים אחרים מחוברים + No comment provided by engineer. + + + New display name + שם תצוגה חדש + No comment provided by engineer. + + + No delivery information + אין מידע על מסירה + No comment provided by engineer. + + + No filtered chats + אין צ'אטים מסוננים + No comment provided by engineer. + + + No history + ללא היסטוריה + No comment provided by engineer. + diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index de3b0c0a67..fb6ea10da1 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -1819,6 +1819,10 @@ Crittografare il database? No comment provided by engineer. + + Encrypt local files + No comment provided by engineer. + Encrypted database Database crittografato @@ -1949,6 +1953,10 @@ Errore nella creazione del profilo! No comment provided by engineer. + + Error decrypting file + No comment provided by engineer. + Error deleting chat database Errore nell'eliminazione del database della chat diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/it.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings deleted file mode 100644 index 8b13789179..0000000000 --- a/apps/ios/SimpleX Localizations/it.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index b28d7391de..c7460be601 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -44,14 +44,17 @@ # %@ + # %@ copied message info title, # <title> ## History + ## 履歴 copied message info ## In reply to + ## 返信先 copied message info @@ -86,10 +89,12 @@ %@ and %@ connected + %@ と %@ は接続中 No comment provided by engineer. %1$@ at %2$@: + %1$@ at %2$@: copied message info, <sender> at <time> @@ -119,6 +124,7 @@ %@, %@ and %lld other members connected + %@, %@ および %lld 人のメンバーが接続中 No comment provided by engineer. @@ -325,6 +331,9 @@ - more stable message delivery. - a bit better groups. - and more! + - より安定したメッセージ配信。 +- 改良されたグループ。 +- などなど! No comment provided by engineer. @@ -405,6 +414,7 @@ A few more things + その他 No comment provided by engineer. @@ -414,6 +424,7 @@ A new random profile will be shared. + 新しいランダムなプロファイルが共有されます。 No comment provided by engineer. @@ -430,14 +441,17 @@ Abort + 中止 No comment provided by engineer. Abort changing address + アドレス変更の中止 No comment provided by engineer. Abort changing address? + アドレス変更を中止しますか? No comment provided by engineer. @@ -523,6 +537,7 @@ Address change will be aborted. Old receiving address will be used. + アドレス変更は中止されます。古い受信アドレスが使用されます。 No comment provided by engineer. @@ -617,6 +632,7 @@ Allow to send files and media. + ファイルやメディアの送信を許可する。 No comment provided by engineer. @@ -791,6 +807,7 @@ Better messages + より良いメッセージ No comment provided by engineer. @@ -1051,10 +1068,12 @@ Connect directly + 直接接続する No comment provided by engineer. Connect incognito + シークレットモードで接続 No comment provided by engineer. @@ -1159,6 +1178,7 @@ Contacts + 連絡先 No comment provided by engineer. @@ -1556,10 +1576,12 @@ Delivery + Delivery No comment provided by engineer. Delivery receipts are disabled! + Delivery receipts are disabled! No comment provided by engineer. @@ -1613,6 +1635,7 @@ Disable (keep overrides) + 無効にする(設定の優先を維持) No comment provided by engineer. @@ -1622,6 +1645,7 @@ Disable for all + すべて無効 No comment provided by engineer. @@ -1686,6 +1710,7 @@ Don't enable + 有効にしない No comment provided by engineer. @@ -1730,6 +1755,7 @@ Enable (keep overrides) + 有効にする(設定の優先を維持) No comment provided by engineer. @@ -1749,6 +1775,7 @@ Enable for all + すべて有効 No comment provided by engineer. @@ -1791,6 +1818,10 @@ データベースを暗号化しますか? No comment provided by engineer. + + Encrypt local files + No comment provided by engineer. + Encrypted database 暗号化済みデータベース @@ -1868,6 +1899,7 @@ Error aborting address change + アドレス変更中止エラー No comment provided by engineer. @@ -1920,6 +1952,10 @@ プロフィール作成にエラー発生! No comment provided by engineer. + + Error decrypting file + No comment provided by engineer. + Error deleting chat database チャットデータベース削除にエラー発生 @@ -2065,6 +2101,7 @@ Error synchronizing connection + 接続の同期エラー No comment provided by engineer. @@ -2109,6 +2146,7 @@ Even when disabled in the conversation. + 会話中に無効になっている場合でも。 No comment provided by engineer. @@ -2148,6 +2186,7 @@ Favorite + お気に入り No comment provided by engineer. @@ -2177,50 +2216,62 @@ Files and media + ファイルとメディア chat feature Files and media are prohibited in this group. + このグループでは、ファイルとメディアは禁止されています。 No comment provided by engineer. Files and media prohibited! + ファイルとメディアは禁止されています! No comment provided by engineer. Filter unread and favorite chats. + 未読とお気に入りをフィルターします。 No comment provided by engineer. Finally, we have them! 🚀 + ついに、私たちはそれらを手に入れました! 🚀 No comment provided by engineer. Find chats faster + チャットを素早く検索 No comment provided by engineer. Fix + 修正 No comment provided by engineer. Fix connection + 接続を修正 No comment provided by engineer. Fix connection? + 接続を修正しますか? No comment provided by engineer. Fix encryption after restoring backups. + バックアップの復元後に暗号化を修正します。 No comment provided by engineer. Fix not supported by contact + 連絡先による修正はサポートされていません No comment provided by engineer. Fix not supported by group member + グループメンバーによる修正はサポートされていません No comment provided by engineer. @@ -2330,6 +2381,7 @@ Group members can send files and media. + グループメンバーはファイルやメディアを送信できます。 No comment provided by engineer. @@ -2529,6 +2581,7 @@ In reply to + 返信先 No comment provided by engineer. @@ -2543,6 +2596,7 @@ Incognito mode protects your privacy by using a new random profile for each contact. + シークレットモードとは、メインのプロフィールとプロフィール画像を守るために、新しい連絡先を追加する時に、その連絡先に対してランダムなプロフィールが作成されるという対策です。 No comment provided by engineer. @@ -2619,6 +2673,7 @@ Invalid status + 無効なステータス item status text @@ -2714,6 +2769,7 @@ Keep your connections + 接続を維持 No comment provided by engineer. @@ -2808,6 +2864,7 @@ Make one message disappear + メッセージを1つ消す No comment provided by engineer. @@ -2966,6 +3023,7 @@ Most likely this connection is deleted. + おそらく、この接続は削除されています。 item status description @@ -3075,6 +3133,7 @@ No delivery information + 送信情報なし No comment provided by engineer. @@ -3084,6 +3143,7 @@ No filtered chats + フィルタされたチャットはありません No comment provided by engineer. @@ -3093,6 +3153,7 @@ No history + 履歴はありません No comment provided by engineer. @@ -3181,6 +3242,7 @@ Only group owners can enable files and media. + ファイルやメディアを有効にできるのは、グループオーナーだけです。 No comment provided by engineer. @@ -3505,6 +3567,7 @@ Prohibit sending files and media. + ファイルやメディアの送信を禁止します。 No comment provided by engineer. @@ -3529,6 +3592,7 @@ Protocol timeout per KB + KB あたりのプロトコル タイムアウト No comment provided by engineer. @@ -3543,6 +3607,7 @@ React… + 反応する… chat item menu @@ -3601,6 +3666,7 @@ Receiving address will be changed to a different server. Address change will complete after sender comes online. + 開発中の機能です!相手のクライアントが4.2でなければ機能しません。アドレス変更が完了すると、会話にメッセージが出ます。連絡相手 (またはグループのメンバー) からメッセージを受信できないかをご確認ください。 No comment provided by engineer. @@ -3620,10 +3686,12 @@ Reconnect all connected servers to force message delivery. It uses additional traffic. + 接続されているすべてのサーバーを再接続して、メッセージを強制的に配信します。 追加のトラフィックを使用します。 No comment provided by engineer. Reconnect servers? + サーバーに再接続しますか? No comment provided by engineer. @@ -3688,14 +3756,17 @@ Renegotiate + 再ネゴシエート No comment provided by engineer. Renegotiate encryption + 暗号化の再ネゴシエート No comment provided by engineer. Renegotiate encryption? + 暗号化を再ネゴシエートしますか? No comment provided by engineer. @@ -4182,6 +4253,7 @@ Show last messages + 最新のメッセージを表示 No comment provided by engineer. @@ -4266,10 +4338,12 @@ Small groups (max 20) + 小グループ(最大20名) No comment provided by engineer. Some non-fatal errors occurred during import - you may see Chat console for more details. + インポート中に致命的でないエラーが発生しました - 詳細はチャットコンソールを参照してください。 No comment provided by engineer. @@ -4486,6 +4560,7 @@ It can happen because of some bug or when the connection is compromised. The encryption is working and the new encryption agreement is not required. It may result in connection errors! + 暗号化は機能しており、新しい暗号化への同意は必要ありません。接続エラーが発生する可能性があります! No comment provided by engineer. @@ -4525,6 +4600,7 @@ It can happen because of some bug or when the connection is compromised. The second tick we missed! ✅ + 長らくお待たせしました! ✅ No comment provided by engineer. @@ -4554,10 +4630,12 @@ It can happen because of some bug or when the connection is compromised. These settings are for your current profile **%@**. + これらの設定は現在のプロファイル **%@** 用です。 No comment provided by engineer. They can be overridden in contact and group settings. + これらは連絡先の設定が優先します。 No comment provided by engineer. @@ -4688,6 +4766,7 @@ You will be prompted to complete authentication before this feature is enabled.< Unfav. + お気に入りを取り消す。 No comment provided by engineer. @@ -4819,6 +4898,7 @@ To connect, please ask your contact to create another connection link and check Use current profile + 現在のプロファイルを使用する No comment provided by engineer. @@ -4833,6 +4913,7 @@ To connect, please ask your contact to create another connection link and check Use new incognito profile + 新しいシークレットプロファイルを使用する No comment provided by engineer. @@ -5047,10 +5128,12 @@ To connect, please ask your contact to create another connection link and check You can enable later via Settings + あとで設定から有効にできます No comment provided by engineer. You can enable them later via app Privacy & Security settings. + あとでアプリのプライバシーとセキュリティの設定から有効にすることができます。 No comment provided by engineer. @@ -5309,6 +5392,7 @@ You can change it in Settings. Your profile **%@** will be shared. + あなたのプロファイル **%@** が共有されます。 No comment provided by engineer. @@ -5385,10 +5469,12 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 agreeing encryption for %@… + %@の暗号化に同意しています… chat item text agreeing encryption… + 暗号化に同意しています… chat item text @@ -5453,10 +5539,12 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 changing address for %@… + %@ のアドレスを変更しています… chat item text changing address… + アドレスを変更しています… chat item text @@ -5561,10 +5649,12 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 default (no) + デフォルト(いいえ) No comment provided by engineer. default (yes) + デフォルト(はい) No comment provided by engineer. @@ -5589,6 +5679,7 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 disabled + 無効 No comment provided by engineer. @@ -5618,34 +5709,42 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 encryption agreed + 暗号化に同意しました chat item text encryption agreed for %@ + %@ の暗号化に同意しました chat item text encryption ok + 暗号化OK chat item text encryption ok for %@ + %@ の暗号化OK chat item text encryption re-negotiation allowed + 暗号化の再ネゴシエーションを許可 chat item text encryption re-negotiation allowed for %@ + %@ の暗号化の再ネゴシエーションを許可 chat item text encryption re-negotiation required + 暗号化の再ネゴシエーションが必要 chat item text encryption re-negotiation required for %@ + %@ の暗号化の再ネゴシエーションが必要 chat item text @@ -5665,6 +5764,7 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 event happened + イベント発生 No comment provided by engineer. @@ -5834,6 +5934,7 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 no text + テキストなし copied message info in history @@ -5924,6 +6025,7 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 security code changed + セキュリティコードが変更されました chat item text diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/ja.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings deleted file mode 100644 index 8b13789179..0000000000 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index b7a73f1f9d..7882a062e7 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -244,7 +244,7 @@ %u messages failed to decrypt. - %u-berichten kunnen niet worden gedecodeerd. + %u berichten kunnen niet worden ontsleuteld. No comment provided by engineer. @@ -1819,6 +1819,10 @@ Database versleutelen? No comment provided by engineer. + + Encrypt local files + No comment provided by engineer. + Encrypted database Versleutelde database @@ -1949,6 +1953,10 @@ Fout bij aanmaken van profiel! No comment provided by engineer. + + Error decrypting file + No comment provided by engineer. + Error deleting chat database Fout bij het verwijderen van de chat database @@ -2713,7 +2721,7 @@ It can happen when you or your connection used the old database backup. - Het kan gebeuren wanneer u of uw verbinding de oude databaseback-up gebruikte. + Het kan gebeuren wanneer u of de ander een oude databaseback-up gebruikt. No comment provided by engineer. @@ -4919,7 +4927,7 @@ Om verbinding te maken, vraagt u uw contactpersoon om een andere verbinding link Use new incognito profile - Gebruik een nieuw incognito -profiel + Gebruik een nieuw incognitoprofiel No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/nl.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings deleted file mode 100644 index 8b13789179..0000000000 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index 767dcccbae..7402249c01 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -89,6 +89,7 @@ %@ and %@ connected + %@ i %@ połączeni No comment provided by engineer. @@ -123,6 +124,7 @@ %@, %@ and %lld other members connected + %@, %@ i %lld innych członków połączeni No comment provided by engineer. @@ -1817,6 +1819,10 @@ Zaszyfrować bazę danych? No comment provided by engineer. + + Encrypt local files + No comment provided by engineer. + Encrypted database Zaszyfrowana baza danych @@ -1947,6 +1953,10 @@ Błąd tworzenia profilu! No comment provided by engineer. + + Error decrypting file + No comment provided by engineer. + Error deleting chat database Błąd usuwania bazy danych czatu @@ -4256,6 +4266,7 @@ Show last messages + Pokaż ostatnie wiadomości No comment provided by engineer. @@ -5767,6 +5778,7 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. event happened + nowe wydarzenie No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/pl.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings deleted file mode 100644 index 8b13789179..0000000000 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 5641ba085e..eec4fd40de 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -1819,6 +1819,10 @@ Зашифровать базу данных? No comment provided by engineer. + + Encrypt local files + No comment provided by engineer. + Encrypted database База данных зашифрована @@ -1949,6 +1953,10 @@ Ошибка создания профиля! No comment provided by engineer. + + Error decrypting file + No comment provided by engineer. + Error deleting chat database Ошибка при удалении данных чата diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings deleted file mode 100644 index 8b13789179..0000000000 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index 72182b2c7a..11bde620f3 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -1807,6 +1807,10 @@ Encrypt ฐานข้อมูล? No comment provided by engineer. + + Encrypt local files + No comment provided by engineer. + Encrypted database Encrypt ฐานข้อมูลเรียบร้อยแล้ว @@ -1937,6 +1941,10 @@ เกิดข้อผิดพลาดในการสร้างโปรไฟล์! No comment provided by engineer. + + Error decrypting file + No comment provided by engineer. + Error deleting chat database เกิดข้อผิดพลาดในการลบฐานข้อมูลแชท diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/th.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings deleted file mode 100644 index 8b13789179..0000000000 --- a/apps/ios/SimpleX Localizations/th.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000000..4b8ee6308e --- /dev/null +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,15 @@ +{ + "colors" : [ + { + "idiom" : "universal", + "locale" : "uk" + } + ], + "properties" : { + "localizable" : true + }, + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/Shared/Assets.xcassets/Contents.json b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/Shared/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/Shared/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index 3051a61122..52c69fbfac 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -1891,7 +1891,7 @@ Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) - Встановіть [SimpleX Chat для терміналу] (https://github.com/simplex-chat/simplex-chat) + Встановіть [SimpleX Chat для терміналу](https://github.com/simplex-chat/simplex-chat) No comment provided by engineer. @@ -2586,7 +2586,7 @@ We will be adding server redundancy to prevent lost messages. Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). - Читайте більше в нашому [GitHub репозиторії] (https://github.com/simplex-chat/simplex-chat#readme). + Читайте більше в нашому [GitHub репозиторії](https://github.com/simplex-chat/simplex-chat#readme). No comment provided by engineer. @@ -3892,17 +3892,17 @@ SimpleX servers cannot see your profile. [Contribute](https://github.com/simplex-chat/simplex-chat#contribute) - [Внесок] (https://github.com/simplex-chat/simplex-chat#contribute) + [Внесок](https://github.com/simplex-chat/simplex-chat#contribute) No comment provided by engineer. [Send us email](mailto:chat@simplex.chat) - [Напишіть нам електронною поштою] (mailto:chat@simplex.chat) + [Напишіть нам електронною поштою](mailto:chat@simplex.chat) No comment provided by engineer. [Star on GitHub](https://github.com/simplex-chat/simplex-chat) - [Зірка на GitHub] (https://github.com/simplex-chat/simplex-chat) + [Зірка на GitHub](https://github.com/simplex-chat/simplex-chat) No comment provided by engineer. @@ -5369,7 +5369,7 @@ SimpleX servers cannot see your profile. Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - Читайте більше в [Посібнику користувача] (https://simplex.chat/docs/guide/readme.html#connect-to-friends). + Читайте більше в [Посібнику користувача](https://simplex.chat/docs/guide/readme.html#connect-to-friends). No comment provided by engineer. @@ -5419,7 +5419,7 @@ SimpleX servers cannot see your profile. Read more in [User Guide](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address). - Читайте більше в [Посібнику користувача] (https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address). + Читайте більше в [Посібнику користувача](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address). No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000000..aaa7f79bc8 --- /dev/null +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,23 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "red" : "0.000", + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.533" + } + }, + "idiom" : "universal" + } + ], + "properties" : { + "localizable" : true + }, + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/Shared/Assets.xcassets/Contents.json b/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/Shared/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/Shared/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/SimpleX NSE/en.lproj/InfoPlist.strings b/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/SimpleX NSE/en.lproj/InfoPlist.strings new file mode 100644 index 0000000000..124ddbcc33 --- /dev/null +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/SimpleX NSE/en.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ +/* Bundle display name */ +"CFBundleDisplayName" = "SimpleX NSE"; +/* Bundle name */ +"CFBundleName" = "SimpleX NSE"; +/* Copyright (human-readable) */ +"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved."; diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/en.lproj/Localizable.strings new file mode 100644 index 0000000000..cf485752ea --- /dev/null +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/en.lproj/Localizable.strings @@ -0,0 +1,30 @@ +/* No comment provided by engineer. */ +"_italic_" = "\\_italic_"; + +/* No comment provided by engineer. */ +"**Add new contact**: to create your one-time QR Code for your contact." = "**Add new contact**: to create your one-time QR Code or link for your contact."; + +/* No comment provided by engineer. */ +"*bold*" = "\\*bold*"; + +/* No comment provided by engineer. */ +"`a + b`" = "\\`a + b`"; + +/* No comment provided by engineer. */ +"~strike~" = "\\~strike~"; + +/* call status */ +"connecting call" = "connecting call…"; + +/* No comment provided by engineer. */ +"Connecting server…" = "Connecting to server…"; + +/* No comment provided by engineer. */ +"Connecting server… (error: %@)" = "Connecting to server… (error: %@)"; + +/* rcv group event chat item */ +"member connected" = "connected"; + +/* No comment provided by engineer. */ +"No group!" = "Group not found!"; + diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings new file mode 100644 index 0000000000..3af673b19f --- /dev/null +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -0,0 +1,10 @@ +/* Bundle name */ +"CFBundleName" = "SimpleX"; +/* Privacy - Camera Usage Description */ +"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; +/* Privacy - Face ID Usage Description */ +"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Microphone Usage Description */ +"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; +/* Privacy - Photo Library Additions Usage Description */ +"NSPhotoLibraryAddUsageDescription" = "SimpleX needs access to Photo Library for saving captured and received media"; diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/contents.json b/apps/ios/SimpleX Localizations/uk.xcloc/contents.json new file mode 100644 index 0000000000..6ad42fd109 --- /dev/null +++ b/apps/ios/SimpleX Localizations/uk.xcloc/contents.json @@ -0,0 +1,12 @@ +{ + "developmentRegion" : "en", + "project" : "SimpleX.xcodeproj", + "targetLocale" : "uk", + "toolInfo" : { + "toolBuildNumber" : "15A5219j", + "toolID" : "com.apple.dt.xcode", + "toolName" : "Xcode", + "toolVersion" : "15.0" + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index eda74c5fd4..8fa66159d4 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -44,14 +44,17 @@ # %@ + # %@ copied message info title, # <title> ## History + ## 历史 copied message info ## In reply to + ## 回复 copied message info @@ -86,10 +89,12 @@ %@ and %@ connected + %@ 和%@ 以建立连接 No comment provided by engineer. %1$@ at %2$@: + %2$@: copied message info, <sender> at <time> @@ -119,6 +124,7 @@ %@, %@ and %lld other members connected + %@, %@ 和 %lld 个成员 No comment provided by engineer. @@ -325,6 +331,9 @@ - more stable message delivery. - a bit better groups. - and more! + - 更稳定的传输! +- 更好的社群! +- 以及更多! No comment provided by engineer. @@ -405,6 +414,7 @@ A few more things + No comment provided by engineer. @@ -414,6 +424,7 @@ A new random profile will be shared. + 创建一个随机的共享文件 No comment provided by engineer. @@ -1797,6 +1808,10 @@ 加密数据库? No comment provided by engineer. + + Encrypt local files + No comment provided by engineer. + Encrypted database 加密数据库 @@ -1927,6 +1942,10 @@ 创建资料错误! No comment provided by engineer. + + Error decrypting file + No comment provided by engineer. + Error deleting chat database 删除聊天数据库错误 diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings deleted file mode 100644 index 8b13789179..0000000000 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - 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 NSE/fi.lproj/InfoPlist.strings b/apps/ios/SimpleX NSE/fi.lproj/InfoPlist.strings new file mode 100644 index 0000000000..28a503d909 --- /dev/null +++ b/apps/ios/SimpleX NSE/fi.lproj/InfoPlist.strings @@ -0,0 +1,9 @@ +/* Bundle display name */ +"CFBundleDisplayName" = "SimpleX NSE"; + +/* Bundle name */ +"CFBundleName" = "SimpleX NSE"; + +/* Copyright (human-readable) */ +"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. Kaikki oikeudet pidätetään."; + diff --git a/apps/ios/SimpleX NSE/uk.lproj/InfoPlist.strings b/apps/ios/SimpleX NSE/uk.lproj/InfoPlist.strings new file mode 100644 index 0000000000..da1f5367b3 --- /dev/null +++ b/apps/ios/SimpleX NSE/uk.lproj/InfoPlist.strings @@ -0,0 +1,9 @@ +/* Bundle display name */ +"CFBundleDisplayName" = "SimpleX NSE"; + +/* Bundle name */ +"CFBundleName" = "SimpleX NSE"; + +/* Copyright (human-readable) */ +"NSHumanReadableCopyright" = "Авторське право © 2022 SimpleX Chat. Всі права захищені."; + diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 8b814e3c0d..026bc963a0 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 */; }; @@ -267,6 +268,8 @@ 5C10D88728EED12E00E58BF0 /* ContactConnectionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactConnectionInfo.swift; sourceTree = ""; }; 5C10D88928F187F300E58BF0 /* FullScreenMediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenMediaView.swift; sourceTree = ""; }; 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactRequestView.swift; sourceTree = ""; }; + 5C136D8E2AAB3D14006DE2FC /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = "fi.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; + 5C136D8F2AAB3D14006DE2FC /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = ""; }; 5C13730A28156D2700F43030 /* ContactConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactConnectionView.swift; sourceTree = ""; }; 5C13730C2815740A00F43030 /* DebugJSON.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = DebugJSON.playground; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemView.swift; sourceTree = ""; }; @@ -295,6 +298,8 @@ 5C5E5D3C282447AB00B0488A /* CallTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallTypes.swift; sourceTree = ""; }; 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = ""; }; 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileImage.swift; sourceTree = ""; }; + 5C636F662AAB3D2400751C84 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = "uk.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; + 5C636F672AAB3D2400751C84 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/InfoPlist.strings; sourceTree = ""; }; 5C65DAE429C77136003CEE45 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; 5C65DAE629C771B9003CEE45 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5C65DAE729C771B9003CEE45 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = ""; }; @@ -331,6 +336,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 = ""; }; @@ -413,6 +419,8 @@ 5CE2BA96284537A800EC33A6 /* dummy.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = dummy.m; sourceTree = ""; }; 5CE4407127ADB1D0007B033A /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = ""; }; 5CE4407827ADB701007B033A /* EmojiItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiItemView.swift; sourceTree = ""; }; + 5CE6C7B32AAB1515007F345C /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; + 5CE6C7B42AAB1527007F345C /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 5CEACCE227DE9246000BD591 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = ""; }; 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardPadding.swift; sourceTree = ""; }; @@ -723,10 +731,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 +787,7 @@ 5CDCAD7D2818941F00503DA2 /* API.swift */, 5CDCAD80281A7E2700503DA2 /* Notifications.swift */, 64DAE1502809D9F5000DA960 /* FileUtils.swift */, + 5C9D81182AA7A4F1001D49FD /* CryptoFile.swift */, 5C00168028C4FE760094D739 /* KeyChain.swift */, 5CE2BA76284530BF00EC33A6 /* SimpleXChat.h */, 5CE2BA8A2845332200EC33A6 /* SimpleX.h */, @@ -1003,6 +1012,8 @@ pl, ja, th, + fi, + uk, ); mainGroup = 5CA059BD279559F40002BEB4; packageReferences = ( @@ -1236,6 +1247,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 */, @@ -1283,6 +1295,8 @@ 5C6D183329E93FBA00D430B3 /* pl */, 5CAC411B2A192DE800C331A2 /* ja */, 5CA3ED502A9422D1005D71E2 /* th */, + 5C136D8F2AAB3D14006DE2FC /* fi */, + 5C636F672AAB3D2400751C84 /* uk */, ); name = InfoPlist.strings; sourceTree = ""; @@ -1302,6 +1316,8 @@ 5CAB912529E93F9400F34A95 /* pl */, 5CAC41182A192D8400C331A2 /* ja */, 5CA3ED4D2A942170005D71E2 /* th */, + 5CE6C7B32AAB1515007F345C /* fi */, + 5CE6C7B42AAB1527007F345C /* uk */, ); name = Localizable.strings; sourceTree = ""; @@ -1320,6 +1336,8 @@ 5C6D183229E93FBA00D430B3 /* pl */, 5CAC411A2A192DE800C331A2 /* ja */, 5CA3ED4F2A9422D1005D71E2 /* th */, + 5C136D8E2AAB3D14006DE2FC /* fi */, + 5C636F662AAB3D2400751C84 /* uk */, ); name = "SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index d80626d6f1..ad641810c2 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 @@ -481,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) @@ -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..dcb2be9ae0 --- /dev/null +++ b/apps/ios/SimpleXChat/CryptoFile.swift @@ -0,0 +1,69 @@ +// +// 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) + } +} + +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 ptr = chat_read_file(&cPath, &cKey, &cNonce)! + let status = UInt8(ptr.pointee) + switch status { + case 0: // ok + let dLen = Data(bytes: ptr.advanced(by: 1), count: 4) + let len = dLen.withUnsafeBytes { $0.load(as: UInt32.self) } + let d = Data(bytes: ptr.advanced(by: 5), count: Int(len)) + free(ptr) + return d + case 1: // error + let err = String.init(cString: ptr) + free(ptr) + throw RuntimeError(err) + default: + throw RuntimeError("unexpected chat_read_file status: \(status)") + } +} + +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..67c2fa728c 100644 --- a/apps/ios/SimpleXChat/SimpleX.h +++ b/apps/ios/SimpleXChat/SimpleX.h @@ -25,3 +25,18 @@ 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 null-terminated string with JSON of WriteFileResult +extern char *chat_write_file(char *path, char *data, int len); + +// chat_read_file returns a buffer with: +// result status (1 byte), then if +// status == 0 (success): buffer length (uint32, 4 bytes), buffer of specified length. +// status == 1 (error): null-terminated error message string. +extern char *chat_read_file(char *path, char *key, char *nonce); + +// chat_encrypt_file returns null-terminated string with JSON of WriteFileResult +extern char *chat_encrypt_file(char *fromPath, char *toPath); + +// chat_decrypt_file returns null-terminated string with the error message +extern char *chat_decrypt_file(char *fromPath, char *key, char *nonce, char *toPath); diff --git a/apps/ios/cs.lproj/Localizable.strings b/apps/ios/cs.lproj/Localizable.strings index ef5a2e2d27..3d7d5f8fe2 100644 --- a/apps/ios/cs.lproj/Localizable.strings +++ b/apps/ios/cs.lproj/Localizable.strings @@ -88,6 +88,15 @@ /* No comment provided by engineer. */ "*bold*" = "\\*tučně*"; +/* copied message info title, # */ +"# %@" = "# %@"; + +/* copied message info */ +"## History" = "## Historie"; + +/* copied message info */ +"## In reply to" = "## Odpovídáno"; + /* No comment provided by engineer. */ "#secret#" = "#tajný#"; @@ -106,6 +115,9 @@ /* No comment provided by engineer. */ "%@ %@" = "%@ %@"; +/* No comment provided by engineer. */ +"%@ and %@ connected" = "%@ a %@ připojen"; + /* copied message info, <sender> at <time> */ "%@ at %@:" = "%1$@ na %2$@:"; @@ -124,6 +136,9 @@ /* notification title */ "%@ wants to connect!" = "%@ se chce připojit!"; +/* No comment provided by engineer. */ +"%@, %@ and %lld other members connected" = "%@, %@ a %lld ostatní členové připojeni"; + /* copied message info */ "%@:" = "%@:"; @@ -282,7 +297,7 @@ "Accept" = "Přijmout"; /* No comment provided by engineer. */ -"Accept connection request?" = "Přijmout kontakt"; +"Accept connection request?" = "Přijmout kontakt?"; /* notification body */ "Accept contact request from %@?" = "Přijmout žádost o kontakt od %@?"; @@ -693,11 +708,17 @@ /* server test step */ "Connect" = "Připojit"; +/* No comment provided by engineer. */ +"Connect directly" = "Připojit přímo"; + +/* No comment provided by engineer. */ +"Connect incognito" = "Spojit se inkognito"; + /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "připojit se k vývojářům SimpleX Chat."; /* No comment provided by engineer. */ -"Connect via contact link" = "Připojit se přes kontaktní odkaz?"; +"Connect via contact link" = "Připojit se přes odkaz"; /* No comment provided by engineer. */ "Connect via group link?" = "Připojit se přes odkaz skupiny?"; @@ -709,7 +730,7 @@ "Connect via link / QR code" = "Připojit se prostřednictvím odkazu / QR kódu"; /* No comment provided by engineer. */ -"Connect via one-time link" = "Připojit se jednorázovým odkazem?"; +"Connect via one-time link" = "Připojit se jednorázovým odkazem"; /* No comment provided by engineer. */ "connected" = "připojeno"; @@ -1053,6 +1074,9 @@ /* rcv group event chat item */ "deleted group" = "odstraněna skupina"; +/* No comment provided by engineer. */ +"Delivery" = "Doručenka"; + /* No comment provided by engineer. */ "Delivery receipts are disabled!" = "Potvrzení o doručení jsou vypnuté!"; @@ -1722,6 +1746,9 @@ /* No comment provided by engineer. */ "Incognito mode" = "Režim inkognito"; +/* No comment provided by engineer. */ +"Incognito mode protects your privacy by using a new random profile for each contact." = "Režim inkognito chrání vaše soukromí používáním nového náhodného profilu pro každý kontakt."; + /* chat list item description */ "incognito via contact address link" = "inkognito přes odkaz na kontaktní adresu"; @@ -1785,6 +1812,9 @@ /* No comment provided by engineer. */ "Invalid server address!" = "Neplatná adresa serveru!"; +/* item status text */ +"Invalid status" = "Neplatný status"; + /* No comment provided by engineer. */ "Invitation expired!" = "Platnost pozvánky vypršela!"; @@ -1858,10 +1888,10 @@ "Join group" = "Připojit ke skupině"; /* No comment provided by engineer. */ -"Join incognito" = "Připojte se inkognito"; +"Join incognito" = "Připojit se inkognito"; /* No comment provided by engineer. */ -"Joining group" = "Připojení ke skupině"; +"Joining group" = "Připojování ke skupině"; /* No comment provided by engineer. */ "Keep your connections" = "Zachovat vaše připojení"; @@ -2046,6 +2076,9 @@ /* No comment provided by engineer. */ "More improvements are coming soon!" = "Další vylepšení se chystají již brzy!"; +/* item status description */ +"Most likely this connection is deleted." = "Pravděpodobně je toto spojení smazáno."; + /* No comment provided by engineer. */ "Most likely this contact has deleted the connection with you." = "Tento kontakt s největší pravděpodobností smazal spojení s vámi."; @@ -3071,7 +3104,7 @@ "These settings are for your current profile **%@**." = "Toto nastavení je pro váš aktuální profil **%@**."; /* No comment provided by engineer. */ -"They can be overridden in contact and group settings." = "Mohou být přepsány v nastavení kontaktů"; +"They can be overridden in contact and group settings." = "Mohou být přepsány v nastavení kontaktů."; /* No comment provided by engineer. */ "This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Tuto akci nelze vrátit zpět - všechny přijaté a odeslané soubory a média budou smazány. Obrázky s nízkým rozlišením zůstanou zachovány."; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index d1cd5816ae..e351114d74 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -281,7 +281,7 @@ "About SimpleX" = "Acerca de SimpleX"; /* No comment provided by engineer. */ -"About SimpleX address" = "Acerca de dirección SimpleX"; +"About SimpleX address" = "Acerca de la dirección SimpleX"; /* No comment provided by engineer. */ "About SimpleX Chat" = "Sobre SimpleX Chat"; @@ -832,7 +832,7 @@ "Create" = "Crear"; /* No comment provided by engineer. */ -"Create an address to let people connect with you." = "Crear una dirección para que otras personas se puedan conectar contigo."; +"Create an address to let people connect with you." = "Crea una dirección para que otras personas puedan conectar contigo."; /* server test step */ "Create file" = "Crear archivo"; @@ -853,10 +853,10 @@ "Create secret group" = "Crea grupo secreto"; /* No comment provided by engineer. */ -"Create SimpleX address" = "Crear dirección SimpleX"; +"Create SimpleX address" = "Crear tu dirección SimpleX"; /* No comment provided by engineer. */ -"Create your profile" = "Crear tu perfil"; +"Create your profile" = "Crea tu perfil"; /* No comment provided by engineer. */ "Created on %@" = "Creado en %@"; @@ -943,7 +943,7 @@ "days" = "días"; /* No comment provided by engineer. */ -"Decentralized" = "Descentralizado"; +"Decentralized" = "Descentralizada"; /* message decrypt error item */ "Decryption error" = "Error descifrado"; @@ -1162,7 +1162,7 @@ "Do NOT use SimpleX for emergency calls." = "NO uses SimpleX para llamadas de emergencia."; /* No comment provided by engineer. */ -"Don't create address" = "No crear dirección"; +"Don't create address" = "No crear dirección SimpleX"; /* No comment provided by engineer. */ "Don't enable" = "No activar"; @@ -1981,7 +1981,7 @@ "Mark verified" = "Marcar como verificado"; /* No comment provided by engineer. */ -"Markdown in messages" = "Sintaxis markdown en los mensajes"; +"Markdown in messages" = "Sintaxis Markdown"; /* marked deleted chat item preview text */ "marked deleted" = "marcado eliminado"; @@ -2510,7 +2510,7 @@ "Received message" = "Mensaje entrante"; /* No comment provided by engineer. */ -"Receiving address will be changed to a different server. Address change will complete after sender comes online." = "La dirección de recepción se cambiará. El cambio se completará cuando el remitente esté en línea."; +"Receiving address will be changed to a different server. Address change will complete after sender comes online." = "La dirección de recepción pasará a otro servidor. El cambio se completará cuando el remitente esté en línea."; /* No comment provided by engineer. */ "Receiving file will be stopped." = "Se detendrá la recepción del archivo."; @@ -2966,7 +2966,7 @@ "Stop" = "Detener"; /* No comment provided by engineer. */ -"Stop chat to enable database actions" = "Para habilitar las acciones sobre la base de datos, previamente debes detener Chat"; +"Stop chat to enable database actions" = "Detén SimpleX para habilitar las acciones sobre la base de datos"; /* No comment provided by engineer. */ "Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped." = "Para poder exportar, importar o eliminar la base de datos primero debes detener Chat. Durante el tiempo que esté detenido no podrás recibir ni enviar mensajes."; @@ -3095,7 +3095,7 @@ "The message will be marked as moderated for all members." = "El mensaje será marcado como moderado para todos los miembros."; /* No comment provided by engineer. */ -"The next generation of private messaging" = "La próxima generación de mensajería privada"; +"The next generation of private messaging" = "La nueva generación de mensajería privada"; /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "La base de datos antigua no se eliminó durante la migración, puede eliminarse."; @@ -3464,7 +3464,7 @@ "You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button." = "También puedes conectarte haciendo clic en el enlace. Si se abre en el navegador, haz clic en el botón **Abrir en aplicación móvil**."; /* No comment provided by engineer. */ -"You can create it later" = "Puedes crearlo más tarde"; +"You can create it later" = "Puedes crearla más tarde"; /* No comment provided by engineer. */ "You can enable later via Settings" = "Puedes activar más tarde en Configuración"; @@ -3599,7 +3599,7 @@ "Your calls" = "Llamadas"; /* No comment provided by engineer. */ -"Your chat database" = "Base de datos Chat"; +"Your chat database" = "Base de datos"; /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "La base de datos no está cifrada - establece una contraseña para cifrarla."; @@ -3647,7 +3647,7 @@ "Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "Tu perfil se almacena en tu dispositivo y sólo se comparte con tus contactos.\nLos servidores de SimpleX no pueden ver tu perfil."; /* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "Tu perfil, contactos y mensajes entregados se almacenan en tu dispositivo."; +"Your profile, contacts and delivered messages are stored on your device." = "Tu perfil, contactos y mensajes se almacenan en tu dispositivo."; /* No comment provided by engineer. */ "Your random profile" = "Tu perfil aleatorio"; diff --git a/apps/ios/fi.lproj/Localizable.strings b/apps/ios/fi.lproj/Localizable.strings new file mode 100644 index 0000000000..47cce5061d --- /dev/null +++ b/apps/ios/fi.lproj/Localizable.strings @@ -0,0 +1,3675 @@ +/* No comment provided by engineer. */ +"\n" = "\n"; + +/* No comment provided by engineer. */ +" " = " "; + +/* No comment provided by engineer. */ +" " = " "; + +/* No comment provided by engineer. */ +" " = " "; + +/* No comment provided by engineer. */ +" (" = " ("; + +/* No comment provided by engineer. */ +" (can be copied)" = " (voidaan kopioida)"; + +/* No comment provided by engineer. */ +"_italic_" = "\\_italic_"; + +/* No comment provided by engineer. */ +"- more stable message delivery.\n- a bit better groups.\n- and more!" = "- vakaampi viestien toimitus.\n- hieman paremmat ryhmät.\n- ja paljon muuta!"; + +/* No comment provided by engineer. */ +"- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- ääniviestit enintään 5 minuuttia.\n- mukautettu katoamisaika.\n- historian muokkaaminen."; + +/* No comment provided by engineer. */ +", " = ", "; + +/* No comment provided by engineer. */ +": " = ": "; + +/* No comment provided by engineer. */ +"!1 colored!" = "!1 värillinen!"; + +/* No comment provided by engineer. */ +"." = "."; + +/* No comment provided by engineer. */ +"(" = "("; + +/* No comment provided by engineer. */ +")" = ")"; + +/* No comment provided by engineer. */ +"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Osallistu](https://github.com/simplex-chat/simplex-chat#contribute)"; + +/* No comment provided by engineer. */ +"[Send us email](mailto:chat@simplex.chat)" = "[Lähetä meille sähköpostia](mailto:chat@simplex.chat)"; + +/* No comment provided by engineer. */ +"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Tähti GitHubissa](https://github.com/simplex-chat/simplex-chat)"; + +/* No comment provided by engineer. */ +"**Add new contact**: to create your one-time QR Code for your contact." = "**Lisää uusi kontakti**: luo kertakäyttöinen QR-koodi tai linkki kontaktille."; + +/* No comment provided by engineer. */ +"**Create link / QR code** for your contact to use." = "**Luo linkki / QR-koodi* kontaktille."; + +/* No comment provided by engineer. */ +"**e2e encrypted** audio call" = "**e2e-salattu** äänipuhelu"; + +/* No comment provided by engineer. */ +"**e2e encrypted** video call" = "**e2e-salattu** videopuhelu"; + +/* No comment provided by engineer. */ +"**More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have." = "**Yksityisempi**: tarkista uudet viestit 20 minuutin välein. Laitetunnus jaetaan SimpleX Chat -palvelimen kanssa, mutta ei sitä, kuinka monta yhteystietoa tai viestiä sinulla on."; + +/* No comment provided by engineer. */ +"**Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app)." = "**Yksityisin**: älä käytä SimpleX Chat -ilmoituspalvelinta, tarkista viestit ajoittain taustalla (riippuu siitä, kuinka usein käytät sovellusta)."; + +/* No comment provided by engineer. */ +"**Paste received link** or open it in the browser and tap **Open in mobile app**." = "**Liitä vastaanotettu linkki** tai avaa se selaimessa ja napauta **Avaa mobiilisovelluksessa**."; + +/* No comment provided by engineer. */ +"**Please note**: you will NOT be able to recover or change passphrase if you lose it." = "**Huomaa**: et voi palauttaa tai muuttaa tunnuslausetta, jos kadotat sen."; + +/* No comment provided by engineer. */ +"**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from." = "**Suositus**: laitetunnus ja ilmoitukset lähetetään SimpleX Chat -ilmoituspalvelimelle, mutta ei viestin sisältöä, kokoa tai sitä, keneltä se on peräisin."; + +/* No comment provided by engineer. */ +"**Scan QR code**: to connect to your contact in person or via video call." = "**Skannaa QR-koodi**: muodosta yhteys kontaktiisi henkilökohtaisesti tai videopuhelun kautta."; + +/* No comment provided by engineer. */ +"**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Varoitus**: Välittömät push-ilmoitukset vaativat tunnuslauseen, joka on tallennettu Keychainiin."; + +/* No comment provided by engineer. */ +"*bold*" = "\\*bold*"; + +/* copied message info title, # <title> */ +"# %@" = "# %@"; + +/* copied message info */ +"## History" = "## Historia"; + +/* copied message info */ +"## In reply to" = "## vastauksena"; + +/* No comment provided by engineer. */ +"#secret#" = "#salaisuus#"; + +/* No comment provided by engineer. */ +"%@" = "% @"; + +/* No comment provided by engineer. */ +"%@ (current)" = "%@ (nykyinen)"; + +/* copied message info */ +"%@ (current):" = "% (nykyinen):"; + +/* No comment provided by engineer. */ +"%@ / %@" = "%@ / % @"; + +/* No comment provided by engineer. */ +"%@ %@" = "%@ % @"; + +/* No comment provided by engineer. */ +"%@ and %@ connected" = "%@ ja %@ yhdistetty"; + +/* copied message info, <sender> at <time> */ +"%@ at %@:" = "%1$@ klo %2$@:"; + +/* notification title */ +"%@ is connected!" = "%@ on yhdistetty!"; + +/* No comment provided by engineer. */ +"%@ is not verified" = "%@ ei ole vahvistettu"; + +/* No comment provided by engineer. */ +"%@ is verified" = "%@ on vahvistettu"; + +/* No comment provided by engineer. */ +"%@ servers" = "%@ palvelimet"; + +/* notification title */ +"%@ wants to connect!" = "%@ haluaa muodostaa yhteyden!"; + +/* No comment provided by engineer. */ +"%@, %@ and %lld other members connected" = "%@, %@ ja %lld muut jäsenet yhdistetty"; + +/* copied message info */ +"%@:" = "%@:"; + +/* time interval */ +"%d days" = "%d päivää"; + +/* time interval */ +"%d hours" = "%d tuntia"; + +/* time interval */ +"%d min" = "%d min"; + +/* time interval */ +"%d months" = "%d kuukautta"; + +/* time interval */ +"%d sec" = "%d sek"; + +/* integrity error chat item */ +"%d skipped message(s)" = "%d ohitettua viestiä"; + +/* time interval */ +"%d weeks" = "%d viikkoa"; + +/* No comment provided by engineer. */ +"%lld" = "%lld"; + +/* No comment provided by engineer. */ +"%lld %@" = "%lld %@"; + +/* No comment provided by engineer. */ +"%lld contact(s) selected" = "%lld kontaktia valittu"; + +/* No comment provided by engineer. */ +"%lld file(s) with total size of %@" = "%lld tiedosto(a), joiden kokonaiskoko on %@"; + +/* No comment provided by engineer. */ +"%lld members" = "%lld jäsenet"; + +/* No comment provided by engineer. */ +"%lld minutes" = "%lld minuuttia"; + +/* No comment provided by engineer. */ +"%lld second(s)" = "%lld sekunti(a)"; + +/* No comment provided by engineer. */ +"%lld seconds" = "%lld sekuntia"; + +/* No comment provided by engineer. */ +"%lldd" = "%lldd"; + +/* No comment provided by engineer. */ +"%lldh" = "%lldh"; + +/* No comment provided by engineer. */ +"%lldk" = "%lldk"; + +/* No comment provided by engineer. */ +"%lldm" = "%lldm"; + +/* No comment provided by engineer. */ +"%lldmth" = "%lldmth"; + +/* No comment provided by engineer. */ +"%llds" = "%llds"; + +/* No comment provided by engineer. */ +"%lldw" = "%lldw"; + +/* No comment provided by engineer. */ +"%u messages failed to decrypt." = "%u viestien salauksen purku epäonnistui."; + +/* No comment provided by engineer. */ +"%u messages skipped." = "%u viestit ohitettu."; + +/* No comment provided by engineer. */ +"`a + b`" = "\\`a + b`"; + +/* email text */ +"<p>Hi!</p>\n<p><a href=\"%@\">Connect to me via SimpleX Chat</a></p>" = "<p> Hei! </p>\n<p> <a href=\"%@\"> Ollaan yhteydessä SimpleX Chatin kautta</a></p>"; + +/* No comment provided by engineer. */ +"~strike~" = "\\~strike~"; + +/* No comment provided by engineer. */ +"0s" = "0s"; + +/* time interval */ +"1 day" = "1 päivä"; + +/* time interval */ +"1 hour" = "1 tunti"; + +/* No comment provided by engineer. */ +"1 minute" = "1 minuutti"; + +/* time interval */ +"1 month" = "1 kuukausi"; + +/* time interval */ +"1 week" = "1 viikko"; + +/* No comment provided by engineer. */ +"1-time link" = "Kertakäyttölinkki"; + +/* No comment provided by engineer. */ +"5 minutes" = "5 minuuttia"; + +/* No comment provided by engineer. */ +"6" = "6"; + +/* No comment provided by engineer. */ +"30 seconds" = "30 sekuntia"; + +/* No comment provided by engineer. */ +"A few more things" = "Muutama asia lisää"; + +/* notification title */ +"A new contact" = "Uusi kontakti"; + +/* No comment provided by engineer. */ +"A new random profile will be shared." = "Uusi satunnainen profiili jaetaan."; + +/* No comment provided by engineer. */ +"A separate TCP connection will be used **for each chat profile you have in the app**." = "Erillistä TCP-yhteyttä käytetään **jokaiselle sovelluksessa olevalle chat-profiilille**."; + +/* No comment provided by engineer. */ +"A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." = "Jokaiselle kontaktille ja ryhmän jäsenelle käytetään erillistä TCP-yhteyttä**.\n**Huomaa**: jos kontakteja on useita, akun ja liikenteen kulutus voi olla huomattavasti suurempi ja jotkin yhteydet voivat epäonnistua."; + +/* No comment provided by engineer. */ +"Abort" = "Keskeytä"; + +/* No comment provided by engineer. */ +"Abort changing address" = "Keskeytä osoitteenvaihto"; + +/* No comment provided by engineer. */ +"Abort changing address?" = "Keskeytä osoitteenvaihto?"; + +/* No comment provided by engineer. */ +"About SimpleX" = "Tietoja SimpleX:stä"; + +/* No comment provided by engineer. */ +"About SimpleX address" = "Tietoja SimpleX osoitteesta"; + +/* No comment provided by engineer. */ +"About SimpleX Chat" = "Tietoja SimpleX Chatistä"; + +/* No comment provided by engineer. */ +"above, then choose:" = "edellä, valitse sitten:"; + +/* No comment provided by engineer. */ +"Accent color" = "Korostusväri"; + +/* accept contact request via notification + accept incoming call via notification */ +"Accept" = "Hyväksy"; + +/* No comment provided by engineer. */ +"Accept connection request?" = "Hyväksy yhteyspyyntö?"; + +/* notification body */ +"Accept contact request from %@?" = "Hyväksy kontaktipyyntö %@:ltä?"; + +/* accept contact request via notification */ +"Accept incognito" = "Hyväksy tuntematon"; + +/* call status */ +"accepted call" = "hyväksytty puhelu"; + +/* No comment provided by engineer. */ +"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Lisää osoite profiiliisi, jotta kontaktisi voivat jakaa sen muiden kanssa. Profiilipäivitys lähetetään kontakteillesi."; + +/* No comment provided by engineer. */ +"Add preset servers" = "Lisää esiasetettuja palvelimia"; + +/* No comment provided by engineer. */ +"Add profile" = "Lisää profiili"; + +/* No comment provided by engineer. */ +"Add server…" = "Lisää palvelin…"; + +/* No comment provided by engineer. */ +"Add servers by scanning QR codes." = "Lisää palvelimia skannaamalla QR-koodeja."; + +/* No comment provided by engineer. */ +"Add to another device" = "Lisää toiseen laitteeseen"; + +/* No comment provided by engineer. */ +"Add welcome message" = "Lisää tervetuloviesti"; + +/* No comment provided by engineer. */ +"Address" = "Osoite"; + +/* No comment provided by engineer. */ +"Address change will be aborted. Old receiving address will be used." = "Osoitteenmuutos keskeytetään. Käytetään vanhaa vastaanotto-osoitetta."; + +/* member role */ +"admin" = "ylläpitäjä"; + +/* No comment provided by engineer. */ +"Admins can create the links to join groups." = "Ylläpitäjät voivat luoda linkkejä ryhmiin liittymiseen."; + +/* No comment provided by engineer. */ +"Advanced network settings" = "Verkon lisäasetukset"; + +/* chat item text */ +"agreeing encryption for %@…" = "salauksesta sovitaan %@:lle…"; + +/* chat item text */ +"agreeing encryption…" = "hyväksyy salausta…"; + +/* No comment provided by engineer. */ +"All app data is deleted." = "Kaikki sovelluksen tiedot poistetaan."; + +/* No comment provided by engineer. */ +"All chats and messages will be deleted - this cannot be undone!" = "Kaikki keskustelut ja viestit poistetaan - tätä ei voi kumota!"; + +/* No comment provided by engineer. */ +"All data is erased when it is entered." = "Kaikki tiedot poistetaan, kun se syötetään."; + +/* No comment provided by engineer. */ +"All group members will remain connected." = "Kaikki ryhmän jäsenet pysyvät yhteydessä."; + +/* No comment provided by engineer. */ +"All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." = "Kaikki viestit poistetaan - tätä ei voi kumota! Viestit poistuvat VAIN sinulta."; + +/* No comment provided by engineer. */ +"All your contacts will remain connected." = "Kaikki kontaktisi pysyvät yhteydessä."; + +/* No comment provided by engineer. */ +"All your contacts will remain connected. Profile update will be sent to your contacts." = "Kaikki kontaktisi pysyvät yhteydessä. Profiilipäivitys lähetetään kontakteillesi."; + +/* No comment provided by engineer. */ +"Allow" = "Salli"; + +/* No comment provided by engineer. */ +"Allow calls only if your contact allows them." = "Salli puhelut vain, jos kontaktisi sallii ne."; + +/* No comment provided by engineer. */ +"Allow disappearing messages only if your contact allows it to you." = "Salli katoavat viestit vain, jos kontaktisi sallii sen sinulle."; + +/* No comment provided by engineer. */ +"Allow irreversible message deletion only if your contact allows it to you." = "Salli peruuttamaton viestien poisto vain, jos kontaktisi sallii ne sinulle."; + +/* No comment provided by engineer. */ +"Allow message reactions only if your contact allows them." = "Salli reaktiot viesteihin vain, jos kontaktisi sallii ne."; + +/* No comment provided by engineer. */ +"Allow message reactions." = "Salli viestireaktiot."; + +/* No comment provided by engineer. */ +"Allow sending direct messages to members." = "Salli yksityisviestien lähettäminen jäsenille."; + +/* No comment provided by engineer. */ +"Allow sending disappearing messages." = "Salli katoavien viestien lähettäminen."; + +/* No comment provided by engineer. */ +"Allow to irreversibly delete sent messages." = "Salli lähetettyjen viestien peruuttamaton poistaminen."; + +/* No comment provided by engineer. */ +"Allow to send files and media." = "Salli tiedostojen ja median lähettäminen."; + +/* No comment provided by engineer. */ +"Allow to send voice messages." = "Salli ääniviestien lähettäminen."; + +/* No comment provided by engineer. */ +"Allow voice messages only if your contact allows them." = "Salli ääniviestit vain, jos kontaktisi sallii ne."; + +/* No comment provided by engineer. */ +"Allow voice messages?" = "Salli ääniviestit?"; + +/* No comment provided by engineer. */ +"Allow your contacts adding message reactions." = "Salli kontaktiesi lisätä viestireaktioita."; + +/* No comment provided by engineer. */ +"Allow your contacts to call you." = "Salli kontaktiesi soittaa sinulle."; + +/* No comment provided by engineer. */ +"Allow your contacts to irreversibly delete sent messages." = "Salli kontaktiesi poistaa lähetetyt viestit peruuttamattomasti."; + +/* No comment provided by engineer. */ +"Allow your contacts to send disappearing messages." = "Salli kontaktiesi lähettää katoavia viestejä."; + +/* No comment provided by engineer. */ +"Allow your contacts to send voice messages." = "Salli kontaktiesi lähettää ääniviestejä."; + +/* No comment provided by engineer. */ +"Already connected?" = "Oletko jo muodostanut yhteyden?"; + +/* pref value */ +"always" = "aina"; + +/* No comment provided by engineer. */ +"Always use relay" = "Käytä aina relettä"; + +/* No comment provided by engineer. */ +"An empty chat profile with the provided name is created, and the app opens as usual." = "Luodaan tyhjä chat-profiili annetulla nimellä, ja sovellus avautuu normaalisti."; + +/* No comment provided by engineer. */ +"Answer call" = "Vastaa puheluun"; + +/* No comment provided by engineer. */ +"App build: %@" = "Sovellusversio: %@"; + +/* No comment provided by engineer. */ +"App icon" = "Sovelluksen kuvake"; + +/* No comment provided by engineer. */ +"App passcode" = "Sovelluksen pääsykoodi"; + +/* No comment provided by engineer. */ +"App passcode is replaced with self-destruct passcode." = "Sovelluksen pääsykoodi korvataan itsetuhoutuvalla pääsykoodilla."; + +/* No comment provided by engineer. */ +"App version" = "Sovellusversio"; + +/* No comment provided by engineer. */ +"App version: v%@" = "Sovellusversio: v%@"; + +/* No comment provided by engineer. */ +"Appearance" = "Ulkonäkö"; + +/* No comment provided by engineer. */ +"Attach" = "Liitä"; + +/* No comment provided by engineer. */ +"Audio & video calls" = "Ääni- ja videopuhelut"; + +/* No comment provided by engineer. */ +"Audio and video calls" = "Ääni- ja videopuhelut"; + +/* No comment provided by engineer. */ +"audio call (not e2e encrypted)" = "äänipuhelu (ei e2e-salattu)"; + +/* chat feature */ +"Audio/video calls" = "Ääni/videopuhelut"; + +/* No comment provided by engineer. */ +"Audio/video calls are prohibited." = "Ääni-/videopuhelut ovat kiellettyjä."; + +/* PIN entry */ +"Authentication cancelled" = "Tunnistautuminen peruutettu"; + +/* No comment provided by engineer. */ +"Authentication failed" = "Tunnistautuminen epäonnistui"; + +/* No comment provided by engineer. */ +"Authentication is required before the call is connected, but you may miss calls." = "Tunnistautuminen vaaditaan ennen kuin puhelu yhdistetään, mutta puheluita voi jäädä vastaamatta."; + +/* No comment provided by engineer. */ +"Authentication unavailable" = "Tunnistautuminen ei ole käytettävissä"; + +/* No comment provided by engineer. */ +"Auto-accept" = "Hyväksy automaattisesti"; + +/* No comment provided by engineer. */ +"Auto-accept contact requests" = "Hyväksy yhteydenottopyynnöt automaattisesti"; + +/* No comment provided by engineer. */ +"Auto-accept images" = "Hyväksy kuvat automaattisesti"; + +/* No comment provided by engineer. */ +"Back" = "Takaisin"; + +/* integrity error chat item */ +"bad message hash" = "virheellinen viestin tarkiste"; + +/* No comment provided by engineer. */ +"Bad message hash" = "Virheellinen viestin tarkiste"; + +/* integrity error chat item */ +"bad message ID" = "virheellinen viestin tunniste"; + +/* No comment provided by engineer. */ +"Bad message ID" = "Virheellinen viestin tunniste"; + +/* No comment provided by engineer. */ +"Better messages" = "Parempia viestejä"; + +/* No comment provided by engineer. */ +"bold" = "lihavoitu"; + +/* No comment provided by engineer. */ +"Both you and your contact can add message reactions." = "Sekä sinä että kontaktisi voivat käyttää viestireaktioita."; + +/* No comment provided by engineer. */ +"Both you and your contact can irreversibly delete sent messages." = "Sekä sinä että kontaktisi voitte peruuttamattomasti poistaa lähetetyt viestit."; + +/* No comment provided by engineer. */ +"Both you and your contact can make calls." = "Sekä sinä että kontaktisi voitte soittaa puheluita."; + +/* No comment provided by engineer. */ +"Both you and your contact can send disappearing messages." = "Sekä sinä että kontaktisi voitte lähettää katoavia viestejä."; + +/* No comment provided by engineer. */ +"Both you and your contact can send voice messages." = "Sekä sinä että kontaktisi voitte lähettää ääniviestejä."; + +/* No comment provided by engineer. */ +"By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Chat-profiilin mukaan (oletus) tai [yhteyden mukaan](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; + +/* No comment provided by engineer. */ +"Call already ended!" = "Puhelu on jo päättynyt!"; + +/* call status */ +"call error" = "soittovirhe"; + +/* call status */ +"call in progress" = "puhelu käynnissä"; + +/* call status */ +"calling…" = "soittaa…"; + +/* No comment provided by engineer. */ +"Calls" = "Puhelut"; + +/* No comment provided by engineer. */ +"Can't delete user profile!" = "Käyttäjäprofiilia ei voi poistaa!"; + +/* No comment provided by engineer. */ +"Can't invite contact!" = "Kontaktia ei voi kutsua!"; + +/* No comment provided by engineer. */ +"Can't invite contacts!" = "Kontakteja ei voi kutsua!"; + +/* No comment provided by engineer. */ +"Cancel" = "Peruuta"; + +/* feature offered item */ +"cancelled %@" = "peruutettu %@"; + +/* No comment provided by engineer. */ +"Cannot access keychain to save database password" = "Ei pääsyä avainnippuun tietokannan salasanan tallentamiseksi"; + +/* No comment provided by engineer. */ +"Cannot receive file" = "Tiedostoa ei voi vastaanottaa"; + +/* No comment provided by engineer. */ +"Change" = "Muuta"; + +/* No comment provided by engineer. */ +"Change database passphrase?" = "Muutetaanko tietokannan tunnuslause?"; + +/* authentication reason */ +"Change lock mode" = "Vaihda lukitustilaa"; + +/* No comment provided by engineer. */ +"Change member role?" = "Vaihda jäsenroolia?"; + +/* authentication reason */ +"Change passcode" = "Vaihda pääsykoodi"; + +/* No comment provided by engineer. */ +"Change receiving address" = "Vaihda vastaanotto-osoitetta"; + +/* No comment provided by engineer. */ +"Change receiving address?" = "Vaihda vastaanotto-osoite?"; + +/* No comment provided by engineer. */ +"Change role" = "Vaihda rooli"; + +/* authentication reason */ +"Change self-destruct mode" = "Vaihda itsetuhotilaa"; + +/* authentication reason + set passcode view */ +"Change self-destruct passcode" = "Vaihda itsetuhoutuva pääsykoodi"; + +/* chat item text */ +"changed address for you" = "muuttunut osoite sinulle"; + +/* rcv group event chat item */ +"changed role of %@ to %@" = "%1$@:n roolin muuttui %2$@:ksi"; + +/* rcv group event chat item */ +"changed your role to %@" = "roolisi muuttui %@:ksi"; + +/* chat item text */ +"changing address for %@…" = "osoitteen muuttaminen %@:lle…"; + +/* chat item text */ +"changing address…" = "muuttamassa osoitetta…"; + +/* No comment provided by engineer. */ +"Chat archive" = "Chat-arkisto"; + +/* No comment provided by engineer. */ +"Chat console" = "Chat-konsoli"; + +/* No comment provided by engineer. */ +"Chat database" = "Chat-tietokanta"; + +/* No comment provided by engineer. */ +"Chat database deleted" = "Chat-tietokanta poistettu"; + +/* No comment provided by engineer. */ +"Chat database imported" = "Chat-tietokanta tuotu"; + +/* No comment provided by engineer. */ +"Chat is running" = "Chat on käynnissä"; + +/* No comment provided by engineer. */ +"Chat is stopped" = "Chat on pysäytetty"; + +/* No comment provided by engineer. */ +"Chat preferences" = "Chat-asetukset"; + +/* No comment provided by engineer. */ +"Chats" = "Keskustelut"; + +/* No comment provided by engineer. */ +"Check server address and try again." = "Tarkista palvelimen osoite ja yritä uudelleen."; + +/* No comment provided by engineer. */ +"Chinese and Spanish interface" = "Kiinalainen ja espanjalainen käyttöliittymä"; + +/* No comment provided by engineer. */ +"Choose file" = "Valitse tiedosto"; + +/* No comment provided by engineer. */ +"Choose from library" = "Valitse kirjastosta"; + +/* No comment provided by engineer. */ +"Clear" = "Tyhjennä"; + +/* No comment provided by engineer. */ +"Clear conversation" = "Tyhjennä keskustelu"; + +/* No comment provided by engineer. */ +"Clear conversation?" = "Tyhjennä keskustelu?"; + +/* No comment provided by engineer. */ +"Clear verification" = "Tyhjennä vahvistus"; + +/* No comment provided by engineer. */ +"colored" = "värillinen"; + +/* No comment provided by engineer. */ +"Colors" = "Värit"; + +/* server test step */ +"Compare file" = "Vertaa tiedostoa"; + +/* No comment provided by engineer. */ +"Compare security codes with your contacts." = "Vertaa turvakoodeja kontaktiesi kanssa."; + +/* No comment provided by engineer. */ +"complete" = "valmis"; + +/* No comment provided by engineer. */ +"Configure ICE servers" = "Määritä ICE-palvelimet"; + +/* No comment provided by engineer. */ +"Confirm" = "Vahvista"; + +/* No comment provided by engineer. */ +"Confirm database upgrades" = "Vahvista tietokannan päivitykset"; + +/* No comment provided by engineer. */ +"Confirm new passphrase…" = "Vahvista uusi tunnuslause…"; + +/* No comment provided by engineer. */ +"Confirm Passcode" = "Vahvista pääsykoodi"; + +/* No comment provided by engineer. */ +"Confirm password" = "Vahvista salasana"; + +/* server test step */ +"Connect" = "Yhdistä"; + +/* No comment provided by engineer. */ +"Connect directly" = "Yhdistä suoraan"; + +/* No comment provided by engineer. */ +"Connect incognito" = "Yhdistä Incognito"; + +/* No comment provided by engineer. */ +"connect to SimpleX Chat developers." = "ole yhteydessä SimpleX Chat -kehittäjiin."; + +/* No comment provided by engineer. */ +"Connect via contact link" = "Yhdistä kontaktilinkillä"; + +/* No comment provided by engineer. */ +"Connect via group link?" = "Yhdistetäänkö ryhmälinkin kautta?"; + +/* No comment provided by engineer. */ +"Connect via link" = "Yhdistä linkin kautta"; + +/* No comment provided by engineer. */ +"Connect via link / QR code" = "Yhdistä linkillä / QR-koodilla"; + +/* No comment provided by engineer. */ +"Connect via one-time link" = "Yhdistä kertalinkillä"; + +/* No comment provided by engineer. */ +"connected" = "yhdistetty"; + +/* No comment provided by engineer. */ +"connecting" = "yhdistää"; + +/* No comment provided by engineer. */ +"connecting (accepted)" = "yhdistäminen (hyväksytty)"; + +/* No comment provided by engineer. */ +"connecting (announced)" = "yhdistäminen (ilmoitettu)"; + +/* No comment provided by engineer. */ +"connecting (introduced)" = "yhdistäminen (esitelty)"; + +/* No comment provided by engineer. */ +"connecting (introduction invitation)" = "yhdistäminen (esittelykutsu)"; + +/* call status */ +"connecting call" = "yhdistää puhelun…"; + +/* No comment provided by engineer. */ +"Connecting server…" = "Yhteyden muodostaminen palvelimeen…"; + +/* No comment provided by engineer. */ +"Connecting server… (error: %@)" = "Yhteyden muodostaminen palvelimeen... (virhe: %@)"; + +/* chat list item title */ +"connecting…" = "yhdistää…"; + +/* No comment provided by engineer. */ +"Connection" = "Yhteys"; + +/* No comment provided by engineer. */ +"Connection error" = "Yhteysvirhe"; + +/* No comment provided by engineer. */ +"Connection error (AUTH)" = "Yhteysvirhe (AUTH)"; + +/* chat list item title (it should not be shown */ +"connection established" = "yhteys luotu"; + +/* No comment provided by engineer. */ +"Connection request sent!" = "Yhteyspyyntö lähetetty!"; + +/* No comment provided by engineer. */ +"Connection timeout" = "Yhteyden aikakatkaisu"; + +/* connection information */ +"connection:%@" = "yhteys:%@"; + +/* No comment provided by engineer. */ +"Contact allows" = "Kontakti sallii"; + +/* No comment provided by engineer. */ +"Contact already exists" = "Kontakti on jo olemassa"; + +/* No comment provided by engineer. */ +"Contact and all messages will be deleted - this cannot be undone!" = "Kontakti ja kaikki viestit poistetaan - tätä ei voi perua!"; + +/* No comment provided by engineer. */ +"contact has e2e encryption" = "kontaktilla on e2e-salaus"; + +/* No comment provided by engineer. */ +"contact has no e2e encryption" = "kontaktilla ei ole e2e-salausta"; + +/* notification */ +"Contact hidden:" = "Kontakti piilotettu:"; + +/* notification */ +"Contact is connected" = "Kontakti on yhdistetty"; + +/* No comment provided by engineer. */ +"Contact is not connected yet!" = "Kontaktia ei ole vielä yhdistetty!"; + +/* No comment provided by engineer. */ +"Contact name" = "Kontaktin nimi"; + +/* No comment provided by engineer. */ +"Contact preferences" = "Kontaktin asetukset"; + +/* No comment provided by engineer. */ +"Contacts" = "Kontaktit"; + +/* No comment provided by engineer. */ +"Contacts can mark messages for deletion; you will be able to view them." = "Kontaktit voivat merkitä viestit poistettaviksi; voit katsella niitä."; + +/* No comment provided by engineer. */ +"Continue" = "Jatka"; + +/* chat item action */ +"Copy" = "Kopioi"; + +/* No comment provided by engineer. */ +"Core version: v%@" = "Ydinversio: v%@"; + +/* No comment provided by engineer. */ +"Create" = "Luo"; + +/* No comment provided by engineer. */ +"Create an address to let people connect with you." = "Luo osoite, jolla ihmiset voivat ottaa sinuun yhteyttä."; + +/* server test step */ +"Create file" = "Luo tiedosto"; + +/* No comment provided by engineer. */ +"Create group link" = "Luo ryhmälinkki"; + +/* No comment provided by engineer. */ +"Create link" = "Luo linkki"; + +/* No comment provided by engineer. */ +"Create one-time invitation link" = "Luo kertakutsulinkki"; + +/* server test step */ +"Create queue" = "Luo jono"; + +/* No comment provided by engineer. */ +"Create secret group" = "Luo salainen ryhmä"; + +/* No comment provided by engineer. */ +"Create SimpleX address" = "Luo SimpleX-osoite"; + +/* No comment provided by engineer. */ +"Create your profile" = "Luo profiilisi"; + +/* No comment provided by engineer. */ +"Created on %@" = "Luotu %@"; + +/* No comment provided by engineer. */ +"creator" = "luoja"; + +/* No comment provided by engineer. */ +"Current Passcode" = "Nykyinen pääsykoodi"; + +/* No comment provided by engineer. */ +"Current passphrase…" = "Nykyinen tunnuslause…"; + +/* No comment provided by engineer. */ +"Currently maximum supported file size is %@." = "Nykyinen tuettu enimmäistiedostokoko on %@."; + +/* dropdown time picker choice */ +"custom" = "mukautettu"; + +/* No comment provided by engineer. */ +"Custom time" = "Mukautettu aika"; + +/* No comment provided by engineer. */ +"Dark" = "Tumma"; + +/* No comment provided by engineer. */ +"Database downgrade" = "Tietokannan alentaminen"; + +/* No comment provided by engineer. */ +"Database encrypted!" = "Tietokanta salattu!"; + +/* No comment provided by engineer. */ +"Database encryption passphrase will be updated and stored in the keychain.\n" = "Tietokannan salaustunnuslause päivitetään ja tallennetaan avainnippuun.\n"; + +/* No comment provided by engineer. */ +"Database encryption passphrase will be updated.\n" = "Tietokannan salauksen tunnuslause päivitetään.\n"; + +/* No comment provided by engineer. */ +"Database error" = "Tietokantavirhe"; + +/* No comment provided by engineer. */ +"Database ID" = "Tietokannan tunnus"; + +/* copied message info */ +"Database ID: %d" = "Tietokannan tunnus: %d"; + +/* No comment provided by engineer. */ +"Database IDs and Transport isolation option." = "Tietokantatunnukset ja kuljetuseristysvaihtoehto."; + +/* No comment provided by engineer. */ +"Database is encrypted using a random passphrase, you can change it." = "Tietokanta on salattu satunnaisella tunnuslauseella, voit muuttaa sitä."; + +/* No comment provided by engineer. */ +"Database is encrypted using a random passphrase. Please change it before exporting." = "Tietokanta on salattu satunnaisella tunnuslauseella. Vaihda se ennen vientiä."; + +/* No comment provided by engineer. */ +"Database passphrase" = "Tietokannan tunnuslause"; + +/* No comment provided by engineer. */ +"Database passphrase & export" = "Tietokannan tunnuslause ja vienti"; + +/* No comment provided by engineer. */ +"Database passphrase is different from saved in the keychain." = "Tietokannan tunnuslause eroaa avainnippuun tallennetusta."; + +/* No comment provided by engineer. */ +"Database passphrase is required to open chat." = "Keskustelun avaamiseen tarvitaan tietokannan tunnuslause."; + +/* No comment provided by engineer. */ +"Database upgrade" = "Tietokannan päivitys"; + +/* No comment provided by engineer. */ +"database version is newer than the app, but no down migration for: %@" = "tietokantaversio on uudempi kuin sovellus, mutta ei alaspäin siirtymistä varten: %@"; + +/* No comment provided by engineer. */ +"Database will be encrypted and the passphrase stored in the keychain.\n" = "Tietokanta salataan ja tunnuslause tallennetaan avainnippuun.\n"; + +/* No comment provided by engineer. */ +"Database will be encrypted.\n" = "Tietokanta salataan.\n"; + +/* No comment provided by engineer. */ +"Database will be migrated when the app restarts" = "Tietokanta siirretään, kun sovellus käynnistyy uudelleen"; + +/* time unit */ +"days" = "päivää"; + +/* No comment provided by engineer. */ +"Decentralized" = "Hajautettu"; + +/* message decrypt error item */ +"Decryption error" = "Salauksen purkuvirhe"; + +/* pref value */ +"default (%@)" = "oletusarvo (%@)"; + +/* No comment provided by engineer. */ +"default (no)" = "oletusarvo (ei)"; + +/* No comment provided by engineer. */ +"default (yes)" = "oletusarvo (kyllä)"; + +/* chat item action */ +"Delete" = "Poista"; + +/* No comment provided by engineer. */ +"Delete address" = "Poista osoite"; + +/* No comment provided by engineer. */ +"Delete address?" = "Poista osoite?"; + +/* No comment provided by engineer. */ +"Delete after" = "Poista jälkeen"; + +/* No comment provided by engineer. */ +"Delete all files" = "Poista kaikki tiedostot"; + +/* No comment provided by engineer. */ +"Delete archive" = "Poista arkisto"; + +/* No comment provided by engineer. */ +"Delete chat archive?" = "Poista keskusteluarkisto?"; + +/* No comment provided by engineer. */ +"Delete chat profile" = "Poista keskusteluprofiili"; + +/* No comment provided by engineer. */ +"Delete chat profile?" = "Poista keskusteluprofiili?"; + +/* No comment provided by engineer. */ +"Delete connection" = "Poista yhteys"; + +/* No comment provided by engineer. */ +"Delete contact" = "Poista kontakti"; + +/* No comment provided by engineer. */ +"Delete Contact" = "Poista kontakti"; + +/* No comment provided by engineer. */ +"Delete contact?" = "Poista kontakti?"; + +/* No comment provided by engineer. */ +"Delete database" = "Poista tietokanta"; + +/* server test step */ +"Delete file" = "Poista tiedosto"; + +/* No comment provided by engineer. */ +"Delete files and media?" = "Poista tiedostot ja media?"; + +/* No comment provided by engineer. */ +"Delete files for all chat profiles" = "Poista tiedostot kaikista keskusteluprofiileista"; + +/* chat feature */ +"Delete for everyone" = "Poista kaikilta"; + +/* No comment provided by engineer. */ +"Delete for me" = "Poista minulta"; + +/* No comment provided by engineer. */ +"Delete group" = "Poista ryhmä"; + +/* No comment provided by engineer. */ +"Delete group?" = "Poista ryhmä?"; + +/* No comment provided by engineer. */ +"Delete invitation" = "Poista kutsu"; + +/* No comment provided by engineer. */ +"Delete link" = "Poista linkki"; + +/* No comment provided by engineer. */ +"Delete link?" = "Poista linkki?"; + +/* No comment provided by engineer. */ +"Delete member message?" = "Poista jäsenviesti?"; + +/* No comment provided by engineer. */ +"Delete message?" = "Poista viesti?"; + +/* No comment provided by engineer. */ +"Delete messages" = "Poista viestit"; + +/* No comment provided by engineer. */ +"Delete messages after" = "Poista viestit tämän jälkeen"; + +/* No comment provided by engineer. */ +"Delete old database" = "Poista vanha tietokanta"; + +/* No comment provided by engineer. */ +"Delete old database?" = "Poista vanha tietokanta?"; + +/* No comment provided by engineer. */ +"Delete pending connection" = "Poista vireillä oleva yhteys"; + +/* No comment provided by engineer. */ +"Delete pending connection?" = "Poistetaanko odottava yhteys?"; + +/* No comment provided by engineer. */ +"Delete profile" = "Poista profiili"; + +/* server test step */ +"Delete queue" = "Poista jono"; + +/* No comment provided by engineer. */ +"Delete user profile?" = "Poista käyttäjäprofiili?"; + +/* deleted chat item */ +"deleted" = "poistettu"; + +/* No comment provided by engineer. */ +"Deleted at" = "Poistettu klo"; + +/* copied message info */ +"Deleted at: %@" = "Poistettu klo: %@"; + +/* rcv group event chat item */ +"deleted group" = "poistettu ryhmä"; + +/* No comment provided by engineer. */ +"Delivery" = "Toimitus"; + +/* No comment provided by engineer. */ +"Delivery receipts are disabled!" = "Toimituskuittaukset poissa käytöstä!"; + +/* No comment provided by engineer. */ +"Delivery receipts!" = "Toimituskuittaukset!"; + +/* No comment provided by engineer. */ +"Description" = "Kuvaus"; + +/* No comment provided by engineer. */ +"Develop" = "Kehitä"; + +/* No comment provided by engineer. */ +"Developer tools" = "Kehittäjätyökalut"; + +/* No comment provided by engineer. */ +"Device" = "Laite"; + +/* No comment provided by engineer. */ +"Device authentication is disabled. Turning off SimpleX Lock." = "Laitteen todennus on poistettu käytöstä. SimpleX Lock kytketään pois päältä."; + +/* No comment provided by engineer. */ +"Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication." = "Laitteen todennus ei ole käytössä. Voit ottaa SimpleX Lockin käyttöön Asetuksista, kun olet ottanut laitteen todennuksen käyttöön."; + +/* No comment provided by engineer. */ +"different migration in the app/database: %@ / %@" = "eri siirtyminen sovelluksessa/tietokannassa: %@ / %@"; + +/* No comment provided by engineer. */ +"Different names, avatars and transport isolation." = "Eri nimet, avatarit ja kuljetuseristys."; + +/* connection level description */ +"direct" = "suora"; + +/* chat feature */ +"Direct messages" = "Yksityisviestit"; + +/* No comment provided by engineer. */ +"Direct messages between members are prohibited in this group." = "Yksityisviestit jäsenten välillä ovat kiellettyjä tässä ryhmässä."; + +/* No comment provided by engineer. */ +"Disable (keep overrides)" = "Poista käytöstä (pidä ohitukset)"; + +/* No comment provided by engineer. */ +"Disable for all" = "Poista käytöstä kaikilta"; + +/* authentication reason */ +"Disable SimpleX Lock" = "Poista SimpleX Lock käytöstä"; + +/* No comment provided by engineer. */ +"disabled" = "ei käytössä"; + +/* No comment provided by engineer. */ +"Disappearing message" = "Tuhoutuva viesti"; + +/* chat feature */ +"Disappearing messages" = "Tuhoutuvat viestit"; + +/* No comment provided by engineer. */ +"Disappearing messages are prohibited in this chat." = "Katoavat viestit ovat kiellettyjä tässä keskustelussa."; + +/* No comment provided by engineer. */ +"Disappearing messages are prohibited in this group." = "Katoavat viestit ovat kiellettyjä tässä ryhmässä."; + +/* No comment provided by engineer. */ +"Disappears at" = "Katoaa klo"; + +/* copied message info */ +"Disappears at: %@" = "Katoaa klo: %@"; + +/* server test step */ +"Disconnect" = "Katkaise"; + +/* No comment provided by engineer. */ +"Display name" = "Näyttönimi"; + +/* No comment provided by engineer. */ +"Display name:" = "Näyttönimi:"; + +/* No comment provided by engineer. */ +"Do it later" = "Tee myöhemmin"; + +/* No comment provided by engineer. */ +"Do NOT use SimpleX for emergency calls." = "Älä käytä SimpleX-sovellusta hätäpuheluihin."; + +/* No comment provided by engineer. */ +"Don't create address" = "Älä luo osoitetta"; + +/* No comment provided by engineer. */ +"Don't enable" = "Älä salli"; + +/* No comment provided by engineer. */ +"Don't show again" = "Älä näytä uudelleen"; + +/* No comment provided by engineer. */ +"Downgrade and open chat" = "Alenna ja avaa keskustelu"; + +/* server test step */ +"Download file" = "Lataa tiedosto"; + +/* No comment provided by engineer. */ +"Duplicate display name!" = "Päällekkäinen näyttönimi!"; + +/* integrity error chat item */ +"duplicate message" = "päällekkäinen viesti"; + +/* No comment provided by engineer. */ +"Duration" = "Kesto"; + +/* No comment provided by engineer. */ +"e2e encrypted" = "e2e-salattu"; + +/* chat item action */ +"Edit" = "Muokkaa"; + +/* No comment provided by engineer. */ +"Edit group profile" = "Muokkaa ryhmäprofiilia"; + +/* No comment provided by engineer. */ +"Enable" = "Salli"; + +/* No comment provided by engineer. */ +"Enable (keep overrides)" = "Salli (pidä ohitukset)"; + +/* No comment provided by engineer. */ +"Enable automatic message deletion?" = "Ota automaattinen viestien poisto käyttöön?"; + +/* No comment provided by engineer. */ +"Enable for all" = "Salli kaikille"; + +/* No comment provided by engineer. */ +"Enable instant notifications?" = "Salli välittömät ilmoitukset?"; + +/* No comment provided by engineer. */ +"Enable lock" = "Ota lukitus käyttöön"; + +/* No comment provided by engineer. */ +"Enable notifications" = "Salli ilmoitukset"; + +/* No comment provided by engineer. */ +"Enable periodic notifications?" = "Salli säännölliset ilmoitukset?"; + +/* No comment provided by engineer. */ +"Enable self-destruct" = "Ota itsetuho käyttöön"; + +/* set passcode view */ +"Enable self-destruct passcode" = "Ota itsetuhoava pääsykoodi käyttöön"; + +/* authentication reason */ +"Enable SimpleX Lock" = "Ota SimpleX Lock käyttöön"; + +/* No comment provided by engineer. */ +"Enable TCP keep-alive" = "Ota TCP-säilytys käyttöön"; + +/* enabled status */ +"enabled" = "käytössä"; + +/* enabled status */ +"enabled for contact" = "käytössä kontaktille"; + +/* enabled status */ +"enabled for you" = "käytössä sinulle"; + +/* No comment provided by engineer. */ +"Encrypt" = "Salaa"; + +/* No comment provided by engineer. */ +"Encrypt database?" = "Salaa tietokanta?"; + +/* No comment provided by engineer. */ +"Encrypted database" = "Salattu tietokanta"; + +/* notification */ +"Encrypted message or another event" = "Salattu viesti tai muu tapahtuma"; + +/* notification */ +"Encrypted message: database error" = "Salattu viesti: tietokantavirhe"; + +/* notification */ +"Encrypted message: database migration error" = "Salattu viesti: tietokannan siirtovirhe"; + +/* notification */ +"Encrypted message: keychain error" = "Salattu viesti: avainnipun virhe"; + +/* notification */ +"Encrypted message: no passphrase" = "Salattu viesti: ei tunnuslausetta"; + +/* notification */ +"Encrypted message: unexpected error" = "Salattu viesti: odottamaton virhe"; + +/* chat item text */ +"encryption agreed" = "salaus sovittu"; + +/* chat item text */ +"encryption agreed for %@" = "salaus sovittu %@:lle"; + +/* chat item text */ +"encryption ok" = "salaus ok"; + +/* chat item text */ +"encryption ok for %@" = "salaus ok %@:lle"; + +/* chat item text */ +"encryption re-negotiation allowed" = "salauksen uudelleenneuvottelu sallittu"; + +/* chat item text */ +"encryption re-negotiation allowed for %@" = "salauksen uudelleenneuvottelu sallittu %@:lle"; + +/* chat item text */ +"encryption re-negotiation required" = "tarvitaan salauksen uudelleenneuvottelu"; + +/* chat item text */ +"encryption re-negotiation required for %@" = "tarvitaan salauksen uudelleenneuvottelu %@:lle"; + +/* No comment provided by engineer. */ +"ended" = "päättyi"; + +/* call status */ +"ended call %@" = "puhelu päättyi %@:lle"; + +/* No comment provided by engineer. */ +"Enter correct passphrase." = "Anna oikea tunnuslause."; + +/* No comment provided by engineer. */ +"Enter Passcode" = "Syötä pääsykoodi"; + +/* No comment provided by engineer. */ +"Enter passphrase…" = "Syötä tunnuslause…"; + +/* No comment provided by engineer. */ +"Enter password above to show!" = "Kirjoita yllä oleva salasana näyttääksesi!"; + +/* No comment provided by engineer. */ +"Enter server manually" = "Syötä palvelin manuaalisesti"; + +/* placeholder */ +"Enter welcome message…" = "Kirjoita tervetuloviesti…"; + +/* placeholder */ +"Enter welcome message… (optional)" = "Kirjoita tervetuloviesti... (valinnainen)"; + +/* No comment provided by engineer. */ +"error" = "virhe"; + +/* No comment provided by engineer. */ +"Error" = "Virhe"; + +/* No comment provided by engineer. */ +"Error aborting address change" = "Virhe osoitteenmuutoksen keskeytyksessä"; + +/* No comment provided by engineer. */ +"Error accepting contact request" = "Virhe kontaktipyynnön hyväksymisessä"; + +/* No comment provided by engineer. */ +"Error accessing database file" = "Virhe tietokantatiedoston käyttämisessä"; + +/* No comment provided by engineer. */ +"Error adding member(s)" = "Virhe lisättäessä jäseniä"; + +/* No comment provided by engineer. */ +"Error changing address" = "Virhe osoitteenvaihdossa"; + +/* No comment provided by engineer. */ +"Error changing role" = "Virhe roolin vaihdossa"; + +/* No comment provided by engineer. */ +"Error changing setting" = "Virhe asetuksen muuttamisessa"; + +/* No comment provided by engineer. */ +"Error creating address" = "Virhe osoitteen luomisessa"; + +/* No comment provided by engineer. */ +"Error creating group" = "Virhe ryhmän luomisessa"; + +/* No comment provided by engineer. */ +"Error creating group link" = "Virhe ryhmälinkin luomisessa"; + +/* No comment provided by engineer. */ +"Error creating profile!" = "Virhe profiilin luomisessa!"; + +/* No comment provided by engineer. */ +"Error deleting chat database" = "Virhe keskustelujen tietokannan poistamisessa"; + +/* No comment provided by engineer. */ +"Error deleting chat!" = "Virhe keskutelun poistamisessa!"; + +/* No comment provided by engineer. */ +"Error deleting connection" = "Virhe yhteyden poistamisessa"; + +/* No comment provided by engineer. */ +"Error deleting contact" = "Virhe kontaktin poistamisessa"; + +/* No comment provided by engineer. */ +"Error deleting database" = "Virhe tietokannan poistamisessa"; + +/* No comment provided by engineer. */ +"Error deleting old database" = "Virhe vanhan tietokannan poistamisessa"; + +/* No comment provided by engineer. */ +"Error deleting token" = "Virhe tokenin poistamisessa"; + +/* No comment provided by engineer. */ +"Error deleting user profile" = "Virhe käyttäjäprofiilin poistamisessa"; + +/* No comment provided by engineer. */ +"Error enabling delivery receipts!" = "Virhe toimituskuittauksien sallimisessa!"; + +/* No comment provided by engineer. */ +"Error enabling notifications" = "Virhe ilmoitusten käyttöönotossa"; + +/* No comment provided by engineer. */ +"Error encrypting database" = "Virhe tietokannan salauksessa"; + +/* No comment provided by engineer. */ +"Error exporting chat database" = "Virhe vietäessä keskustelujen tietokantaa"; + +/* No comment provided by engineer. */ +"Error importing chat database" = "Virhe keskustelujen tietokannan tuonnissa"; + +/* No comment provided by engineer. */ +"Error joining group" = "Virhe ryhmään liittymisessä"; + +/* No comment provided by engineer. */ +"Error loading %@ servers" = "Virhe %@-palvelimien lataamisessa"; + +/* No comment provided by engineer. */ +"Error receiving file" = "Virhe tiedoston vastaanottamisessa"; + +/* No comment provided by engineer. */ +"Error removing member" = "Virhe poistettaessa jäsentä"; + +/* No comment provided by engineer. */ +"Error saving %@ servers" = "Virhe %@ palvelimien tallentamisessa"; + +/* No comment provided by engineer. */ +"Error saving group profile" = "Virhe ryhmäprofiilin tallentamisessa"; + +/* No comment provided by engineer. */ +"Error saving ICE servers" = "Virhe ICE-palvelimien tallentamisessa"; + +/* No comment provided by engineer. */ +"Error saving passcode" = "Virhe pääsykoodin tallentamisessa"; + +/* No comment provided by engineer. */ +"Error saving passphrase to keychain" = "Virhe tunnuslauseen tallentamisessa avainnippuun"; + +/* No comment provided by engineer. */ +"Error saving user password" = "Virhe käyttäjän salasanan tallentamisessa"; + +/* No comment provided by engineer. */ +"Error sending email" = "Virhe sähköpostin lähettämisessä"; + +/* No comment provided by engineer. */ +"Error sending message" = "Virhe viestin lähettämisessä"; + +/* No comment provided by engineer. */ +"Error setting delivery receipts!" = "Virhe toimituskuittauksien asettamisessa!"; + +/* No comment provided by engineer. */ +"Error starting chat" = "Virhe käynnistettäessä keskustelua"; + +/* No comment provided by engineer. */ +"Error stopping chat" = "Virhe keskustelun lopettamisessa"; + +/* No comment provided by engineer. */ +"Error switching profile!" = "Virhe profiilin vaihdossa!"; + +/* No comment provided by engineer. */ +"Error synchronizing connection" = "Virhe yhteyden synkronoinnissa"; + +/* No comment provided by engineer. */ +"Error updating group link" = "Virhe ryhmälinkin päivittämisessä"; + +/* No comment provided by engineer. */ +"Error updating message" = "Virhe viestin päivityksessä"; + +/* No comment provided by engineer. */ +"Error updating settings" = "Virhe asetusten päivittämisessä"; + +/* No comment provided by engineer. */ +"Error updating user privacy" = "Virhe päivitettäessä käyttäjän tietosuojaa"; + +/* No comment provided by engineer. */ +"Error: " = "Virhe: "; + +/* No comment provided by engineer. */ +"Error: %@" = "Virhe: %@"; + +/* No comment provided by engineer. */ +"Error: no database file" = "Virhe: ei tietokantatiedostoa"; + +/* No comment provided by engineer. */ +"Error: URL is invalid" = "Virhe: URL on virheellinen"; + +/* No comment provided by engineer. */ +"Even when disabled in the conversation." = "Jopa kun ei käytössä keskustelussa."; + +/* No comment provided by engineer. */ +"event happened" = "tapahtuma tapahtui"; + +/* No comment provided by engineer. */ +"Exit without saving" = "Poistu tallentamatta"; + +/* No comment provided by engineer. */ +"Export database" = "Vie tietokanta"; + +/* No comment provided by engineer. */ +"Export error:" = "Vientivirhe:"; + +/* No comment provided by engineer. */ +"Exported database archive." = "Viety tietokanta-arkisto."; + +/* No comment provided by engineer. */ +"Exporting database archive…" = "Tietokanta-arkiston vienti…"; + +/* No comment provided by engineer. */ +"Failed to remove passphrase" = "Tunnuslauseen poisto epäonnistui"; + +/* No comment provided by engineer. */ +"Fast and no wait until the sender is online!" = "Nopea ja ei odotusta, kunnes lähettäjä on online-tilassa!"; + +/* No comment provided by engineer. */ +"Favorite" = "Suosikki"; + +/* No comment provided by engineer. */ +"File will be deleted from servers." = "Tiedosto poistetaan palvelimilta."; + +/* No comment provided by engineer. */ +"File will be received when your contact completes uploading it." = "Tiedosto vastaanotetaan, kun kontaktisi on ladannut sen."; + +/* No comment provided by engineer. */ +"File will be received when your contact is online, please wait or check later!" = "Tiedosto vastaanotetaan, kun kontakti on online-tilassa, odota tai tarkista myöhemmin!"; + +/* No comment provided by engineer. */ +"File: %@" = "Tiedosto: %@"; + +/* No comment provided by engineer. */ +"Files & media" = "Tiedostot & media"; + +/* chat feature */ +"Files and media" = "Tiedostot ja media"; + +/* No comment provided by engineer. */ +"Files and media are prohibited in this group." = "Tiedostot ja media ovat tässä ryhmässä kiellettyjä."; + +/* No comment provided by engineer. */ +"Files and media prohibited!" = "Tiedostot ja media kielletty!"; + +/* No comment provided by engineer. */ +"Filter unread and favorite chats." = "Suodata lukemattomia- ja suosikkikeskusteluja."; + +/* No comment provided by engineer. */ +"Finally, we have them! 🚀" = "Vihdoinkin meillä! 🚀"; + +/* No comment provided by engineer. */ +"Find chats faster" = "Löydä keskustelut nopeammin"; + +/* No comment provided by engineer. */ +"Fix" = "Korjaa"; + +/* No comment provided by engineer. */ +"Fix connection" = "Korjaa yhteys"; + +/* No comment provided by engineer. */ +"Fix connection?" = "Korjaa yhteys?"; + +/* No comment provided by engineer. */ +"Fix encryption after restoring backups." = "Korjaa salaus varmuuskopioiden palauttamisen jälkeen."; + +/* No comment provided by engineer. */ +"Fix not supported by contact" = "Kontakti ei tue korjausta"; + +/* No comment provided by engineer. */ +"Fix not supported by group member" = "Ryhmän jäsen ei tue korjausta"; + +/* No comment provided by engineer. */ +"For console" = "Konsoliin"; + +/* No comment provided by engineer. */ +"French interface" = "Ranskalainen käyttöliittymä"; + +/* No comment provided by engineer. */ +"Full link" = "Koko linkki"; + +/* No comment provided by engineer. */ +"Full name (optional)" = "Koko nimi (valinnainen)"; + +/* No comment provided by engineer. */ +"Full name:" = "Koko nimi:"; + +/* No comment provided by engineer. */ +"Fully re-implemented - work in background!" = "Täysin uudistettu - toimii taustalla!"; + +/* No comment provided by engineer. */ +"Further reduced battery usage" = "Entistä pienempi akun käyttö"; + +/* No comment provided by engineer. */ +"GIFs and stickers" = "GIFit ja tarrat"; + +/* No comment provided by engineer. */ +"Group" = "Ryhmä"; + +/* No comment provided by engineer. */ +"group deleted" = "ryhmä poistettu"; + +/* No comment provided by engineer. */ +"Group display name" = "Ryhmän näyttönimi"; + +/* No comment provided by engineer. */ +"Group full name (optional)" = "Ryhmän näyttönimi (valinnainen)"; + +/* No comment provided by engineer. */ +"Group image" = "Ryhmäkuva"; + +/* No comment provided by engineer. */ +"Group invitation" = "Ryhmän kutsu"; + +/* No comment provided by engineer. */ +"Group invitation expired" = "Vanhentunut ryhmäkutsu"; + +/* No comment provided by engineer. */ +"Group invitation is no longer valid, it was removed by sender." = "Ryhmäkutsu ei ole enää voimassa, lähettäjä poisti sen."; + +/* No comment provided by engineer. */ +"Group link" = "Ryhmälinkki"; + +/* No comment provided by engineer. */ +"Group links" = "Ryhmälinkit"; + +/* No comment provided by engineer. */ +"Group members can add message reactions." = "Ryhmän jäsenet voivat lisätä viestireaktioita."; + +/* No comment provided by engineer. */ +"Group members can irreversibly delete sent messages." = "Ryhmän jäsenet voivat poistaa lähetetyt viestit peruuttamattomasti."; + +/* No comment provided by engineer. */ +"Group members can send direct messages." = "Ryhmän jäsenet voivat lähettää suoraviestejä."; + +/* No comment provided by engineer. */ +"Group members can send disappearing messages." = "Ryhmän jäsenet voivat lähettää katoavia viestejä."; + +/* No comment provided by engineer. */ +"Group members can send files and media." = "Ryhmän jäsenet voivat lähettää tiedostoja ja mediaa."; + +/* No comment provided by engineer. */ +"Group members can send voice messages." = "Ryhmän jäsenet voivat lähettää ääniviestejä."; + +/* notification */ +"Group message:" = "Ryhmäviesti:"; + +/* No comment provided by engineer. */ +"Group moderation" = "Ryhmän moderointi"; + +/* No comment provided by engineer. */ +"Group preferences" = "Ryhmän asetukset"; + +/* No comment provided by engineer. */ +"Group profile" = "Ryhmäprofiili"; + +/* No comment provided by engineer. */ +"Group profile is stored on members' devices, not on the servers." = "Ryhmäprofiili tallennetaan jäsenten laitteille, ei palvelimille."; + +/* snd group event chat item */ +"group profile updated" = "ryhmäprofiili päivitetty"; + +/* No comment provided by engineer. */ +"Group welcome message" = "Ryhmän tervetuloviesti"; + +/* No comment provided by engineer. */ +"Group will be deleted for all members - this cannot be undone!" = "Ryhmä poistetaan kaikilta jäseniltä - tätä ei voi kumota!"; + +/* No comment provided by engineer. */ +"Group will be deleted for you - this cannot be undone!" = "Ryhmä poistetaan sinulta - tätä ei voi perua!"; + +/* No comment provided by engineer. */ +"Help" = "Apua"; + +/* No comment provided by engineer. */ +"Hidden" = "Piilotettu"; + +/* No comment provided by engineer. */ +"Hidden chat profiles" = "Piilotetut keskusteluprofiilit"; + +/* No comment provided by engineer. */ +"Hidden profile password" = "Piilotettu profiilin salasana"; + +/* chat item action */ +"Hide" = "Piilota"; + +/* No comment provided by engineer. */ +"Hide app screen in the recent apps." = "Piilota sovellusnäyttö viimeisimmissä sovelluksissa."; + +/* No comment provided by engineer. */ +"Hide profile" = "Piilota profiili"; + +/* No comment provided by engineer. */ +"Hide:" = "Piilota:"; + +/* No comment provided by engineer. */ +"History" = "Historia"; + +/* time unit */ +"hours" = "tuntia"; + +/* No comment provided by engineer. */ +"How it works" = "Kuinka se toimii"; + +/* No comment provided by engineer. */ +"How SimpleX works" = "Miten SimpleX toimii"; + +/* No comment provided by engineer. */ +"How to" = "Miten"; + +/* No comment provided by engineer. */ +"How to use it" = "Kuinka sitä käytetään"; + +/* No comment provided by engineer. */ +"How to use your servers" = "Miten käytät palvelimiasi"; + +/* No comment provided by engineer. */ +"ICE servers (one per line)" = "ICE-palvelimet (yksi per rivi)"; + +/* No comment provided by engineer. */ +"If you can't meet in person, show QR code in a video call, or share the link." = "Jos et voi tavata henkilökohtaisesti, näytä QR-koodi videopuhelussa tai jaa linkki."; + +/* No comment provided by engineer. */ +"If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link." = "Jos et voi tavata henkilökohtaisesti, voit **skannata QR-koodin videopuhelussa** tai kontaktisi voi jakaa kutsulinkin."; + +/* No comment provided by engineer. */ +"If you enter this passcode when opening the app, all app data will be irreversibly removed!" = "Jos syötät tämän pääsykoodin sovellusta avatessasi, kaikki sovelluksen tiedot poistetaan peruuttamattomasti!"; + +/* No comment provided by engineer. */ +"If you enter your self-destruct passcode while opening the app:" = "Jos syötät itsetuhoutuvan pääsykoodin sovellusta avattaessa:"; + +/* No comment provided by engineer. */ +"If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app)." = "Jos haluat käyttää keskustelua nyt, napauta **Tee se myöhemmin** alla (sinulle tarjotaan tietokannan siirtämistä, kun käynnistät sovelluksen uudelleen)."; + +/* No comment provided by engineer. */ +"Ignore" = "Sivuuta"; + +/* No comment provided by engineer. */ +"Image will be received when your contact completes uploading it." = "Kuva vastaanotetaan, kun kontaktisi on ladannut sen."; + +/* No comment provided by engineer. */ +"Image will be received when your contact is online, please wait or check later!" = "Kuva vastaanotetaan, kun kontaktisi on verkossa, odota tai tarkista myöhemmin!"; + +/* No comment provided by engineer. */ +"Immediately" = "Heti"; + +/* No comment provided by engineer. */ +"Immune to spam and abuse" = "Immuuni roskapostille ja väärinkäytöksille"; + +/* No comment provided by engineer. */ +"Import" = "Tuo"; + +/* No comment provided by engineer. */ +"Import chat database?" = "Tuo keskustelujen-tietokanta?"; + +/* No comment provided by engineer. */ +"Import database" = "Tuo tietokanta"; + +/* No comment provided by engineer. */ +"Improved privacy and security" = "Parannettu yksityisyys ja turvallisuus"; + +/* No comment provided by engineer. */ +"Improved server configuration" = "Parannettu palvelimen kokoonpano"; + +/* No comment provided by engineer. */ +"In reply to" = "Vastauksena"; + +/* No comment provided by engineer. */ +"Incognito" = "Incognito"; + +/* No comment provided by engineer. */ +"Incognito mode" = "Incognito-tila"; + +/* No comment provided by engineer. */ +"Incognito mode protects your privacy by using a new random profile for each contact." = "Incognito-tila suojaa yksityisyyttäsi käyttämällä uutta satunnaista profiilia jokaiselle kontaktille."; + +/* chat list item description */ +"incognito via contact address link" = "incognito kontaktilinkin kautta"; + +/* chat list item description */ +"incognito via group link" = "incognito ryhmälinkin kautta"; + +/* chat list item description */ +"incognito via one-time link" = "incognito kertalinkillä"; + +/* notification */ +"Incoming audio call" = "Saapuva äänipuhelu"; + +/* notification */ +"Incoming call" = "Saapuva puhelu"; + +/* notification */ +"Incoming video call" = "Saapuva videopuhelu"; + +/* No comment provided by engineer. */ +"Incompatible database version" = "Yhteensopimaton tietokantaversio"; + +/* PIN entry */ +"Incorrect passcode" = "Väärä pääsykoodi"; + +/* No comment provided by engineer. */ +"Incorrect security code!" = "Väärä turvakoodi!"; + +/* connection level description */ +"indirect (%d)" = "epäsuora (%d)"; + +/* chat item action */ +"Info" = "Tiedot"; + +/* No comment provided by engineer. */ +"Initial role" = "Alkuperäinen rooli"; + +/* No comment provided by engineer. */ +"Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "Asenna [SimpleX Chat terminaalille](https://github.com/simplex-chat/simplex-chat)"; + +/* No comment provided by engineer. */ +"Instant push notifications will be hidden!\n" = "Välittömät push-ilmoitukset ovat piilossa!\n"; + +/* No comment provided by engineer. */ +"Instantly" = "Heti"; + +/* No comment provided by engineer. */ +"Interface" = "Käyttöliittymä"; + +/* invalid chat data */ +"invalid chat" = "virheellinen keskustelu"; + +/* No comment provided by engineer. */ +"invalid chat data" = "virheelliset keskustelu-tiedot"; + +/* No comment provided by engineer. */ +"Invalid connection link" = "Virheellinen yhteyslinkki"; + +/* invalid chat item */ +"invalid data" = "virheelliset tiedot"; + +/* No comment provided by engineer. */ +"Invalid server address!" = "Virheellinen palvelinosoite!"; + +/* item status text */ +"Invalid status" = "Virheellinen tila"; + +/* No comment provided by engineer. */ +"Invitation expired!" = "Vanhentunut kutsu!"; + +/* group name */ +"invitation to group %@" = "kutsu ryhmään %@"; + +/* No comment provided by engineer. */ +"Invite friends" = "Kutsu ystäviä"; + +/* No comment provided by engineer. */ +"Invite members" = "Kutsu jäseniä"; + +/* No comment provided by engineer. */ +"Invite to group" = "Kutsu ryhmään"; + +/* No comment provided by engineer. */ +"invited" = "kutsuttu"; + +/* rcv group event chat item */ +"invited %@" = "kutsuttu %@"; + +/* chat list item title */ +"invited to connect" = "kutsuttu yhteydenpitoon"; + +/* rcv group event chat item */ +"invited via your group link" = "kutsuttu ryhmäsi linkin kautta"; + +/* No comment provided by engineer. */ +"iOS Keychain is used to securely store passphrase - it allows receiving push notifications." = "iOS-Avainnippua käytetään tunnuslauseen turvalliseen tallentamiseen - se mahdollistaa push-ilmoitusten vastaanottamisen."; + +/* No comment provided by engineer. */ +"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "iOS-Avainnippua käytetään tunnuslauseen turvalliseen tallentamiseen sen muuttamisen tai sovelluksen uudelleen käynnistämisen jälkeen - se mahdollistaa push-ilmoitusten vastaanottamisen."; + +/* No comment provided by engineer. */ +"Irreversible message deletion" = "Peruuttamaton viestin poisto"; + +/* No comment provided by engineer. */ +"Irreversible message deletion is prohibited in this chat." = "Viestien peruuttamaton poisto on kielletty tässä keskustelussa."; + +/* No comment provided by engineer. */ +"Irreversible message deletion is prohibited in this group." = "Viestien peruuttamaton poisto on kielletty tässä ryhmässä."; + +/* No comment provided by engineer. */ +"It allows having many anonymous connections without any shared data between them in a single chat profile." = "Se mahdollistaa useiden nimettömien yhteyksien muodostamisen yhdessä keskusteluprofiilissa ilman, että niiden välillä on jaettuja tietoja."; + +/* No comment provided by engineer. */ +"It can happen when you or your connection used the old database backup." = "Se voi tapahtua, kun sinä tai kontaktisi käytitte vanhaa varmuuskopiota tietokannasta."; + +/* No comment provided by engineer. */ +"It can happen when:\n1. The messages expired in the sending client after 2 days or on the server after 30 days.\n2. Message decryption failed, because you or your contact used old database backup.\n3. The connection was compromised." = "Se voi tapahtua, kun:\n1. Viestit vanhenivat lähettävässä päätelaitteessa kahden päivän päästä tai palvelimella 30 päivän kuluttua.\n2. Viestin salauksen purku epäonnistui, koska sinä tai kontaktisi käytitte vanhaa varmuuskopiota tietokannasta.\n3. Yhteys vaarantui."; + +/* No comment provided by engineer. */ +"It seems like you are already connected via this link. If it is not the case, there was an error (%@)." = "Näyttäisi, että olet jo yhteydessä tämän linkin kautta. Jos näin ei ole, tapahtui virhe (%@)."; + +/* No comment provided by engineer. */ +"Italian interface" = "Italialainen käyttöliittymä"; + +/* No comment provided by engineer. */ +"italic" = "kursivoitu"; + +/* No comment provided by engineer. */ +"Japanese interface" = "Japanilainen käyttöliittymä"; + +/* No comment provided by engineer. */ +"Join" = "Liity"; + +/* No comment provided by engineer. */ +"join as %@" = "Liity %@:nä"; + +/* No comment provided by engineer. */ +"Join group" = "Liity ryhmään"; + +/* No comment provided by engineer. */ +"Join incognito" = "Liity incognito-tilassa"; + +/* No comment provided by engineer. */ +"Joining group" = "Liittyy ryhmään"; + +/* No comment provided by engineer. */ +"Keep your connections" = "Pidä kontaktisi"; + +/* No comment provided by engineer. */ +"Keychain error" = "Avainnipun virhe"; + +/* No comment provided by engineer. */ +"KeyChain error" = "Avainnipun virhe"; + +/* No comment provided by engineer. */ +"Large file!" = "Suuri tiedosto!"; + +/* No comment provided by engineer. */ +"Learn more" = "Lue lisää"; + +/* No comment provided by engineer. */ +"Leave" = "Poistu"; + +/* No comment provided by engineer. */ +"Leave group" = "Poistu ryhmästä"; + +/* No comment provided by engineer. */ +"Leave group?" = "Poistu ryhmästä?"; + +/* rcv group event chat item */ +"left" = "poistunut"; + +/* email subject */ +"Let's talk in SimpleX Chat" = "Jutellaan SimpleX Chatissa"; + +/* No comment provided by engineer. */ +"Light" = "Vaalea"; + +/* No comment provided by engineer. */ +"Limitations" = "Rajoitukset"; + +/* No comment provided by engineer. */ +"LIVE" = "LIVE"; + +/* No comment provided by engineer. */ +"Live message!" = "Live-viesti!"; + +/* No comment provided by engineer. */ +"Live messages" = "Live-viestit"; + +/* No comment provided by engineer. */ +"Local name" = "Paikallinen nimi"; + +/* No comment provided by engineer. */ +"Local profile data only" = "Vain paikalliset profiilitiedot"; + +/* No comment provided by engineer. */ +"Lock after" = "Lukitse jälkeen"; + +/* No comment provided by engineer. */ +"Lock mode" = "Lukitustila"; + +/* No comment provided by engineer. */ +"Make a private connection" = "Luo yksityinen yhteys"; + +/* No comment provided by engineer. */ +"Make one message disappear" = "Hävitä yksi viesti"; + +/* No comment provided by engineer. */ +"Make profile private!" = "Tee profiilista yksityinen!"; + +/* No comment provided by engineer. */ +"Make sure %@ server addresses are in correct format, line separated and are not duplicated (%@)." = "Varmista, että %@-palvelinosoitteet ovat oikeassa muodossa, että ne on erotettu toisistaan riveittäin ja että ne eivät ole päällekkäisiä (%@)."; + +/* No comment provided by engineer. */ +"Make sure WebRTC ICE server addresses are in correct format, line separated and are not duplicated." = "Varmista, että WebRTC ICE -palvelinosoitteet ovat oikeassa muodossa, rivieroteltuina ja että ne eivät ole päällekkäisiä."; + +/* No comment provided by engineer. */ +"Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?*" = "Monet ihmiset kysyivät: *Jos SimpleX:llä ei ole käyttäjätunnuksia, miten se voi toimittaa viestejä?*"; + +/* No comment provided by engineer. */ +"Mark deleted for everyone" = "Merkitse poistetuksi kaikilta"; + +/* No comment provided by engineer. */ +"Mark read" = "Merkitse luetuksi"; + +/* No comment provided by engineer. */ +"Mark verified" = "Merkitse vahvistetuksi"; + +/* No comment provided by engineer. */ +"Markdown in messages" = "Markdown viesteissä"; + +/* marked deleted chat item preview text */ +"marked deleted" = "merkitty poistetuksi"; + +/* No comment provided by engineer. */ +"Max 30 seconds, received instantly." = "Enintään 30 sekuntia, vastaanotetaan välittömästi."; + +/* member role */ +"member" = "jäsen"; + +/* No comment provided by engineer. */ +"Member" = "Jäsen"; + +/* rcv group event chat item */ +"member connected" = "yhdistetty"; + +/* No comment provided by engineer. */ +"Member role will be changed to \"%@\". All group members will be notified." = "Jäsenen rooli muuttuu muotoon \"%@\". Kaikille ryhmän jäsenille ilmoitetaan asiasta."; + +/* No comment provided by engineer. */ +"Member role will be changed to \"%@\". The member will receive a new invitation." = "Jäsenen rooli muutetaan muotoon \"%@\". Jäsen saa uuden kutsun."; + +/* No comment provided by engineer. */ +"Member will be removed from group - this cannot be undone!" = "Jäsen poistetaan ryhmästä - tätä ei voi perua!"; + +/* item status text */ +"Message delivery error" = "Viestin toimitusvirhe"; + +/* No comment provided by engineer. */ +"Message delivery receipts!" = "Viestien toimituskuittaukset!"; + +/* No comment provided by engineer. */ +"Message draft" = "Viestiluonnos"; + +/* chat feature */ +"Message reactions" = "Viestireaktiot"; + +/* No comment provided by engineer. */ +"Message reactions are prohibited in this chat." = "Viestireaktiot ovat kiellettyjä tässä keskustelussa."; + +/* No comment provided by engineer. */ +"Message reactions are prohibited in this group." = "Viestireaktiot ovat kiellettyjä tässä ryhmässä."; + +/* notification */ +"message received" = "viesti vastaanotettu"; + +/* No comment provided by engineer. */ +"Message text" = "Viestin teksti"; + +/* No comment provided by engineer. */ +"Messages" = "Viestit"; + +/* No comment provided by engineer. */ +"Messages & files" = "Viestit ja tiedostot"; + +/* No comment provided by engineer. */ +"Migrating database archive…" = "Siirretään tietokannan arkistoa…"; + +/* No comment provided by engineer. */ +"Migration error:" = "Siirtovirhe:"; + +/* No comment provided by engineer. */ +"Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat)." = "Siirto epäonnistui. Jatka nykyisen tietokannan käyttöä napauttamalla alla **Poistu**. Ilmoita ongelmasta sovelluskehittäjille keskustelussa tai sähköpostitse [chat@simplex.chat](mailto:chat@simplex.chat)."; + +/* No comment provided by engineer. */ +"Migration is completed" = "Siirto on valmis"; + +/* No comment provided by engineer. */ +"Migrations: %@" = "Siirrot: %@"; + +/* time unit */ +"minutes" = "minuuttia"; + +/* call status */ +"missed call" = "vastaamaton puhelu"; + +/* chat item action */ +"Moderate" = "Moderoi"; + +/* moderated chat item */ +"moderated" = "moderoitu"; + +/* No comment provided by engineer. */ +"Moderated at" = "Moderoitu klo"; + +/* copied message info */ +"Moderated at: %@" = "Moderoitu klo: %@"; + +/* No comment provided by engineer. */ +"moderated by %@" = "%@ moderoi"; + +/* time unit */ +"months" = "kuukautta"; + +/* No comment provided by engineer. */ +"More improvements are coming soon!" = "Lisää parannuksia on tulossa pian!"; + +/* item status description */ +"Most likely this connection is deleted." = "Todennäköisesti tämä yhteys on poistettu."; + +/* No comment provided by engineer. */ +"Most likely this contact has deleted the connection with you." = "Todennäköisesti tämä kontakti on poistanut yhteyden sinuun."; + +/* No comment provided by engineer. */ +"Multiple chat profiles" = "Useita keskusteluprofiileja"; + +/* No comment provided by engineer. */ +"Mute" = "Mykistä"; + +/* No comment provided by engineer. */ +"Muted when inactive!" = "Mykistetty ei-aktiivisena!"; + +/* No comment provided by engineer. */ +"Name" = "Nimi"; + +/* No comment provided by engineer. */ +"Network & servers" = "Verkko ja palvelimet"; + +/* No comment provided by engineer. */ +"Network settings" = "Verkkoasetukset"; + +/* No comment provided by engineer. */ +"Network status" = "Verkon tila"; + +/* No comment provided by engineer. */ +"never" = "ei koskaan"; + +/* notification */ +"New contact request" = "Uusi kontaktipyyntö"; + +/* notification */ +"New contact:" = "Uusi kontakti:"; + +/* No comment provided by engineer. */ +"New database archive" = "Uusi tietokanta-arkisto"; + +/* No comment provided by engineer. */ +"New display name" = "Uusi näyttönimi"; + +/* No comment provided by engineer. */ +"New in %@" = "Uutta %@"; + +/* No comment provided by engineer. */ +"New member role" = "Uusi jäsenrooli"; + +/* notification */ +"new message" = "uusi viesti"; + +/* notification */ +"New message" = "Uusi viesti"; + +/* No comment provided by engineer. */ +"New Passcode" = "Uusi pääsykoodi"; + +/* No comment provided by engineer. */ +"New passphrase…" = "Uusi tunnuslause…"; + +/* pref value */ +"no" = "ei"; + +/* No comment provided by engineer. */ +"No" = "Ei"; + +/* Authentication unavailable */ +"No app password" = "Ei sovelluksen salasanaa"; + +/* No comment provided by engineer. */ +"No contacts selected" = "Kontakteja ei ole valittu"; + +/* No comment provided by engineer. */ +"No contacts to add" = "Ei lisättäviä kontakteja"; + +/* No comment provided by engineer. */ +"No delivery information" = "Ei toimitustietoja"; + +/* No comment provided by engineer. */ +"No device token!" = "Ei laitetunnusta!"; + +/* No comment provided by engineer. */ +"no e2e encryption" = "ei e2e-salausta"; + +/* No comment provided by engineer. */ +"No filtered chats" = "Ei suodatettuja keskusteluja"; + +/* No comment provided by engineer. */ +"No group!" = "Ryhmää ei löydy!"; + +/* No comment provided by engineer. */ +"No history" = "Ei historiaa"; + +/* No comment provided by engineer. */ +"No permission to record voice message" = "Ei lupaa ääniviestin tallentamiseen"; + +/* No comment provided by engineer. */ +"No received or sent files" = "Ei vastaanotettuja tai lähetettyjä tiedostoja"; + +/* copied message info in history */ +"no text" = "ei tekstiä"; + +/* No comment provided by engineer. */ +"Notifications" = "Ilmoitukset"; + +/* No comment provided by engineer. */ +"Notifications are disabled!" = "Ilmoitukset on poistettu käytöstä!"; + +/* No comment provided by engineer. */ +"Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" = "Nyt järjestelmänvalvojat voivat:\n- poistaa jäsenten viestit.\n- poista jäsenet käytöstä (\"tarkkailija\" rooli)"; + +/* member role */ +"observer" = "tarkkailija"; + +/* enabled status + group pref value */ +"off" = "pois"; + +/* No comment provided by engineer. */ +"Off" = "Pois"; + +/* No comment provided by engineer. */ +"Off (Local)" = "Pois (Paikallinen)"; + +/* feature offered item */ +"offered %@" = "tarjottu %@"; + +/* feature offered item */ +"offered %@: %@" = "tarjottu %1$@: %2$@"; + +/* No comment provided by engineer. */ +"Ok" = "Ok"; + +/* No comment provided by engineer. */ +"Old database" = "Vanha tietokanta"; + +/* No comment provided by engineer. */ +"Old database archive" = "Vanha tietokanta-arkisto"; + +/* group pref value */ +"on" = "päällä"; + +/* No comment provided by engineer. */ +"One-time invitation link" = "Kertakutsulinkki"; + +/* No comment provided by engineer. */ +"Onion hosts will be required for connection. Requires enabling VPN." = "Yhteyden muodostamiseen tarvitaan Onion-isäntiä. Edellyttää VPN:n sallimista."; + +/* No comment provided by engineer. */ +"Onion hosts will be used when available. Requires enabling VPN." = "Onion-isäntiä käytetään, kun niitä on saatavilla. Edellyttää VPN:n sallimista."; + +/* No comment provided by engineer. */ +"Onion hosts will not be used." = "Onion-isäntiä ei käytetä."; + +/* No comment provided by engineer. */ +"Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." = "Vain asiakaslaitteet tallentavat käyttäjäprofiileja, yhteystietoja, ryhmiä ja viestejä, jotka on lähetetty **kaksinkertaisella päästä päähän -salauksella**."; + +/* No comment provided by engineer. */ +"Only group owners can change group preferences." = "Vain ryhmän omistajat voivat muuttaa ryhmän asetuksia."; + +/* No comment provided by engineer. */ +"Only group owners can enable files and media." = "Vain ryhmän omistajat voivat sallia tiedostoja ja mediaa."; + +/* No comment provided by engineer. */ +"Only group owners can enable voice messages." = "Vain ryhmän omistajat voivat ottaa ääniviestit käyttöön."; + +/* No comment provided by engineer. */ +"Only you can add message reactions." = "Vain sinä voit lisätä viestireaktioita."; + +/* No comment provided by engineer. */ +"Only you can irreversibly delete messages (your contact can mark them for deletion)." = "Vain sinä voit poistaa viestejä peruuttamattomasti (kontaktisi voi merkitä ne poistettavaksi)."; + +/* No comment provided by engineer. */ +"Only you can make calls." = "Vain sinä voit soittaa puheluita."; + +/* No comment provided by engineer. */ +"Only you can send disappearing messages." = "Vain sinä voit lähettää katoavia viestejä."; + +/* No comment provided by engineer. */ +"Only you can send voice messages." = "Vain sinä voit lähettää ääniviestejä."; + +/* No comment provided by engineer. */ +"Only your contact can add message reactions." = "Vain kontaktisi voi lisätä viestireaktioita."; + +/* No comment provided by engineer. */ +"Only your contact can irreversibly delete messages (you can mark them for deletion)." = "Vain kontaktisi voi poistaa viestejä peruuttamattomasti (voit merkitä ne poistettavaksi)."; + +/* No comment provided by engineer. */ +"Only your contact can make calls." = "Vain kontaktisi voi soittaa puheluita."; + +/* No comment provided by engineer. */ +"Only your contact can send disappearing messages." = "Vain kontaktisi voi lähettää katoavia viestejä."; + +/* No comment provided by engineer. */ +"Only your contact can send voice messages." = "Vain kontaktisi voi lähettää ääniviestejä."; + +/* No comment provided by engineer. */ +"Open chat" = "Avaa keskustelu"; + +/* authentication reason */ +"Open chat console" = "Avaa keskustelukonsoli"; + +/* No comment provided by engineer. */ +"Open Settings" = "Avaa Asetukset"; + +/* authentication reason */ +"Open user profiles" = "Avaa käyttäjäprofiilit"; + +/* No comment provided by engineer. */ +"Open-source protocol and code – anybody can run the servers." = "Avoimen lähdekoodin protokolla ja koodi - kuka tahansa voi käyttää palvelimia."; + +/* No comment provided by engineer. */ +"Opening database…" = "Avataan tietokantaa…"; + +/* No comment provided by engineer. */ +"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "Linkin avaaminen selaimessa voi heikentää yhteyden yksityisyyttä ja turvallisuutta. Epäluotetut SimpleX-linkit näkyvät punaisina."; + +/* No comment provided by engineer. */ +"or chat with the developers" = "tai keskustele kehittäjien kanssa"; + +/* member role */ +"owner" = "omistaja"; + +/* No comment provided by engineer. */ +"Passcode" = "Pääsykoodi"; + +/* No comment provided by engineer. */ +"Passcode changed!" = "Pääsykoodi vaihdettu!"; + +/* No comment provided by engineer. */ +"Passcode entry" = "Pääsykoodin syöttö"; + +/* No comment provided by engineer. */ +"Passcode not changed!" = "Pääsykoodia ei ole muutettu!"; + +/* No comment provided by engineer. */ +"Passcode set!" = "Pääsykoodi asetettu!"; + +/* No comment provided by engineer. */ +"Password to show" = "Salasana näytettäväksi"; + +/* No comment provided by engineer. */ +"Paste" = "Liitä"; + +/* No comment provided by engineer. */ +"Paste image" = "Liitä kuva"; + +/* No comment provided by engineer. */ +"Paste received link" = "Liitä vastaanotettu linkki"; + +/* placeholder */ +"Paste the link you received to connect with your contact." = "Liitä saamasi linkki, jonka avulla voit muodostaa yhteyden kontaktiisi."; + +/* No comment provided by engineer. */ +"peer-to-peer" = "vertais"; + +/* No comment provided by engineer. */ +"People can connect to you only via the links you share." = "Ihmiset voivat ottaa sinuun yhteyttä vain jakamiesi linkkien kautta."; + +/* No comment provided by engineer. */ +"Periodically" = "Ajoittain"; + +/* message decrypt error item */ +"Permanent decryption error" = "Pysyvä salauksen purkuvirhe"; + +/* No comment provided by engineer. */ +"PING count" = "PING-määrä"; + +/* No comment provided by engineer. */ +"PING interval" = "PING-väli"; + +/* No comment provided by engineer. */ +"Please ask your contact to enable sending voice messages." = "Pyydä kontaktiasi sallimaan ääniviestien lähettäminen."; + +/* No comment provided by engineer. */ +"Please check that you used the correct link or ask your contact to send you another one." = "Tarkista, että käytit oikeaa linkkiä tai pyydä kontaktiasi lähettämään sinulle uusi linkki."; + +/* No comment provided by engineer. */ +"Please check your network connection with %@ and try again." = "Tarkista verkkoyhteytesi %@:lla ja yritä uudelleen."; + +/* No comment provided by engineer. */ +"Please check yours and your contact preferences." = "Tarkista omasi ja kontaktin asetukset."; + +/* No comment provided by engineer. */ +"Please contact group admin." = "Ota yhteyttä ryhmän ylläpitäjään."; + +/* No comment provided by engineer. */ +"Please enter correct current passphrase." = "Anna oikea nykyinen tunnuslause."; + +/* No comment provided by engineer. */ +"Please enter the previous password after restoring database backup. This action can not be undone." = "Anna edellinen salasana tietokannan varmuuskopion palauttamisen jälkeen. Tätä toimintoa ei voi kumota."; + +/* No comment provided by engineer. */ +"Please remember or store it securely - there is no way to recover a lost passcode!" = "Muista tai säilytä se turvallisesti - kadonnutta pääsykoodia ei voi palauttaa!"; + +/* No comment provided by engineer. */ +"Please report it to the developers." = "Ilmoita siitä kehittäjille."; + +/* No comment provided by engineer. */ +"Please restart the app and migrate the database to enable push notifications." = "Käynnistä sovellus uudelleen ja siirrä tietokanta push-ilmoitusten ottamiseksi käyttöön."; + +/* No comment provided by engineer. */ +"Please store passphrase securely, you will NOT be able to access chat if you lose it." = "Säilytä tunnuslause turvallisesti, ET pääse keskusteluihin, jos kadotat sen."; + +/* No comment provided by engineer. */ +"Please store passphrase securely, you will NOT be able to change it if you lose it." = "Säilytä tunnuslause turvallisesti, ET voi muuttaa sitä, jos kadotat sen."; + +/* No comment provided by engineer. */ +"Polish interface" = "Puolalainen käyttöliittymä"; + +/* server test error */ +"Possibly, certificate fingerprint in server address is incorrect" = "Palvelimen osoitteen varmenteen sormenjälki on mahdollisesti virheellinen"; + +/* No comment provided by engineer. */ +"Preserve the last message draft, with attachments." = "Säilytä viimeinen viestiluonnos liitteineen."; + +/* No comment provided by engineer. */ +"Preset server" = "Esiasetettu palvelin"; + +/* No comment provided by engineer. */ +"Preset server address" = "Esiasetettu palvelimen osoite"; + +/* No comment provided by engineer. */ +"Preview" = "Esikatselu"; + +/* No comment provided by engineer. */ +"Privacy & security" = "Yksityisyys ja turvallisuus"; + +/* No comment provided by engineer. */ +"Privacy redefined" = "Yksityisyys uudelleen määritettynä"; + +/* No comment provided by engineer. */ +"Private filenames" = "Yksityiset tiedostonimet"; + +/* No comment provided by engineer. */ +"Profile and server connections" = "Profiili- ja palvelinyhteydet"; + +/* No comment provided by engineer. */ +"Profile image" = "Profiilikuva"; + +/* No comment provided by engineer. */ +"Profile password" = "Profiilin salasana"; + +/* No comment provided by engineer. */ +"Profile update will be sent to your contacts." = "Profiilipäivitys lähetetään kontakteillesi."; + +/* No comment provided by engineer. */ +"Prohibit audio/video calls." = "Estä ääni- ja videopuhelut."; + +/* No comment provided by engineer. */ +"Prohibit irreversible message deletion." = "Estä peruuttamaton viestien poistaminen."; + +/* No comment provided by engineer. */ +"Prohibit message reactions." = "Estä viestireaktiot."; + +/* No comment provided by engineer. */ +"Prohibit messages reactions." = "Estä viestireaktiot."; + +/* No comment provided by engineer. */ +"Prohibit sending direct messages to members." = "Estä suorien viestien lähettäminen jäsenille."; + +/* No comment provided by engineer. */ +"Prohibit sending disappearing messages." = "Estä katoavien viestien lähettäminen."; + +/* No comment provided by engineer. */ +"Prohibit sending files and media." = "Estä tiedostojen ja median lähettäminen."; + +/* No comment provided by engineer. */ +"Prohibit sending voice messages." = "Estä ääniviestien lähettäminen."; + +/* No comment provided by engineer. */ +"Protect app screen" = "Suojaa sovellusnäyttö"; + +/* No comment provided by engineer. */ +"Protect your chat profiles with a password!" = "Suojaa keskusteluprofiilisi salasanalla!"; + +/* No comment provided by engineer. */ +"Protocol timeout" = "Protokollan aikakatkaisu"; + +/* No comment provided by engineer. */ +"Protocol timeout per KB" = "Protokollan aikakatkaisu per KB"; + +/* No comment provided by engineer. */ +"Push notifications" = "Push-ilmoitukset"; + +/* No comment provided by engineer. */ +"Rate the app" = "Arvioi sovellus"; + +/* chat item menu */ +"React…" = "Reagoi…"; + +/* No comment provided by engineer. */ +"Read" = "Lue"; + +/* No comment provided by engineer. */ +"Read more" = "Lue lisää"; + +/* No comment provided by engineer. */ +"Read more in [User Guide](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address)." = "Lue lisää [Käyttöoppaasta](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address)."; + +/* No comment provided by engineer. */ +"Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends)." = "Lue lisää [Käyttöoppaasta](https://simplex.chat/docs/guide/readme.html#connect-to-friends)."; + +/* No comment provided by engineer. */ +"Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme)." = "Lue lisää [GitHub-arkistosta](https://github.com/simplex-chat/simplex-chat#readme)."; + +/* No comment provided by engineer. */ +"Read more in our GitHub repository." = "Lue lisää GitHub-tietovarastostamme."; + +/* No comment provided by engineer. */ +"Receipts are disabled" = "Kuittaukset pois käytöstä"; + +/* No comment provided by engineer. */ +"received answer…" = "vastaus saatu…"; + +/* No comment provided by engineer. */ +"Received at" = "Vastaanotettu klo"; + +/* copied message info */ +"Received at: %@" = "Vastaanotettu klo: %@"; + +/* No comment provided by engineer. */ +"received confirmation…" = "vahvistus saatu…"; + +/* notification */ +"Received file event" = "Tiedoston vastaanottotapahtuma"; + +/* message info title */ +"Received message" = "Vastaanotettu viesti"; + +/* No comment provided by engineer. */ +"Receiving address will be changed to a different server. Address change will complete after sender comes online." = "Vastaanotto-osoite vaihdetaan toiseen palvelimeen. Osoitteenmuutos tehdään sen jälkeen, kun lähettäjä tulee verkkoon."; + +/* No comment provided by engineer. */ +"Receiving file will be stopped." = "Tiedoston vastaanotto pysäytetään."; + +/* No comment provided by engineer. */ +"Receiving via" = "Vastaanotto kautta"; + +/* No comment provided by engineer. */ +"Recipients see updates as you type them." = "Vastaanottajat näkevät päivitykset, kun kirjoitat niitä."; + +/* No comment provided by engineer. */ +"Reconnect all connected servers to force message delivery. It uses additional traffic." = "Yhdistä kaikki yhdistetyt palvelimet uudelleen pakottaaksesi viestin toimituksen. Tämä käyttää ylimääräistä liikennettä."; + +/* No comment provided by engineer. */ +"Reconnect servers?" = "Yhdistä palvelimet uudelleen?"; + +/* No comment provided by engineer. */ +"Record updated at" = "Tietue päivitetty klo"; + +/* copied message info */ +"Record updated at: %@" = "Tietue päivitetty klo: %@"; + +/* No comment provided by engineer. */ +"Reduced battery usage" = "Pienempi akun käyttö"; + +/* reject incoming call via notification */ +"Reject" = "Hylkää"; + +/* No comment provided by engineer. */ +"Reject (sender NOT notified)" = "Hylkää (lähettäjälle EI ilmoiteta)"; + +/* No comment provided by engineer. */ +"Reject contact request" = "Hylkää yhteyspyyntö"; + +/* call status */ +"rejected call" = "hylätty puhelu"; + +/* No comment provided by engineer. */ +"Relay server is only used if necessary. Another party can observe your IP address." = "Välityspalvelinta käytetään vain tarvittaessa. Toinen osapuoli voi tarkkailla IP-osoitettasi."; + +/* No comment provided by engineer. */ +"Relay server protects your IP address, but it can observe the duration of the call." = "Välityspalvelin suojaa IP-osoitteesi, mutta se voi tarkkailla puhelun kestoa."; + +/* No comment provided by engineer. */ +"Remove" = "Poista"; + +/* No comment provided by engineer. */ +"Remove member" = "Poista jäsen"; + +/* No comment provided by engineer. */ +"Remove member?" = "Poista jäsen?"; + +/* No comment provided by engineer. */ +"Remove passphrase from keychain?" = "Poista tunnuslause avainnipusta?"; + +/* No comment provided by engineer. */ +"removed" = "poistettu"; + +/* rcv group event chat item */ +"removed %@" = "%@ poistettu"; + +/* rcv group event chat item */ +"removed you" = "poisti sinut"; + +/* No comment provided by engineer. */ +"Renegotiate" = "Neuvottele uudelleen"; + +/* No comment provided by engineer. */ +"Renegotiate encryption" = "Uudelleenneuvottele salaus"; + +/* No comment provided by engineer. */ +"Renegotiate encryption?" = "Uudelleenneuvottele salaus?"; + +/* chat item action */ +"Reply" = "Vastaa"; + +/* No comment provided by engineer. */ +"Required" = "Pakollinen"; + +/* No comment provided by engineer. */ +"Reset" = "Oletustilaan"; + +/* No comment provided by engineer. */ +"Reset colors" = "Oletusvärit"; + +/* No comment provided by engineer. */ +"Reset to defaults" = "Palauta oletusasetukset"; + +/* No comment provided by engineer. */ +"Restart the app to create a new chat profile" = "Käynnistä sovellus uudelleen uuden keskusteluprofiilin luomiseksi"; + +/* No comment provided by engineer. */ +"Restart the app to use imported chat database" = "Käynnistä sovellus uudelleen käyttääksesi tuotua keskustelujen-tietokantaa"; + +/* No comment provided by engineer. */ +"Restore" = "Palauta"; + +/* No comment provided by engineer. */ +"Restore database backup" = "Palauta tietokannan varmuuskopio"; + +/* No comment provided by engineer. */ +"Restore database backup?" = "Palauta tietokannan varmuuskopio?"; + +/* No comment provided by engineer. */ +"Restore database error" = "Virhe tietokannan palauttamisessa"; + +/* chat item action */ +"Reveal" = "Paljasta"; + +/* No comment provided by engineer. */ +"Revert" = "Palauta"; + +/* No comment provided by engineer. */ +"Revoke" = "Peruuta"; + +/* cancel file action */ +"Revoke file" = "Peruuta tiedosto"; + +/* No comment provided by engineer. */ +"Revoke file?" = "Peruuta tiedosto?"; + +/* No comment provided by engineer. */ +"Role" = "Rooli"; + +/* No comment provided by engineer. */ +"Run chat" = "Käynnistä chat"; + +/* chat item action */ +"Save" = "Tallenna"; + +/* No comment provided by engineer. */ +"Save (and notify contacts)" = "Tallenna (ja ilmoita kontakteille)"; + +/* No comment provided by engineer. */ +"Save and notify contact" = "Tallenna ja ilmoita kontaktille"; + +/* No comment provided by engineer. */ +"Save and notify group members" = "Tallenna ja ilmoita ryhmän jäsenille"; + +/* No comment provided by engineer. */ +"Save and update group profile" = "Tallenna ja päivitä ryhmäprofiili"; + +/* No comment provided by engineer. */ +"Save archive" = "Tallenna arkisto"; + +/* No comment provided by engineer. */ +"Save auto-accept settings" = "Tallenna automaattisen hyväksynnän asetukset"; + +/* No comment provided by engineer. */ +"Save group profile" = "Tallenna ryhmäprofiili"; + +/* No comment provided by engineer. */ +"Save passphrase and open chat" = "Tallenna tunnuslause ja avaa keskustelu"; + +/* No comment provided by engineer. */ +"Save passphrase in Keychain" = "Tallenna tunnuslause Avainnippuun"; + +/* No comment provided by engineer. */ +"Save preferences?" = "Tallenna asetukset?"; + +/* No comment provided by engineer. */ +"Save profile password" = "Tallenna profiilin salasana"; + +/* No comment provided by engineer. */ +"Save servers" = "Tallenna palvelimet"; + +/* No comment provided by engineer. */ +"Save servers?" = "Tallenna palvelimet?"; + +/* No comment provided by engineer. */ +"Save settings?" = "Tallenna asetukset?"; + +/* No comment provided by engineer. */ +"Save welcome message?" = "Tallenna tervetuloviesti?"; + +/* No comment provided by engineer. */ +"Saved WebRTC ICE servers will be removed" = "Tallennetut WebRTC ICE -palvelimet poistetaan"; + +/* No comment provided by engineer. */ +"Scan code" = "Skannaa koodi"; + +/* No comment provided by engineer. */ +"Scan QR code" = "Skannaa QR-koodi"; + +/* No comment provided by engineer. */ +"Scan security code from your contact's app." = "Skannaa turvakoodi kontaktisi sovelluksesta."; + +/* No comment provided by engineer. */ +"Scan server QR code" = "Skannaa palvelimen QR-koodi"; + +/* No comment provided by engineer. */ +"Search" = "Haku"; + +/* network option */ +"sec" = "sek"; + +/* time unit */ +"seconds" = "sekuntia"; + +/* No comment provided by engineer. */ +"secret" = "salainen"; + +/* server test step */ +"Secure queue" = "Turvallinen jono"; + +/* No comment provided by engineer. */ +"Security assessment" = "Turvallisuusarviointi"; + +/* No comment provided by engineer. */ +"Security code" = "Turvakoodi"; + +/* chat item text */ +"security code changed" = "turvakoodi on muuttunut"; + +/* No comment provided by engineer. */ +"Select" = "Valitse"; + +/* No comment provided by engineer. */ +"Self-destruct" = "Itsetuho"; + +/* No comment provided by engineer. */ +"Self-destruct passcode" = "Itsetuhoutuva pääsykoodi"; + +/* No comment provided by engineer. */ +"Self-destruct passcode changed!" = "Itsetuhoutuva pääsykoodi vaihdettu!"; + +/* No comment provided by engineer. */ +"Self-destruct passcode enabled!" = "Itsetuhoutuva pääsykoodi käytössä!"; + +/* No comment provided by engineer. */ +"Send" = "Lähetä"; + +/* No comment provided by engineer. */ +"Send a live message - it will update for the recipient(s) as you type it" = "Lähetä live-viesti - se päivittyy vastaanottajille, kun kirjoitat sitä"; + +/* No comment provided by engineer. */ +"Send delivery receipts to" = "Lähetä toimituskuittaukset vastaanottajalle"; + +/* No comment provided by engineer. */ +"Send direct message" = "Lähetä yksityisviesti"; + +/* No comment provided by engineer. */ +"Send disappearing message" = "Lähetä katoava viesti"; + +/* No comment provided by engineer. */ +"Send link previews" = "Lähetä linkkien esikatselu"; + +/* No comment provided by engineer. */ +"Send live message" = "Lähetä live-viesti"; + +/* No comment provided by engineer. */ +"Send notifications" = "Lähetys ilmoitukset"; + +/* No comment provided by engineer. */ +"Send notifications:" = "Lähetys ilmoitukset:"; + +/* No comment provided by engineer. */ +"Send questions and ideas" = "Lähetä kysymyksiä ja ideoita"; + +/* No comment provided by engineer. */ +"Send receipts" = "Lähetä kuittaukset"; + +/* No comment provided by engineer. */ +"Send them from gallery or custom keyboards." = "Lähetä ne galleriasta tai mukautetuista näppäimistöistä."; + +/* No comment provided by engineer. */ +"Sender cancelled file transfer." = "Lähettäjä peruutti tiedoston siirron."; + +/* No comment provided by engineer. */ +"Sender may have deleted the connection request." = "Lähettäjä on saattanut poistaa yhteyspyynnön."; + +/* No comment provided by engineer. */ +"Sending delivery receipts will be enabled for all contacts in all visible chat profiles." = "Toimituskuittauksien lähettäminen otetaan käyttöön kaikille kontakteille näkyvissä keskusteluprofiileissa."; + +/* No comment provided by engineer. */ +"Sending delivery receipts will be enabled for all contacts." = "Toimituskuittauksien lähettäminen otetaan käyttöön kaikille kontakteille."; + +/* No comment provided by engineer. */ +"Sending file will be stopped." = "Tiedoston lähettäminen lopetetaan."; + +/* No comment provided by engineer. */ +"Sending receipts is disabled for %lld contacts" = "Kuittauksien lähettäminen ei ole käytössä %lld kontakteille"; + +/* No comment provided by engineer. */ +"Sending receipts is disabled for %lld groups" = "Kuittien lähettäminen ei ole käytössä %lld ryhmille"; + +/* No comment provided by engineer. */ +"Sending receipts is enabled for %lld contacts" = "Kuittauksien lähettäminen on käytössä %lld kontakteille"; + +/* No comment provided by engineer. */ +"Sending receipts is enabled for %lld groups" = "Kuittauksien lähettäminen on käytössä %lld ryhmille"; + +/* No comment provided by engineer. */ +"Sending via" = "Lähetetään kautta"; + +/* No comment provided by engineer. */ +"Sent at" = "Lähetetty klo"; + +/* copied message info */ +"Sent at: %@" = "Lähetetty klo: %@"; + +/* notification */ +"Sent file event" = "Lähetetty tiedosto tapahtuma"; + +/* message info title */ +"Sent message" = "Lähetetty viesti"; + +/* No comment provided by engineer. */ +"Sent messages will be deleted after set time." = "Lähetetyt viestit poistetaan asetetun ajan kuluttua."; + +/* server test error */ +"Server requires authorization to create queues, check password" = "Palvelin vaatii valtuutuksen jonojen luomiseen, tarkista salasana"; + +/* server test error */ +"Server requires authorization to upload, check password" = "Palvelin vaatii valtuutuksen tiedoston lataamiseksi, tarkista salasana"; + +/* No comment provided by engineer. */ +"Server test failed!" = "Palvelintesti epäonnistui!"; + +/* No comment provided by engineer. */ +"Servers" = "Palvelimet"; + +/* No comment provided by engineer. */ +"Set 1 day" = "Aseta 1 päivä"; + +/* No comment provided by engineer. */ +"Set contact name…" = "Aseta kontaktin nimi…"; + +/* No comment provided by engineer. */ +"Set group preferences" = "Aseta ryhmän asetukset"; + +/* No comment provided by engineer. */ +"Set it instead of system authentication." = "Aseta se järjestelmän todennuksen sijaan."; + +/* No comment provided by engineer. */ +"Set passcode" = "Aseta pääsykoodi"; + +/* No comment provided by engineer. */ +"Set passphrase to export" = "Aseta tunnuslause vientiä varten"; + +/* No comment provided by engineer. */ +"Set the message shown to new members!" = "Aseta uusille jäsenille näytettävä viesti!"; + +/* No comment provided by engineer. */ +"Set timeouts for proxy/VPN" = "Aseta aikakatkaisut välityspalvelimelle/VPN:lle"; + +/* No comment provided by engineer. */ +"Settings" = "Asetukset"; + +/* chat item action */ +"Share" = "Jaa"; + +/* No comment provided by engineer. */ +"Share 1-time link" = "Jaa kertakäyttölinkki"; + +/* No comment provided by engineer. */ +"Share address" = "Jaa osoite"; + +/* No comment provided by engineer. */ +"Share address with contacts?" = "Jaa osoite kontakteille?"; + +/* No comment provided by engineer. */ +"Share link" = "Jaa linkki"; + +/* No comment provided by engineer. */ +"Share one-time invitation link" = "Jaa kertakutsulinkki"; + +/* No comment provided by engineer. */ +"Share with contacts" = "Jaa kontaktien kanssa"; + +/* No comment provided by engineer. */ +"Show calls in phone history" = "Näytä puhelut puhelinhistoriassa"; + +/* No comment provided by engineer. */ +"Show developer options" = "Näytä kehittäjävaihtoehdot"; + +/* No comment provided by engineer. */ +"Show last messages" = "Näytä viimeiset viestit"; + +/* No comment provided by engineer. */ +"Show preview" = "Näytä esikatselu"; + +/* No comment provided by engineer. */ +"Show:" = "Näytä:"; + +/* No comment provided by engineer. */ +"SimpleX address" = "SimpleX-osoite"; + +/* No comment provided by engineer. */ +"SimpleX Address" = "SimpleX-osoite"; + +/* No comment provided by engineer. */ +"SimpleX Chat security was audited by Trail of Bits." = "Trail of Bits on tarkastanut SimpleX Chatin tietoturvan."; + +/* simplex link type */ +"SimpleX contact address" = "SimpleX-yhteystiedot"; + +/* notification */ +"SimpleX encrypted message or connection event" = "SimpleX-salattu viesti tai yhteystapahtuma"; + +/* simplex link type */ +"SimpleX group link" = "SimpleX-ryhmän linkki"; + +/* No comment provided by engineer. */ +"SimpleX links" = "SimpleX-linkit"; + +/* No comment provided by engineer. */ +"SimpleX Lock" = "SimpleX Lock"; + +/* No comment provided by engineer. */ +"SimpleX Lock mode" = "SimpleX Lock -tila"; + +/* No comment provided by engineer. */ +"SimpleX Lock not enabled!" = "SimpleX Lock ei ole käytössä!"; + +/* No comment provided by engineer. */ +"SimpleX Lock turned on" = "SimpleX Lock päällä"; + +/* simplex link type */ +"SimpleX one-time invitation" = "SimpleX-kertakutsu"; + +/* No comment provided by engineer. */ +"Skip" = "Ohita"; + +/* No comment provided by engineer. */ +"Skipped messages" = "Ohitetut viestit"; + +/* No comment provided by engineer. */ +"Small groups (max 20)" = "Pienryhmät (max 20)"; + +/* No comment provided by engineer. */ +"SMP servers" = "SMP-palvelimet"; + +/* No comment provided by engineer. */ +"Some non-fatal errors occurred during import - you may see Chat console for more details." = "Tuonnin aikana tapahtui joitakin ei-vakavia virheitä – saatat nähdä Chat-konsolissa lisätietoja."; + +/* notification title */ +"Somebody" = "Joku"; + +/* No comment provided by engineer. */ +"Start a new chat" = "Aloita uusi keskustelu"; + +/* No comment provided by engineer. */ +"Start chat" = "Aloita keskustelu"; + +/* No comment provided by engineer. */ +"Start migration" = "Aloita siirto"; + +/* No comment provided by engineer. */ +"starting…" = "alkaa…"; + +/* No comment provided by engineer. */ +"Stop" = "Lopeta"; + +/* No comment provided by engineer. */ +"Stop chat to enable database actions" = "Pysäytä keskustelu tietokantatoimien mahdollistamiseksi"; + +/* No comment provided by engineer. */ +"Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped." = "Pysäytä keskustelut viedäksesi, tuodaksesi tai poistaaksesi keskustelujen tietokannan. Et voi vastaanottaa ja lähettää viestejä, kun keskustelut on pysäytetty."; + +/* No comment provided by engineer. */ +"Stop chat?" = "Lopeta keskustelu?"; + +/* cancel file action */ +"Stop file" = "Pysäytä tiedosto"; + +/* No comment provided by engineer. */ +"Stop receiving file?" = "Lopeta tiedoston vastaanottaminen?"; + +/* No comment provided by engineer. */ +"Stop sending file?" = "Lopeta tiedoston lähettäminen?"; + +/* No comment provided by engineer. */ +"Stop sharing" = "Lopeta jakaminen"; + +/* No comment provided by engineer. */ +"Stop sharing address?" = "Lopeta osoitteen jakaminen?"; + +/* authentication reason */ +"Stop SimpleX" = "Lopeta SimpleX"; + +/* No comment provided by engineer. */ +"strike" = "soita"; + +/* No comment provided by engineer. */ +"Submit" = "Lähetä"; + +/* No comment provided by engineer. */ +"Support SimpleX Chat" = "SimpleX Chat tuki"; + +/* No comment provided by engineer. */ +"System" = "Järjestelmä"; + +/* No comment provided by engineer. */ +"System authentication" = "Järjestelmän todennus"; + +/* No comment provided by engineer. */ +"Take picture" = "Ota kuva"; + +/* No comment provided by engineer. */ +"Tap button " = "Napauta painiketta "; + +/* No comment provided by engineer. */ +"Tap to activate profile." = "Aktivoi profiili napauttamalla."; + +/* No comment provided by engineer. */ +"Tap to join" = "Liity napauttamalla"; + +/* No comment provided by engineer. */ +"Tap to join incognito" = "Napauta liittyäksesi incognito-tilassa"; + +/* No comment provided by engineer. */ +"Tap to start a new chat" = "Aloita uusi keskustelu napauttamalla"; + +/* No comment provided by engineer. */ +"TCP connection timeout" = "TCP-yhteyden aikakatkaisu"; + +/* No comment provided by engineer. */ +"TCP_KEEPCNT" = "TCP_KEEPCNT"; + +/* No comment provided by engineer. */ +"TCP_KEEPIDLE" = "TCP_KEEPIDLE"; + +/* No comment provided by engineer. */ +"TCP_KEEPINTVL" = "TCP_KEEPINTVL"; + +/* server test failure */ +"Test failed at step %@." = "Testi epäonnistui vaiheessa %@."; + +/* No comment provided by engineer. */ +"Test server" = "Testipalvelin"; + +/* No comment provided by engineer. */ +"Test servers" = "Testipalvelimet"; + +/* No comment provided by engineer. */ +"Tests failed!" = "Testit epäonnistuivat!"; + +/* No comment provided by engineer. */ +"Thank you for installing SimpleX Chat!" = "Kiitos SimpleX Chatin asentamisesta!"; + +/* No comment provided by engineer. */ +"Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Kiitos käyttäjille - [osallistu Weblaten avulla](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; + +/* No comment provided by engineer. */ +"Thanks to the users – contribute via Weblate!" = "Kiitokset käyttäjille – osallistu Weblaten kautta!"; + +/* No comment provided by engineer. */ +"The 1st platform without any user identifiers – private by design." = "Ensimmäinen alusta ilman käyttäjätunnisteita – suunniteltu yksityiseksi."; + +/* No comment provided by engineer. */ +"The app can notify you when you receive messages or contact requests - please open settings to enable." = "Sovellus voi ilmoittaa sinulle, kun saat viestejä tai yhteydenottopyyntöjä - avaa asetukset ottaaksesi ne käyttöön."; + +/* No comment provided by engineer. */ +"The attempt to change database passphrase was not completed." = "Tietokannan tunnuslauseen muuttamista ei suoritettu loppuun."; + +/* No comment provided by engineer. */ +"The connection you accepted will be cancelled!" = "Hyväksymäsi yhteys peruuntuu!"; + +/* No comment provided by engineer. */ +"The contact you shared this link with will NOT be able to connect!" = "Kontakti, jolle jaoit tämän linkin, EI voi muodostaa yhteyttä!"; + +/* No comment provided by engineer. */ +"The created archive is available via app Settings / Database / Old database archive." = "Luotu arkisto on käytettävissä sovelluksen Asetukset / Tietokanta / Vanha tietokanta-arkisto kautta."; + +/* No comment provided by engineer. */ +"The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Salaus toimii ja uutta salaussopimusta ei tarvita. Tämä voi johtaa yhteysvirheisiin!"; + +/* No comment provided by engineer. */ +"The group is fully decentralized – it is visible only to the members." = "Ryhmä on täysin hajautettu - se näkyy vain jäsenille."; + +/* No comment provided by engineer. */ +"The hash of the previous message is different." = "Edellisen viestin tarkiste on erilainen."; + +/* No comment provided by engineer. */ +"The ID of the next message is incorrect (less or equal to the previous).\nIt can happen because of some bug or when the connection is compromised." = "Seuraavan viestin tunnus on väärä (pienempi tai yhtä suuri kuin edellisen).\nTämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut."; + +/* No comment provided by engineer. */ +"The message will be deleted for all members." = "Viesti poistetaan kaikilta jäseniltä."; + +/* No comment provided by engineer. */ +"The message will be marked as moderated for all members." = "Viesti merkitään moderoiduksi kaikille jäsenille."; + +/* No comment provided by engineer. */ +"The next generation of private messaging" = "Seuraavan sukupolven yksityisviestit"; + +/* No comment provided by engineer. */ +"The old database was not removed during the migration, it can be deleted." = "Vanhaa tietokantaa ei poistettu siirron aikana, se voidaan kuitenkin poistaa."; + +/* No comment provided by engineer. */ +"The profile is only shared with your contacts." = "Profiili jaetaan vain kontaktiesi kanssa."; + +/* No comment provided by engineer. */ +"The second tick we missed! ✅" = "Toinen kuittaus, joka uupui! ✅"; + +/* No comment provided by engineer. */ +"The sender will NOT be notified" = "Lähettäjälle EI ilmoiteta"; + +/* No comment provided by engineer. */ +"The servers for new connections of your current chat profile **%@**." = "Palvelimet nykyisen keskusteluprofiilisi uusille yhteyksille **%@**."; + +/* No comment provided by engineer. */ +"Theme" = "Teema"; + +/* No comment provided by engineer. */ +"There should be at least one user profile." = "Käyttäjäprofiileja tulee olla vähintään yksi."; + +/* No comment provided by engineer. */ +"There should be at least one visible user profile." = "Näkyviä käyttäjäprofiileja tulee olla vähintään yksi."; + +/* No comment provided by engineer. */ +"These settings are for your current profile **%@**." = "Nämä asetukset koskevat nykyistä profiiliasi **%@**."; + +/* No comment provided by engineer. */ +"They can be overridden in contact and group settings." = "Ne voidaan ohittaa kontakti- ja ryhmäasetuksissa."; + +/* No comment provided by engineer. */ +"This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Tätä toimintoa ei voi kumota - kaikki vastaanotetut ja lähetetyt tiedostot ja media poistetaan. Matalan resoluution kuvat säilyvät."; + +/* No comment provided by engineer. */ +"This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Tätä toimintoa ei voi kumota - valittua aikaisemmin lähetetyt ja vastaanotetut viestit poistetaan. Tämä voi kestää useita minuutteja."; + +/* No comment provided by engineer. */ +"This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Tätä toimintoa ei voi kumota - profiilisi, kontaktisi, viestisi ja tiedostosi poistuvat peruuttamattomasti."; + +/* notification title */ +"this contact" = "tämä kontakti"; + +/* No comment provided by engineer. */ +"This group has over %lld members, delivery receipts are not sent." = "Tässä ryhmässä on yli %lld jäsentä, lähetyskuittauksia ei lähetetä."; + +/* No comment provided by engineer. */ +"This group no longer exists." = "Tätä ryhmää ei enää ole olemassa."; + +/* No comment provided by engineer. */ +"This setting applies to messages in your current chat profile **%@**." = "Tämä asetus koskee nykyisen keskusteluprofiilisi viestejä *%@**."; + +/* No comment provided by engineer. */ +"To ask any questions and to receive updates:" = "Voit esittää kysymyksiä ja saada päivityksiä:"; + +/* No comment provided by engineer. */ +"To connect, your contact can scan QR code or use the link in the app." = "Kontaktisi voi muodostaa yhteyden skannaamalla QR-koodin tai käyttämällä sovelluksessa olevaa linkkiä."; + +/* No comment provided by engineer. */ +"To make a new connection" = "Uuden yhteyden luominen"; + +/* No comment provided by engineer. */ +"To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts." = "Yksityisyyden suojaamiseksi kaikkien muiden alustojen käyttämien käyttäjätunnusten sijaan SimpleX käyttää viestijonojen tunnisteita, jotka ovat kaikille kontakteille erillisiä."; + +/* No comment provided by engineer. */ +"To protect timezone, image/voice files use UTC." = "Aikavyöhykkeen suojaamiseksi kuva-/äänitiedostot käyttävät UTC:tä."; + +/* No comment provided by engineer. */ +"To protect your information, turn on SimpleX Lock.\nYou will be prompted to complete authentication before this feature is enabled." = "Suojaa tietosi ottamalla SimpleX Lock käyttöön.\nSinua kehotetaan suorittamaan todennus loppuun, ennen kuin tämä ominaisuus otetaan käyttöön."; + +/* No comment provided by engineer. */ +"To record voice message please grant permission to use Microphone." = "Jos haluat nauhoittaa ääniviestin, anna lupa käyttää mikrofonia."; + +/* No comment provided by engineer. */ +"To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "Voit paljastaa piilotetun profiilisi syöttämällä koko salasanan hakukenttään **Keskusteluprofiilisi** -sivulla."; + +/* No comment provided by engineer. */ +"To support instant push notifications the chat database has to be migrated." = "Keskustelujen-tietokanta on siirrettävä välittömien push-ilmoitusten tukemiseksi."; + +/* No comment provided by engineer. */ +"To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "Voit tarkistaa päästä päähän -salauksen kontaktisi kanssa vertaamalla (tai skannaamalla) laitteidenne koodia."; + +/* No comment provided by engineer. */ +"Transport isolation" = "Kuljetuksen eristäminen"; + +/* No comment provided by engineer. */ +"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Yritetään muodostaa yhteyttä palvelimeen, jota käytetään tämän kontaktin viestien vastaanottamiseen (virhe: %@)."; + +/* No comment provided by engineer. */ +"Trying to connect to the server used to receive messages from this contact." = "Yritetään muodostaa yhteys palvelimeen, jota käytetään viestien vastaanottamiseen tältä kontaktilta."; + +/* No comment provided by engineer. */ +"Turn off" = "Sammuta"; + +/* No comment provided by engineer. */ +"Turn off notifications?" = "Kytke ilmoitukset pois päältä?"; + +/* No comment provided by engineer. */ +"Turn on" = "Kytke päälle"; + +/* No comment provided by engineer. */ +"Unable to record voice message" = "Ääniviestiä ei voi tallentaa"; + +/* item status description */ +"Unexpected error: %@" = "Odottamaton virhe: %@"; + +/* No comment provided by engineer. */ +"Unexpected migration state" = "Odottamaton siirtotila"; + +/* No comment provided by engineer. */ +"Unfav." = "Epäsuotuisa."; + +/* No comment provided by engineer. */ +"Unhide" = "Näytä"; + +/* No comment provided by engineer. */ +"Unhide chat profile" = "Näytä keskusteluprofiili"; + +/* No comment provided by engineer. */ +"Unhide profile" = "Näytä profiili"; + +/* No comment provided by engineer. */ +"Unit" = "Yksikkö"; + +/* connection info */ +"unknown" = "tuntematon"; + +/* callkit banner */ +"Unknown caller" = "Tuntematon soittaja"; + +/* No comment provided by engineer. */ +"Unknown database error: %@" = "Tuntematon tietokantavirhe: %@"; + +/* No comment provided by engineer. */ +"Unknown error" = "Tuntematon virhe"; + +/* No comment provided by engineer. */ +"Unless you use iOS call interface, enable Do Not Disturb mode to avoid interruptions." = "Ellet käytä iOS:n puhelinkäyttöliittymää, ota Älä häiritse -tila käyttöön keskeytysten välttämiseksi."; + +/* No comment provided by engineer. */ +"Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." = "Ellei yhteyshenkilösi poistanut yhteyttä tai tämä linkki oli jo käytössä, se voi olla virhe - ilmoita siitä.\nJos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja tarkista, että verkkoyhteytesi on vakaa."; + +/* No comment provided by engineer. */ +"Unlock" = "Avaa"; + +/* authentication reason */ +"Unlock app" = "Avaa sovellus"; + +/* No comment provided by engineer. */ +"Unmute" = "Poista mykistys"; + +/* No comment provided by engineer. */ +"Unread" = "Lukematon"; + +/* No comment provided by engineer. */ +"Update" = "Päivitä"; + +/* No comment provided by engineer. */ +"Update .onion hosts setting?" = "Päivitä .onion-isäntien asetus?"; + +/* No comment provided by engineer. */ +"Update database passphrase" = "Päivitä tietokannan tunnuslause"; + +/* No comment provided by engineer. */ +"Update network settings?" = "Päivitä verkkoasetukset?"; + +/* No comment provided by engineer. */ +"Update transport isolation mode?" = "Päivitä kuljetuksen eristystila?"; + +/* rcv group event chat item */ +"updated group profile" = "päivitetty ryhmäprofiili"; + +/* No comment provided by engineer. */ +"Updating settings will re-connect the client to all servers." = "Asetusten päivittäminen yhdistää asiakkaan uudelleen kaikkiin palvelimiin."; + +/* No comment provided by engineer. */ +"Updating this setting will re-connect the client to all servers." = "Tämän asetuksen päivittäminen yhdistää asiakkaan uudelleen kaikkiin palvelimiin."; + +/* No comment provided by engineer. */ +"Upgrade and open chat" = "Päivitä ja avaa keskustelu"; + +/* server test step */ +"Upload file" = "Lataa tiedosto"; + +/* No comment provided by engineer. */ +"Use .onion hosts" = "Käytä .onion-isäntiä"; + +/* No comment provided by engineer. */ +"Use chat" = "Käytä chattia"; + +/* No comment provided by engineer. */ +"Use current profile" = "Käytä nykyistä profiilia"; + +/* No comment provided by engineer. */ +"Use for new connections" = "Käytä uusiin yhteyksiin"; + +/* No comment provided by engineer. */ +"Use iOS call interface" = "Käytä iOS:n puhelujen käyttöliittymää"; + +/* No comment provided by engineer. */ +"Use new incognito profile" = "Käytä uutta incognito-profiilia"; + +/* No comment provided by engineer. */ +"Use server" = "Käytä palvelinta"; + +/* No comment provided by engineer. */ +"Use SimpleX Chat servers?" = "Käytä SimpleX Chat palvelimia?"; + +/* No comment provided by engineer. */ +"User profile" = "Käyttäjäprofiili"; + +/* No comment provided by engineer. */ +"Using .onion hosts requires compatible VPN provider." = ".onion-isäntien käyttäminen vaatii yhteensopivan VPN-palveluntarjoajan."; + +/* No comment provided by engineer. */ +"Using SimpleX Chat servers." = "Käyttää SimpleX Chat -palvelimia."; + +/* No comment provided by engineer. */ +"v%@ (%@)" = "v%@ (%@)"; + +/* No comment provided by engineer. */ +"Verify connection security" = "Tarkista yhteyden suojaus"; + +/* No comment provided by engineer. */ +"Verify security code" = "Tarkista turvakoodi"; + +/* No comment provided by engineer. */ +"Via browser" = "Selaimella"; + +/* chat list item description */ +"via contact address link" = "kontaktiosoitelinkillä"; + +/* chat list item description */ +"via group link" = "ryhmälinkillä"; + +/* chat list item description */ +"via one-time link" = "kertalinkillä"; + +/* No comment provided by engineer. */ +"via relay" = "releellä"; + +/* No comment provided by engineer. */ +"Video call" = "Videopuhelu"; + +/* No comment provided by engineer. */ +"video call (not e2e encrypted)" = "videopuhelu (ei e2e-salattu)"; + +/* No comment provided by engineer. */ +"Video will be received when your contact completes uploading it." = "Video vastaanotetaan, kun kontaktisi on ladannut sen."; + +/* No comment provided by engineer. */ +"Video will be received when your contact is online, please wait or check later!" = "Video vastaanotetaan, kun kontaktisi on online-tilassa, odota tai tarkista myöhemmin!"; + +/* No comment provided by engineer. */ +"Videos and files up to 1gb" = "Videot ja tiedostot 1 Gt asti"; + +/* No comment provided by engineer. */ +"View security code" = "Näytä turvakoodi"; + +/* No comment provided by engineer. */ +"Voice message…" = "Ääniviesti…"; + +/* chat feature */ +"Voice messages" = "Ääniviestit"; + +/* No comment provided by engineer. */ +"Voice messages are prohibited in this chat." = "Ääniviestit ovat kiellettyjä tässä keskustelussa."; + +/* No comment provided by engineer. */ +"Voice messages are prohibited in this group." = "Ääniviestit ovat kiellettyjä tässä ryhmässä."; + +/* No comment provided by engineer. */ +"Voice messages prohibited!" = "Ääniviestit kielletty!"; + +/* No comment provided by engineer. */ +"waiting for answer…" = "odottaa vastaamista…"; + +/* No comment provided by engineer. */ +"waiting for confirmation…" = "odottaa vahvistusta…"; + +/* No comment provided by engineer. */ +"Waiting for file" = "Odottaa tiedostoa"; + +/* No comment provided by engineer. */ +"Waiting for image" = "Odottaa kuvaa"; + +/* No comment provided by engineer. */ +"Waiting for video" = "Odottaa videota"; + +/* No comment provided by engineer. */ +"wants to connect to you!" = "haluaa olla yhteydessä sinuun!"; + +/* No comment provided by engineer. */ +"Warning: you may lose some data!" = "Varoitus: saatat menettää joitain tietoja!"; + +/* No comment provided by engineer. */ +"WebRTC ICE servers" = "WebRTC ICE -palvelimet"; + +/* time unit */ +"weeks" = "viikkoa"; + +/* No comment provided by engineer. */ +"Welcome %@!" = "Tervetuloa %@!"; + +/* No comment provided by engineer. */ +"Welcome message" = "Tervetuloviesti"; + +/* No comment provided by engineer. */ +"What's new" = "Uusimmat"; + +/* No comment provided by engineer. */ +"When available" = "Kun saatavilla"; + +/* No comment provided by engineer. */ +"When people request to connect, you can accept or reject it." = "Kun ihmiset pyytävät yhteyden muodostamista, voit hyväksyä tai hylätä sen."; + +/* No comment provided by engineer. */ +"When you share an incognito profile with somebody, this profile will be used for the groups they invite you to." = "Kun jaat inkognitoprofiilin jonkun kanssa, tätä profiilia käytetään ryhmissä, joihin tämä sinut kutsuu."; + +/* No comment provided by engineer. */ +"With optional welcome message." = "Valinnaisella tervetuloviestillä."; + +/* No comment provided by engineer. */ +"Wrong database passphrase" = "Väärä tietokannan tunnuslause"; + +/* No comment provided by engineer. */ +"Wrong passphrase!" = "Väärä tunnuslause!"; + +/* No comment provided by engineer. */ +"XFTP servers" = "XFTP-palvelimet"; + +/* pref value */ +"yes" = "kyllä"; + +/* No comment provided by engineer. */ +"You" = "Sinä"; + +/* No comment provided by engineer. */ +"You accepted connection" = "Hyväksyit yhteyden"; + +/* No comment provided by engineer. */ +"You allow" = "Sallit"; + +/* No comment provided by engineer. */ +"You already have a chat profile with the same display name. Please choose another name." = "Sinulla on jo keskusteluprofiili samalla näyttönimellä. Valitse toinen nimi."; + +/* No comment provided by engineer. */ +"You are already connected to %@." = "Olet jo muodostanut yhteyden %@:n kanssa."; + +/* No comment provided by engineer. */ +"You are connected to the server used to receive messages from this contact." = "Olet yhteydessä palvelimeen, jota käytetään vastaanottamaan viestejä tältä kontaktilta."; + +/* No comment provided by engineer. */ +"you are invited to group" = "sinut on kutsuttu ryhmään"; + +/* No comment provided by engineer. */ +"You are invited to group" = "Sinut on kutsuttu ryhmään"; + +/* No comment provided by engineer. */ +"you are observer" = "olet tarkkailija"; + +/* No comment provided by engineer. */ +"You can accept calls from lock screen, without device and app authentication." = "Voit vastaanottaa puheluita lukitusnäytöltä ilman laitteen ja sovelluksen todennusta."; + +/* No comment provided by engineer. */ +"You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button." = "Voit myös muodostaa yhteyden klikkaamalla linkkiä. Jos se avautuu selaimessa, napsauta **Avaa mobiilisovelluksessa**-painiketta."; + +/* No comment provided by engineer. */ +"You can create it later" = "Voit luoda sen myöhemmin"; + +/* No comment provided by engineer. */ +"You can enable later via Settings" = "Voit ottaa käyttöön myöhemmin asetusten kautta"; + +/* No comment provided by engineer. */ +"You can enable them later via app Privacy & Security settings." = "Voit ottaa ne käyttöön myöhemmin sovelluksen Yksityisyys & Turvallisuus -asetuksista."; + +/* No comment provided by engineer. */ +"You can hide or mute a user profile - swipe it to the right." = "Voit piilottaa tai mykistää käyttäjäprofiilin pyyhkäisemällä sitä oikealle."; + +/* notification body */ +"You can now send messages to %@" = "Voit nyt lähettää viestejä %@:lle"; + +/* No comment provided by engineer. */ +"You can set lock screen notification preview via settings." = "Voit määrittää lukitusnäytön ilmoituksen esikatselun asetuksista."; + +/* No comment provided by engineer. */ +"You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it." = "Voit jakaa linkin tai QR-koodin - kuka tahansa voi liittyä ryhmään. Et menetä ryhmän jäseniä, jos poistat sen myöhemmin."; + +/* No comment provided by engineer. */ +"You can share this address with your contacts to let them connect with **%@**." = "Voit jakaa tämän osoitteen kontaktiesi kanssa, jotta ne voivat muodostaa yhteyden **%@** kanssa."; + +/* No comment provided by engineer. */ +"You can share your address as a link or QR code - anybody can connect to you." = "Voit jakaa osoitteesi linkkinä tai QR-koodina - kuka tahansa voi muodostaa yhteyden sinuun."; + +/* No comment provided by engineer. */ +"You can start chat via app Settings / Database or by restarting the app" = "Voit aloittaa keskustelun sovelluksen Asetukset / Tietokanta kautta tai käynnistämällä sovelluksen uudelleen"; + +/* No comment provided by engineer. */ +"You can turn on SimpleX Lock via Settings." = "Voit ottaa SimpleX Lockin käyttöön Asetusten kautta."; + +/* No comment provided by engineer. */ +"You can use markdown to format messages:" = "Voit käyttää markdownia viestien muotoiluun:"; + +/* No comment provided by engineer. */ +"You can't send messages!" = "Et voi lähettää viestejä!"; + +/* chat item text */ +"you changed address" = "muutit osoitetta"; + +/* chat item text */ +"you changed address for %@" = "muutit osoitetta %@:ksi"; + +/* snd group event chat item */ +"you changed role for yourself to %@" = "vaihdoit roolin itsellesi %@:ksi"; + +/* snd group event chat item */ +"you changed role of %@ to %@" = "olet vaihtanut %1$@:n roolin %2$@:ksi"; + +/* No comment provided by engineer. */ +"You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them." = "Sinä hallitset, minkä palvelim(i)en kautta **viestit vastaanotetaan**, kontaktisi - palvelimet, joita käytät viestien lähettämiseen niille."; + +/* No comment provided by engineer. */ +"You could not be verified; please try again." = "Sinua ei voitu todentaa; yritä uudelleen."; + +/* No comment provided by engineer. */ +"You have no chats" = "Sinulla ei ole keskusteluja"; + +/* No comment provided by engineer. */ +"You have to enter passphrase every time the app starts - it is not stored on the device." = "Sinun on annettava tunnuslause aina, kun sovellus käynnistyy - sitä ei tallenneta laitteeseen."; + +/* No comment provided by engineer. */ +"You invited a contact" = "Kutsuit kontaktin"; + +/* No comment provided by engineer. */ +"You joined this group" = "Liityit tähän ryhmään"; + +/* No comment provided by engineer. */ +"You joined this group. Connecting to inviting group member." = "Liityit tähän ryhmään. Muodostetaan yhteyttä ryhmän jäsenten kutsumiseksi."; + +/* snd group event chat item */ +"you left" = "lähdit"; + +/* No comment provided by engineer. */ +"You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts." = "Sinun tulee käyttää keskustelujen-tietokannan uusinta versiota AINOSTAAN yhdessä laitteessa, muuten saatat lakata vastaanottamasta viestejä joiltakin kontakteilta."; + +/* No comment provided by engineer. */ +"You need to allow your contact to send voice messages to be able to send them." = "Sinun on sallittava kontaktiesi lähettää ääniviestejä, jotta voit lähettää niitä."; + +/* No comment provided by engineer. */ +"You rejected group invitation" = "Hylkäsit ryhmäkutsun"; + +/* snd group event chat item */ +"you removed %@" = "poistit %@"; + +/* No comment provided by engineer. */ +"You sent group invitation" = "Lähetit ryhmäkutsun"; + +/* chat list item description */ +"you shared one-time link" = "jaoit kertalinkin"; + +/* chat list item description */ +"you shared one-time link incognito" = "jaoit kertalinkin incognito-tilassa"; + +/* No comment provided by engineer. */ +"You will be connected to group when the group host's device is online, please wait or check later!" = "Sinut yhdistetään ryhmään, kun ryhmän isännän laite on online-tilassa, odota tai tarkista myöhemmin!"; + +/* No comment provided by engineer. */ +"You will be connected when your connection request is accepted, please wait or check later!" = "Sinut yhdistetään, kun yhteyspyyntösi on hyväksytty, odota tai tarkista myöhemmin!"; + +/* No comment provided by engineer. */ +"You will be connected when your contact's device is online, please wait or check later!" = "Sinut yhdistetään, kun kontaktisi laite on online-tilassa, odota tai tarkista myöhemmin!"; + +/* No comment provided by engineer. */ +"You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Sinun on tunnistauduttava, kun käynnistät sovelluksen tai jatkat sen käyttöä 30 sekunnin tauon jälkeen."; + +/* No comment provided by engineer. */ +"You will join a group this link refers to and connect to its group members." = "Liityt ryhmään, johon tämä linkki viittaa, ja muodostat yhteyden sen ryhmän jäseniin."; + +/* No comment provided by engineer. */ +"You will still receive calls and notifications from muted profiles when they are active." = "Saat edelleen puheluita ja ilmoituksia mykistetyiltä profiileilta, kun ne ovat aktiivisia."; + +/* No comment provided by engineer. */ +"You will stop receiving messages from this group. Chat history will be preserved." = "Et enää saa viestejä tästä ryhmästä. Keskusteluhistoria säilytetään."; + +/* No comment provided by engineer. */ +"You won't lose your contacts if you later delete your address." = "Et menetä kontaktejasi, jos poistat osoitteesi myöhemmin."; + +/* No comment provided by engineer. */ +"you: " = "sinä: "; + +/* No comment provided by engineer. */ +"You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile" = "Yrität kutsua kontaktia, jonka kanssa olet jakanut inkognito-profiilin, ryhmään, jossa käytät pääprofiiliasi"; + +/* No comment provided by engineer. */ +"You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed" = "Käytät tässä ryhmässä incognito-profiilia. Kontaktien kutsuminen ei ole sallittua, jotta pääprofiilisi ei tule jaetuksi"; + +/* No comment provided by engineer. */ +"Your %@ servers" = "%@-palvelimesi"; + +/* No comment provided by engineer. */ +"Your calls" = "Puhelusi"; + +/* No comment provided by engineer. */ +"Your chat database" = "Keskustelut-tietokantasi"; + +/* No comment provided by engineer. */ +"Your chat database is not encrypted - set passphrase to encrypt it." = "Keskustelut-tietokantasi ei ole salattu - aseta tunnuslause sen salaamiseksi."; + +/* No comment provided by engineer. */ +"Your chat profile will be sent to group members" = "Keskusteluprofiilisi lähetetään ryhmän jäsenille"; + +/* No comment provided by engineer. */ +"Your chat profiles" = "Keskusteluprofiilisi"; + +/* No comment provided by engineer. */ +"Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)." = "Kontaktin tulee olla online-tilassa, jotta yhteys voidaan muodostaa.\nVoit peruuttaa tämän yhteyden ja poistaa kontaktin (ja yrittää myöhemmin uudella linkillä)."; + +/* No comment provided by engineer. */ +"Your contact sent a file that is larger than currently supported maximum size (%@)." = "Yhteyshenkilösi lähetti tiedoston, joka on suurempi kuin tällä hetkellä tuettu enimmäiskoko (%@)."; + +/* No comment provided by engineer. */ +"Your contacts can allow full message deletion." = "Kontaktisi voivat sallia viestien täydellisen poistamisen."; + +/* No comment provided by engineer. */ +"Your contacts in SimpleX will see it.\nYou can change it in Settings." = "Kontaktisi SimpleX:ssä näkevät sen.\nVoit muuttaa sitä Asetuksista."; + +/* No comment provided by engineer. */ +"Your contacts will remain connected." = "Kontaktisi pysyvät yhdistettyinä."; + +/* No comment provided by engineer. */ +"Your current chat database will be DELETED and REPLACED with the imported one." = "Nykyinen keskustelut-tietokantasi poistetaan ja korvataan tuodulla tietokannalla."; + +/* No comment provided by engineer. */ +"Your current profile" = "Nykyinen profiilisi"; + +/* No comment provided by engineer. */ +"Your ICE servers" = "ICE-palvelimesi"; + +/* No comment provided by engineer. */ +"Your preferences" = "Asetuksesi"; + +/* No comment provided by engineer. */ +"Your privacy" = "Yksityisyytesi"; + +/* No comment provided by engineer. */ +"Your profile **%@** will be shared." = "Profiilisi **%@** jaetaan."; + +/* No comment provided by engineer. */ +"Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "Profiilisi tallennetaan laitteeseesi ja jaetaan vain yhteystietojesi kanssa.\nSimpleX-palvelimet eivät näe profiiliasi."; + +/* No comment provided by engineer. */ +"Your profile, contacts and delivered messages are stored on your device." = "Profiilisi, kontaktisi ja toimitetut viestit tallennetaan laitteellesi."; + +/* No comment provided by engineer. */ +"Your random profile" = "Satunnainen profiilisi"; + +/* No comment provided by engineer. */ +"Your server" = "Palvelimesi"; + +/* No comment provided by engineer. */ +"Your server address" = "Palvelimesi osoite"; + +/* No comment provided by engineer. */ +"Your settings" = "Asetuksesi"; + +/* No comment provided by engineer. */ +"Your SimpleX address" = "SimpleX-osoitteesi"; + +/* No comment provided by engineer. */ +"Your SMP servers" = "SMP-palvelimesi"; + +/* No comment provided by engineer. */ +"Your XFTP servers" = "XFTP-palvelimesi"; + diff --git a/apps/ios/fi.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/fi.lproj/SimpleX--iOS--InfoPlist.strings new file mode 100644 index 0000000000..969e43e449 --- /dev/null +++ b/apps/ios/fi.lproj/SimpleX--iOS--InfoPlist.strings @@ -0,0 +1,15 @@ +/* Bundle name */ +"CFBundleName" = "SimpleX"; + +/* Privacy - Camera Usage Description */ +"NSCameraUsageDescription" = "SimpleX tarvitsee pääsyn kameraan, jotta se voi skannata QR-koodeja muodostaakseen yhteyden muihin käyttäjiin ja videopuheluita varten."; + +/* Privacy - Face ID Usage Description */ +"NSFaceIDUsageDescription" = "SimpleX käyttää Face ID:tä paikalliseen todennukseen"; + +/* Privacy - Microphone Usage Description */ +"NSMicrophoneUsageDescription" = "SimpleX tarvitsee mikrofonia ääni- ja videopuheluita ja ääniviestien tallentamista varten."; + +/* Privacy - Photo Library Additions Usage Description */ +"NSPhotoLibraryAddUsageDescription" = "SimpleX tarvitsee pääsyn valokuvakirjastoon kuvattujen ja vastaanotettujen medioiden tallentamista varten"; + diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index b2428f5e78..9ce7245cd0 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -1273,7 +1273,7 @@ "encryption agreed for %@" = "chiffrement accepté pour %@"; /* chat item text */ -"encryption ok" = "chiffrement ok"; +"encryption ok" = "chiffrement OK"; /* chat item text */ "encryption ok for %@" = "chiffrement ok pour %@"; diff --git a/apps/ios/ja.lproj/Localizable.strings b/apps/ios/ja.lproj/Localizable.strings index 7a00b096ba..3d78a9f6e3 100644 --- a/apps/ios/ja.lproj/Localizable.strings +++ b/apps/ios/ja.lproj/Localizable.strings @@ -19,6 +19,9 @@ /* No comment provided by engineer. */ "_italic_" = "\\_斜体_"; +/* No comment provided by engineer. */ +"- more stable message delivery.\n- a bit better groups.\n- and more!" = "- より安定したメッセージ配信。\n- 改良されたグループ。\n- などなど!"; + /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- 最長 5 分間の音声メッセージ。\n- 消えるまでのカスタム時間。\n- 編集履歴。"; @@ -85,6 +88,15 @@ /* No comment provided by engineer. */ "*bold*" = "\\*太文字*"; +/* copied message info title, # <title> */ +"# %@" = "# %@"; + +/* copied message info */ +"## History" = "## 履歴"; + +/* copied message info */ +"## In reply to" = "## 返信先"; + /* No comment provided by engineer. */ "#secret#" = "シークレット"; @@ -103,6 +115,12 @@ /* No comment provided by engineer. */ "%@ %@" = "%@ %@"; +/* No comment provided by engineer. */ +"%@ and %@ connected" = "%@ と %@ は接続中"; + +/* copied message info, <sender> at <time> */ +"%@ at %@:" = "%1$@ at %2$@:"; + /* notification title */ "%@ is connected!" = "%@ 接続中!"; @@ -118,6 +136,9 @@ /* notification title */ "%@ wants to connect!" = "%@ が接続を希望しています!"; +/* No comment provided by engineer. */ +"%@, %@ and %lld other members connected" = "%@, %@ および %lld 人のメンバーが接続中"; + /* copied message info */ "%@:" = "%@:"; @@ -232,15 +253,30 @@ /* No comment provided by engineer. */ "30 seconds" = "30秒"; +/* No comment provided by engineer. */ +"A few more things" = "その他"; + /* notification title */ "A new contact" = "新しい連絡先"; +/* No comment provided by engineer. */ +"A new random profile will be shared." = "新しいランダムなプロファイルが共有されます。"; + /* No comment provided by engineer. */ "A separate TCP connection will be used **for each chat profile you have in the app**." = "**アプリ内のチャット プロフィールごとに**、個別の TCP 接続が使用されます。"; /* No comment provided by engineer. */ "A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." = "**アプリ内のチャット プロファイルごとに**、個別の TCP 接続が使用されます。\n**注意**:多くの接続がある場合、バッテリーと通信量の消費が大幅に増加し、一部の接続に失敗することがあります。"; +/* No comment provided by engineer. */ +"Abort" = "中止"; + +/* No comment provided by engineer. */ +"Abort changing address" = "アドレス変更の中止"; + +/* No comment provided by engineer. */ +"Abort changing address?" = "アドレス変更を中止しますか?"; + /* No comment provided by engineer. */ "About SimpleX" = "SimpleXについて"; @@ -296,6 +332,9 @@ /* No comment provided by engineer. */ "Address" = "アドレス"; +/* No comment provided by engineer. */ +"Address change will be aborted. Old receiving address will be used." = "アドレス変更は中止されます。古い受信アドレスが使用されます。"; + /* member role */ "admin" = "管理者"; @@ -305,6 +344,12 @@ /* No comment provided by engineer. */ "Advanced network settings" = "ネットワーク詳細設定"; +/* chat item text */ +"agreeing encryption for %@…" = "%@の暗号化に同意しています…"; + +/* chat item text */ +"agreeing encryption…" = "暗号化に同意しています…"; + /* No comment provided by engineer. */ "All app data is deleted." = "すべてのアプリデータが削除されます。"; @@ -353,6 +398,9 @@ /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages." = "送信済みメッセージの永久削除を許可する。"; +/* No comment provided by engineer. */ +"Allow to send files and media." = "ファイルやメディアの送信を許可する。"; + /* No comment provided by engineer. */ "Allow to send voice messages." = "音声メッセージの送信を許可する。"; @@ -467,6 +515,9 @@ /* No comment provided by engineer. */ "Bad message ID" = "メッセージ ID が正しくありません"; +/* No comment provided by engineer. */ +"Better messages" = "より良いメッセージ"; + /* No comment provided by engineer. */ "bold" = "太文字"; @@ -564,6 +615,12 @@ /* rcv group event chat item */ "changed your role to %@" = "あなたの役割を %@ に変更しました"; +/* chat item text */ +"changing address for %@…" = "%@ のアドレスを変更しています…"; + +/* chat item text */ +"changing address…" = "アドレスを変更しています…"; + /* No comment provided by engineer. */ "Chat archive" = "チャットのアーカイブ"; @@ -651,6 +708,12 @@ /* server test step */ "Connect" = "接続"; +/* No comment provided by engineer. */ +"Connect directly" = "直接接続する"; + +/* No comment provided by engineer. */ +"Connect incognito" = "シークレットモードで接続"; + /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "SimpleX Chat 開発者に接続します。"; @@ -750,6 +813,9 @@ /* No comment provided by engineer. */ "Contact preferences" = "連絡先の設定"; +/* No comment provided by engineer. */ +"Contacts" = "連絡先"; + /* No comment provided by engineer. */ "Contacts can mark messages for deletion; you will be able to view them." = "連絡先はメッセージを削除対象とすることができます。あなたには閲覧可能です。"; @@ -885,6 +951,12 @@ /* pref value */ "default (%@)" = "デフォルト (%@)"; +/* No comment provided by engineer. */ +"default (no)" = "デフォルト(いいえ)"; + +/* No comment provided by engineer. */ +"default (yes)" = "デフォルト(はい)"; + /* chat item action */ "Delete" = "削除"; @@ -1002,6 +1074,12 @@ /* rcv group event chat item */ "deleted group" = "削除されたグループ"; +/* No comment provided by engineer. */ +"Delivery" = "Delivery"; + +/* No comment provided by engineer. */ +"Delivery receipts are disabled!" = "Delivery receipts are disabled!"; + /* No comment provided by engineer. */ "Description" = "説明"; @@ -1035,9 +1113,18 @@ /* No comment provided by engineer. */ "Direct messages between members are prohibited in this group." = "このグループではメンバー間のダイレクトメッセージが使用禁止です。"; +/* No comment provided by engineer. */ +"Disable (keep overrides)" = "無効にする(設定の優先を維持)"; + +/* No comment provided by engineer. */ +"Disable for all" = "すべて無効"; + /* authentication reason */ "Disable SimpleX Lock" = "SimpleXロックを無効にする"; +/* No comment provided by engineer. */ +"disabled" = "無効"; + /* No comment provided by engineer. */ "Disappearing message" = "消えるメッセージ"; @@ -1074,6 +1161,9 @@ /* No comment provided by engineer. */ "Don't create address" = "アドレスを作成しないでください"; +/* No comment provided by engineer. */ +"Don't enable" = "有効にしない"; + /* No comment provided by engineer. */ "Don't show again" = "次から表示しない"; @@ -1104,9 +1194,15 @@ /* No comment provided by engineer. */ "Enable" = "有効"; +/* No comment provided by engineer. */ +"Enable (keep overrides)" = "有効にする(設定の優先を維持)"; + /* No comment provided by engineer. */ "Enable automatic message deletion?" = "自動メッセージ削除を有効にしますか?"; +/* No comment provided by engineer. */ +"Enable for all" = "すべて有効"; + /* No comment provided by engineer. */ "Enable instant notifications?" = "即時通知を有効にしますか?"; @@ -1167,6 +1263,30 @@ /* notification */ "Encrypted message: unexpected error" = "暗号化されたメッセージ : 予期しないエラー"; +/* chat item text */ +"encryption agreed" = "暗号化に同意しました"; + +/* chat item text */ +"encryption agreed for %@" = "%@ の暗号化に同意しました"; + +/* chat item text */ +"encryption ok" = "暗号化OK"; + +/* chat item text */ +"encryption ok for %@" = "%@ の暗号化OK"; + +/* chat item text */ +"encryption re-negotiation allowed" = "暗号化の再ネゴシエーションを許可"; + +/* chat item text */ +"encryption re-negotiation allowed for %@" = "%@ の暗号化の再ネゴシエーションを許可"; + +/* chat item text */ +"encryption re-negotiation required" = "暗号化の再ネゴシエーションが必要"; + +/* chat item text */ +"encryption re-negotiation required for %@" = "%@ の暗号化の再ネゴシエーションが必要"; + /* No comment provided by engineer. */ "ended" = "終了"; @@ -1200,6 +1320,9 @@ /* No comment provided by engineer. */ "Error" = "エラー"; +/* No comment provided by engineer. */ +"Error aborting address change" = "アドレス変更中止エラー"; + /* No comment provided by engineer. */ "Error accepting contact request" = "連絡先リクエストの承諾にエラー発生"; @@ -1311,6 +1434,9 @@ /* No comment provided by engineer. */ "Error switching profile!" = "プロフィール切り替えにエラー発生!"; +/* No comment provided by engineer. */ +"Error synchronizing connection" = "接続の同期エラー"; + /* No comment provided by engineer. */ "Error updating group link" = "グループのリンクのアップデートにエラー発生"; @@ -1335,6 +1461,12 @@ /* No comment provided by engineer. */ "Error: URL is invalid" = "エラー: 無効なURL"; +/* No comment provided by engineer. */ +"Even when disabled in the conversation." = "会話中に無効になっている場合でも。"; + +/* No comment provided by engineer. */ +"event happened" = "イベント発生"; + /* No comment provided by engineer. */ "Exit without saving" = "保存せずに閉じる"; @@ -1356,6 +1488,9 @@ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "送信者がオンラインになるまでの待ち時間がなく、速い!"; +/* No comment provided by engineer. */ +"Favorite" = "お気に入り"; + /* No comment provided by engineer. */ "File will be deleted from servers." = "ファイルはサーバーから削除されます。"; @@ -1371,6 +1506,42 @@ /* No comment provided by engineer. */ "Files & media" = "ファイルとメディア"; +/* chat feature */ +"Files and media" = "ファイルとメディア"; + +/* No comment provided by engineer. */ +"Files and media are prohibited in this group." = "このグループでは、ファイルとメディアは禁止されています。"; + +/* No comment provided by engineer. */ +"Files and media prohibited!" = "ファイルとメディアは禁止されています!"; + +/* No comment provided by engineer. */ +"Filter unread and favorite chats." = "未読とお気に入りをフィルターします。"; + +/* No comment provided by engineer. */ +"Finally, we have them! 🚀" = "ついに、私たちはそれらを手に入れました! 🚀"; + +/* No comment provided by engineer. */ +"Find chats faster" = "チャットを素早く検索"; + +/* No comment provided by engineer. */ +"Fix" = "修正"; + +/* No comment provided by engineer. */ +"Fix connection" = "接続を修正"; + +/* No comment provided by engineer. */ +"Fix connection?" = "接続を修正しますか?"; + +/* No comment provided by engineer. */ +"Fix encryption after restoring backups." = "バックアップの復元後に暗号化を修正します。"; + +/* No comment provided by engineer. */ +"Fix not supported by contact" = "連絡先による修正はサポートされていません"; + +/* No comment provided by engineer. */ +"Fix not supported by group member" = "グループメンバーによる修正はサポートされていません"; + /* No comment provided by engineer. */ "For console" = "コンソール"; @@ -1437,6 +1608,9 @@ /* No comment provided by engineer. */ "Group members can send disappearing messages." = "グループのメンバーが消えるメッセージを送信できます。"; +/* No comment provided by engineer. */ +"Group members can send files and media." = "グループメンバーはファイルやメディアを送信できます。"; + /* No comment provided by engineer. */ "Group members can send voice messages." = "グループのメンバーが音声メッセージを送信できます。"; @@ -1560,12 +1734,18 @@ /* No comment provided by engineer. */ "Improved server configuration" = "サーバ設定の向上"; +/* No comment provided by engineer. */ +"In reply to" = "返信先"; + /* No comment provided by engineer. */ "Incognito" = "シークレットモード"; /* No comment provided by engineer. */ "Incognito mode" = "シークレットモード"; +/* No comment provided by engineer. */ +"Incognito mode protects your privacy by using a new random profile for each contact." = "シークレットモードとは、メインのプロフィールとプロフィール画像を守るために、新しい連絡先を追加する時に、その連絡先に対してランダムなプロフィールが作成されるという対策です。"; + /* chat list item description */ "incognito via contact address link" = "連絡先リンク経由でシークレットモード"; @@ -1629,6 +1809,9 @@ /* No comment provided by engineer. */ "Invalid server address!" = "無効なサーバアドレス!"; +/* item status text */ +"Invalid status" = "無効なステータス"; + /* No comment provided by engineer. */ "Invitation expired!" = "招待が期限切れました!"; @@ -1707,6 +1890,9 @@ /* No comment provided by engineer. */ "Joining group" = "グループに参加"; +/* No comment provided by engineer. */ +"Keep your connections" = "接続を維持"; + /* No comment provided by engineer. */ "Keychain error" = "キーチェーンのエラー"; @@ -1764,6 +1950,9 @@ /* No comment provided by engineer. */ "Make a private connection" = "プライベートな接続をする"; +/* No comment provided by engineer. */ +"Make one message disappear" = "メッセージを1つ消す"; + /* No comment provided by engineer. */ "Make profile private!" = "プロフィールを非表示にできます!"; @@ -1881,6 +2070,9 @@ /* No comment provided by engineer. */ "More improvements are coming soon!" = "まだまだ改善してまいります!"; +/* item status description */ +"Most likely this connection is deleted." = "おそらく、この接続は削除されています。"; + /* No comment provided by engineer. */ "Most likely this contact has deleted the connection with you." = "恐らくこの連絡先があなたとの接続を削除しました。"; @@ -1953,21 +2145,33 @@ /* No comment provided by engineer. */ "No contacts to add" = "追加できる連絡先がありません"; +/* No comment provided by engineer. */ +"No delivery information" = "送信情報なし"; + /* No comment provided by engineer. */ "No device token!" = "デバイストークンがありません!"; /* No comment provided by engineer. */ "no e2e encryption" = "エンドツーエンド暗号化がありません"; +/* No comment provided by engineer. */ +"No filtered chats" = "フィルタされたチャットはありません"; + /* No comment provided by engineer. */ "No group!" = "グループが見つかりません!"; +/* No comment provided by engineer. */ +"No history" = "履歴はありません"; + /* No comment provided by engineer. */ "No permission to record voice message" = "音声メッセージを録音する権限がありません"; /* No comment provided by engineer. */ "No received or sent files" = "送受信済みのファイルがありません"; +/* copied message info in history */ +"no text" = "テキストなし"; + /* No comment provided by engineer. */ "Notifications" = "通知"; @@ -2026,6 +2230,9 @@ /* No comment provided by engineer. */ "Only group owners can change group preferences." = "グループ設定を変えられるのはグループのオーナーだけです。"; +/* No comment provided by engineer. */ +"Only group owners can enable files and media." = "ファイルやメディアを有効にできるのは、グループオーナーだけです。"; + /* No comment provided by engineer. */ "Only group owners can enable voice messages." = "音声メッセージを利用可能に設定できるのはグループのオーナーだけです。"; @@ -2227,6 +2434,9 @@ /* No comment provided by engineer. */ "Prohibit sending disappearing messages." = "消えるメッセージを使用禁止にする。"; +/* No comment provided by engineer. */ +"Prohibit sending files and media." = "ファイルやメディアの送信を禁止します。"; + /* No comment provided by engineer. */ "Prohibit sending voice messages." = "音声メッセージを使用禁止にする。"; @@ -2239,12 +2449,18 @@ /* No comment provided by engineer. */ "Protocol timeout" = "プロトコル・タイムアウト"; +/* No comment provided by engineer. */ +"Protocol timeout per KB" = "KB あたりのプロトコル タイムアウト"; + /* No comment provided by engineer. */ "Push notifications" = "プッシュ通知"; /* No comment provided by engineer. */ "Rate the app" = "アプリを評価"; +/* chat item menu */ +"React…" = "反応する…"; + /* No comment provided by engineer. */ "Read" = "読む"; @@ -2281,6 +2497,9 @@ /* message info title */ "Received message" = "受信したメッセージ"; +/* No comment provided by engineer. */ +"Receiving address will be changed to a different server. Address change will complete after sender comes online." = "開発中の機能です!相手のクライアントが4.2でなければ機能しません。アドレス変更が完了すると、会話にメッセージが出ます。連絡相手 (またはグループのメンバー) からメッセージを受信できないかをご確認ください。"; + /* No comment provided by engineer. */ "Receiving file will be stopped." = "ファイルの受信を停止します。"; @@ -2290,6 +2509,12 @@ /* No comment provided by engineer. */ "Recipients see updates as you type them." = "受信者には、入力時に更新内容が表示されます。"; +/* No comment provided by engineer. */ +"Reconnect all connected servers to force message delivery. It uses additional traffic." = "接続されているすべてのサーバーを再接続して、メッセージを強制的に配信します。 追加のトラフィックを使用します。"; + +/* No comment provided by engineer. */ +"Reconnect servers?" = "サーバーに再接続しますか?"; + /* No comment provided by engineer. */ "Record updated at" = "レコード更新日時"; @@ -2338,6 +2563,15 @@ /* rcv group event chat item */ "removed you" = "あなたを除名しました"; +/* No comment provided by engineer. */ +"Renegotiate" = "再ネゴシエート"; + +/* No comment provided by engineer. */ +"Renegotiate encryption" = "暗号化の再ネゴシエート"; + +/* No comment provided by engineer. */ +"Renegotiate encryption?" = "暗号化を再ネゴシエートしますか?"; + /* chat item action */ "Reply" = "返信"; @@ -2476,6 +2710,9 @@ /* No comment provided by engineer. */ "Security code" = "セキュリティコード"; +/* chat item text */ +"security code changed" = "セキュリティコードが変更されました"; + /* No comment provided by engineer. */ "Select" = "選択"; @@ -2614,6 +2851,9 @@ /* No comment provided by engineer. */ "Show developer options" = "開発者向けオプションを表示"; +/* No comment provided by engineer. */ +"Show last messages" = "最新のメッセージを表示"; + /* No comment provided by engineer. */ "Show preview" = "プレビューを表示"; @@ -2662,9 +2902,15 @@ /* No comment provided by engineer. */ "Skipped messages" = "飛ばしたメッセージ"; +/* No comment provided by engineer. */ +"Small groups (max 20)" = "小グループ(最大20名)"; + /* No comment provided by engineer. */ "SMP servers" = "SMPサーバ"; +/* No comment provided by engineer. */ +"Some non-fatal errors occurred during import - you may see Chat console for more details." = "インポート中に致命的でないエラーが発生しました - 詳細はチャットコンソールを参照してください。"; + /* notification title */ "Somebody" = "誰か"; @@ -2794,6 +3040,9 @@ /* No comment provided by engineer. */ "The created archive is available via app Settings / Database / Old database archive." = "作成されたアーカイブは、アプリの設定/データベース/過去のデータベースアーカイブから利用できます。"; +/* No comment provided by engineer. */ +"The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "暗号化は機能しており、新しい暗号化への同意は必要ありません。接続エラーが発生する可能性があります!"; + /* No comment provided by engineer. */ "The group is fully decentralized – it is visible only to the members." = "グループは完全分散型で、メンバーしか内容を見れません。"; @@ -2818,6 +3067,9 @@ /* No comment provided by engineer. */ "The profile is only shared with your contacts." = "プロフィールは連絡先にしか共有されません。"; +/* No comment provided by engineer. */ +"The second tick we missed! ✅" = "長らくお待たせしました! ✅"; + /* No comment provided by engineer. */ "The sender will NOT be notified" = "送信者には通知されません"; @@ -2833,6 +3085,12 @@ /* No comment provided by engineer. */ "There should be at least one visible user profile." = "少なくとも1つのユーザープロフィールが表示されている必要があります。"; +/* No comment provided by engineer. */ +"These settings are for your current profile **%@**." = "これらの設定は現在のプロファイル **%@** 用です。"; + +/* No comment provided by engineer. */ +"They can be overridden in contact and group settings." = "これらは連絡先の設定が優先します。"; + /* No comment provided by engineer. */ "This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "ファイルとメディアが全て削除されます (※元に戻せません※)。低解像度の画像が残ります。"; @@ -2908,6 +3166,9 @@ /* No comment provided by engineer. */ "Unexpected migration state" = "予期しない移行状態"; +/* No comment provided by engineer. */ +"Unfav." = "お気に入りを取り消す。"; + /* No comment provided by engineer. */ "Unhide" = "表示にする"; @@ -2986,12 +3247,18 @@ /* No comment provided by engineer. */ "Use chat" = "チャット"; +/* No comment provided by engineer. */ +"Use current profile" = "現在のプロファイルを使用する"; + /* No comment provided by engineer. */ "Use for new connections" = "新しい接続に使う"; /* No comment provided by engineer. */ "Use iOS call interface" = "iOS通話インターフェースを使用する"; +/* No comment provided by engineer. */ +"Use new incognito profile" = "新しいシークレットプロファイルを使用する"; + /* No comment provided by engineer. */ "Use server" = "サーバを使う"; @@ -3160,6 +3427,12 @@ /* No comment provided by engineer. */ "You can create it later" = "後からでも作成できます"; +/* No comment provided by engineer. */ +"You can enable later via Settings" = "あとで設定から有効にできます"; + +/* No comment provided by engineer. */ +"You can enable them later via app Privacy & Security settings." = "あとでアプリのプライバシーとセキュリティの設定から有効にすることができます。"; + /* No comment provided by engineer. */ "You can hide or mute a user profile - swipe it to the right." = "ユーザープロファイルを右にスワイプすると、非表示またはミュートにすることができます。"; @@ -3328,6 +3601,9 @@ /* No comment provided by engineer. */ "Your privacy" = "あなたのプライバシー"; +/* No comment provided by engineer. */ +"Your profile **%@** will be shared." = "あなたのプロファイル **%@** が共有されます。"; + /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "プロフィールはデバイスに保存され、連絡先とのみ共有されます。\nSimpleX サーバーはあなたのプロファイルを参照できません。"; diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index 79d7b619e6..199afb8422 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -209,7 +209,7 @@ "%lldw" = "%lldw"; /* No comment provided by engineer. */ -"%u messages failed to decrypt." = "%u-berichten kunnen niet worden gedecodeerd."; +"%u messages failed to decrypt." = "%u berichten kunnen niet worden ontsleuteld."; /* No comment provided by engineer. */ "%u messages skipped." = "%u berichten zijn overgeslagen."; @@ -1867,7 +1867,7 @@ "It allows having many anonymous connections without any shared data between them in a single chat profile." = "Het maakt het mogelijk om veel anonieme verbindingen te hebben zonder enige gedeelde gegevens tussen hen in een enkel chat profiel."; /* No comment provided by engineer. */ -"It can happen when you or your connection used the old database backup." = "Het kan gebeuren wanneer u of uw verbinding de oude databaseback-up gebruikte."; +"It can happen when you or your connection used the old database backup." = "Het kan gebeuren wanneer u of de ander een oude databaseback-up gebruikt."; /* No comment provided by engineer. */ "It can happen when:\n1. The messages expired in the sending client after 2 days or on the server after 30 days.\n2. Message decryption failed, because you or your contact used old database backup.\n3. The connection was compromised." = "Het kan gebeuren wanneer:\n1. De berichten zijn na 2 dagen verlopen bij de verzendende client of na 30 dagen op de server.\n2. Decodering van het bericht is mislukt, omdat u of uw contactpersoon een oude databaseback-up heeft gebruikt.\n3. De verbinding is verbroken."; @@ -3299,7 +3299,7 @@ "Use iOS call interface" = "De iOS-oproepinterface gebruiken"; /* No comment provided by engineer. */ -"Use new incognito profile" = "Gebruik een nieuw incognito -profiel"; +"Use new incognito profile" = "Gebruik een nieuw incognitoprofiel"; /* No comment provided by engineer. */ "Use server" = "Gebruik server"; diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index 4bf7ea8624..fc20b1c7f2 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -115,6 +115,9 @@ /* No comment provided by engineer. */ "%@ %@" = "%@ %@"; +/* No comment provided by engineer. */ +"%@ and %@ connected" = "%@ i %@ połączeni"; + /* copied message info, <sender> at <time> */ "%@ at %@:" = "%1$@ o %2$@:"; @@ -133,6 +136,9 @@ /* notification title */ "%@ wants to connect!" = "%@ chce się połączyć!"; +/* No comment provided by engineer. */ +"%@, %@ and %lld other members connected" = "%@, %@ i %lld innych członków połączeni"; + /* copied message info */ "%@:" = "%@:"; @@ -1467,6 +1473,9 @@ /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Nawet po wyłączeniu w rozmowie."; +/* No comment provided by engineer. */ +"event happened" = "nowe wydarzenie"; + /* No comment provided by engineer. */ "Exit without saving" = "Wyjdź bez zapisywania"; @@ -2881,6 +2890,9 @@ /* No comment provided by engineer. */ "Show developer options" = "Pokaż opcje dewelopera"; +/* No comment provided by engineer. */ +"Show last messages" = "Pokaż ostatnie wiadomości"; + /* No comment provided by engineer. */ "Show preview" = "Pokaż podgląd"; diff --git a/apps/ios/uk.lproj/Localizable.strings b/apps/ios/uk.lproj/Localizable.strings new file mode 100644 index 0000000000..a9213527c6 --- /dev/null +++ b/apps/ios/uk.lproj/Localizable.strings @@ -0,0 +1,3675 @@ +/* No comment provided by engineer. */ +"\n" = "\n"; + +/* No comment provided by engineer. */ +" " = " "; + +/* No comment provided by engineer. */ +" " = " "; + +/* No comment provided by engineer. */ +" " = " "; + +/* No comment provided by engineer. */ +" (" = " ("; + +/* No comment provided by engineer. */ +" (can be copied)" = " (можна скопіювати)"; + +/* No comment provided by engineer. */ +"_italic_" = "\\_курсив_"; + +/* No comment provided by engineer. */ +"- more stable message delivery.\n- a bit better groups.\n- and more!" = "- стабільніша доставка повідомлень.\n- трохи кращі групи.\n- і багато іншого!"; + +/* No comment provided by engineer. */ +"- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- голосові повідомлення до 5 хвилин.\n- користувальницький час зникнення.\n- історія редагування."; + +/* No comment provided by engineer. */ +", " = ", "; + +/* No comment provided by engineer. */ +": " = ": "; + +/* No comment provided by engineer. */ +"!1 colored!" = "!1 кольоровий!"; + +/* No comment provided by engineer. */ +"." = "."; + +/* No comment provided by engineer. */ +"(" = "("; + +/* No comment provided by engineer. */ +")" = ")"; + +/* No comment provided by engineer. */ +"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Внесок](https://github.com/simplex-chat/simplex-chat#contribute)"; + +/* No comment provided by engineer. */ +"[Send us email](mailto:chat@simplex.chat)" = "[Напишіть нам електронною поштою](mailto:chat@simplex.chat)"; + +/* No comment provided by engineer. */ +"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Зірка на GitHub](https://github.com/simplex-chat/simplex-chat)"; + +/* No comment provided by engineer. */ +"**Add new contact**: to create your one-time QR Code for your contact." = "**Додати новий контакт**: щоб створити одноразовий QR-код або посилання для свого контакту."; + +/* No comment provided by engineer. */ +"**Create link / QR code** for your contact to use." = "**Створіть посилання / QR-код** для використання вашим контактом."; + +/* No comment provided by engineer. */ +"**e2e encrypted** audio call" = "**e2e encrypted** аудіодзвінок"; + +/* No comment provided by engineer. */ +"**e2e encrypted** video call" = "**e2e encrypted** відеодзвінок"; + +/* No comment provided by engineer. */ +"**More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have." = "**Більш приватний**: перевіряти нові повідомлення кожні 20 хвилин. Серверу SimpleX Chat передається токен пристрою, але не кількість контактів або повідомлень, які ви маєте."; + +/* No comment provided by engineer. */ +"**Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app)." = "**Найбільш приватний**: не використовуйте сервер сповіщень SimpleX Chat, періодично перевіряйте повідомлення у фоновому режимі (залежить від того, як часто ви користуєтесь додатком)."; + +/* No comment provided by engineer. */ +"**Paste received link** or open it in the browser and tap **Open in mobile app**." = "**Вставте отримане посилання** або відкрийте його в браузері і натисніть **Відкрити в мобільному додатку**."; + +/* No comment provided by engineer. */ +"**Please note**: you will NOT be able to recover or change passphrase if you lose it." = "**Зверніть увагу: ви НЕ зможете відновити або змінити пароль, якщо втратите його."; + +/* No comment provided by engineer. */ +"**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from." = "**Рекомендується**: токен пристрою та сповіщення надсилаються на сервер сповіщень SimpleX Chat, але не вміст повідомлення, його розмір або від кого воно надійшло."; + +/* No comment provided by engineer. */ +"**Scan QR code**: to connect to your contact in person or via video call." = "**Відскануйте QR-код**: щоб з'єднатися з вашим контактом особисто або за допомогою відеодзвінка."; + +/* No comment provided by engineer. */ +"**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Попередження**: Для отримання миттєвих пуш-сповіщень потрібна парольна фраза, збережена у брелоку."; + +/* No comment provided by engineer. */ +"*bold*" = "\\*жирний*"; + +/* copied message info title, # <title> */ +"# %@" = "# %@"; + +/* copied message info */ +"## History" = "## Історія"; + +/* copied message info */ +"## In reply to" = "## У відповідь на"; + +/* No comment provided by engineer. */ +"#secret#" = "#секрет#"; + +/* No comment provided by engineer. */ +"%@" = "%@"; + +/* No comment provided by engineer. */ +"%@ (current)" = "%@ (поточний)"; + +/* copied message info */ +"%@ (current):" = "%@ (поточний):"; + +/* No comment provided by engineer. */ +"%@ / %@" = "%@ / %@"; + +/* No comment provided by engineer. */ +"%@ %@" = "%@ %@"; + +/* No comment provided by engineer. */ +"%@ and %@ connected" = "%@ і %@ підключено"; + +/* copied message info, <sender> at <time> */ +"%@ at %@:" = "%1$@ за %2$@:"; + +/* notification title */ +"%@ is connected!" = "%@ підключено!"; + +/* No comment provided by engineer. */ +"%@ is not verified" = "%@ не перевірено"; + +/* No comment provided by engineer. */ +"%@ is verified" = "%@ перевірено"; + +/* No comment provided by engineer. */ +"%@ servers" = "%@ сервери"; + +/* notification title */ +"%@ wants to connect!" = "%@ хоче підключитися!"; + +/* No comment provided by engineer. */ +"%@, %@ and %lld other members connected" = "%@, %@ та %lld інші підключені учасники"; + +/* copied message info */ +"%@:" = "%@:"; + +/* time interval */ +"%d days" = "%d днів"; + +/* time interval */ +"%d hours" = "%d годин"; + +/* time interval */ +"%d min" = "%d хв"; + +/* time interval */ +"%d months" = "%d місяців"; + +/* time interval */ +"%d sec" = "%d сек"; + +/* integrity error chat item */ +"%d skipped message(s)" = "%d пропущено повідомлення(ь)"; + +/* time interval */ +"%d weeks" = "%d тижнів"; + +/* No comment provided by engineer. */ +"%lld" = "%lld"; + +/* No comment provided by engineer. */ +"%lld %@" = "%lld %@"; + +/* No comment provided by engineer. */ +"%lld contact(s) selected" = "%lld контакт(и) вибрані"; + +/* No comment provided by engineer. */ +"%lld file(s) with total size of %@" = "%lld файл(и) загальним розміром %@"; + +/* No comment provided by engineer. */ +"%lld members" = "%lld учасників"; + +/* No comment provided by engineer. */ +"%lld minutes" = "%lld хвилин"; + +/* No comment provided by engineer. */ +"%lld second(s)" = "%lld секунд(и)"; + +/* No comment provided by engineer. */ +"%lld seconds" = "%lld секунд"; + +/* No comment provided by engineer. */ +"%lldd" = "%lldd"; + +/* No comment provided by engineer. */ +"%lldh" = "%lldh"; + +/* No comment provided by engineer. */ +"%lldk" = "%lldk"; + +/* No comment provided by engineer. */ +"%lldm" = "%lldm"; + +/* No comment provided by engineer. */ +"%lldmth" = "%lldmth"; + +/* No comment provided by engineer. */ +"%llds" = "%llds"; + +/* No comment provided by engineer. */ +"%lldw" = "%lldw"; + +/* No comment provided by engineer. */ +"%u messages failed to decrypt." = "%u повідомлень не вдалося розшифрувати."; + +/* No comment provided by engineer. */ +"%u messages skipped." = "%u повідомлень пропущено."; + +/* No comment provided by engineer. */ +"`a + b`" = "\\`a + b`"; + +/* email text */ +"<p>Hi!</p>\n<p><a href=\"%@\">Connect to me via SimpleX Chat</a></p>" = "<p>Привіт!</p>\n<p><a href=\"%@\"> Зв'яжіться зі мною через SimpleX Chat</a></p>"; + +/* No comment provided by engineer. */ +"~strike~" = "\\~закреслити~"; + +/* No comment provided by engineer. */ +"0s" = "0с"; + +/* time interval */ +"1 day" = "1 день"; + +/* time interval */ +"1 hour" = "1 година"; + +/* No comment provided by engineer. */ +"1 minute" = "1 хвилина"; + +/* time interval */ +"1 month" = "1 місяць"; + +/* time interval */ +"1 week" = "1 тиждень"; + +/* No comment provided by engineer. */ +"1-time link" = "1-разове посилання"; + +/* No comment provided by engineer. */ +"5 minutes" = "5 хвилин"; + +/* No comment provided by engineer. */ +"6" = "6"; + +/* No comment provided by engineer. */ +"30 seconds" = "30 секунд"; + +/* No comment provided by engineer. */ +"A few more things" = "Ще кілька речей"; + +/* notification title */ +"A new contact" = "Новий контакт"; + +/* No comment provided by engineer. */ +"A new random profile will be shared." = "Буде створено новий випадковий профіль."; + +/* No comment provided by engineer. */ +"A separate TCP connection will be used **for each chat profile you have in the app**." = "Для кожного профілю чату, який ви маєте в додатку, буде використовуватися окреме TCP-з'єднання."; + +/* No comment provided by engineer. */ +"A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." = "Для кожного контакту та учасника групи буде використовуватися окреме TCP-з'єднання.\n**Зверніть увагу: якщо у вас багато з'єднань, споживання заряду акумулятора і трафіку може бути значно вищим, а деякі з'єднання можуть обірватися."; + +/* No comment provided by engineer. */ +"Abort" = "Скасувати"; + +/* No comment provided by engineer. */ +"Abort changing address" = "Скасувати зміну адреси"; + +/* No comment provided by engineer. */ +"Abort changing address?" = "Скасувати зміну адреси?"; + +/* No comment provided by engineer. */ +"About SimpleX" = "Про SimpleX"; + +/* No comment provided by engineer. */ +"About SimpleX address" = "Про адресу SimpleX"; + +/* No comment provided by engineer. */ +"About SimpleX Chat" = "Про чат SimpleX"; + +/* No comment provided by engineer. */ +"above, then choose:" = "вище, а потім обирайте:"; + +/* No comment provided by engineer. */ +"Accent color" = "Акцентний колір"; + +/* accept contact request via notification + accept incoming call via notification */ +"Accept" = "Прийняти"; + +/* No comment provided by engineer. */ +"Accept connection request?" = "Прийняти запит на підключення?"; + +/* notification body */ +"Accept contact request from %@?" = "Прийняти запит на контакт від %@?"; + +/* accept contact request via notification */ +"Accept incognito" = "Прийняти інкогніто"; + +/* call status */ +"accepted call" = "прийнято виклик"; + +/* No comment provided by engineer. */ +"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Додайте адресу до свого профілю, щоб ваші контакти могли поділитися нею з іншими людьми. Повідомлення про оновлення профілю буде надіслано вашим контактам."; + +/* No comment provided by engineer. */ +"Add preset servers" = "Додавання попередньо встановлених серверів"; + +/* No comment provided by engineer. */ +"Add profile" = "Додати профіль"; + +/* No comment provided by engineer. */ +"Add server…" = "Додати сервер…"; + +/* No comment provided by engineer. */ +"Add servers by scanning QR codes." = "Додайте сервери, відсканувавши QR-код."; + +/* No comment provided by engineer. */ +"Add to another device" = "Додати до іншого пристрою"; + +/* No comment provided by engineer. */ +"Add welcome message" = "Додати вітальне повідомлення"; + +/* No comment provided by engineer. */ +"Address" = "Адреса"; + +/* No comment provided by engineer. */ +"Address change will be aborted. Old receiving address will be used." = "Зміна адреси буде скасована. Буде використано стару адресу отримання."; + +/* member role */ +"admin" = "адмін"; + +/* No comment provided by engineer. */ +"Admins can create the links to join groups." = "Адміни можуть створювати посилання для приєднання до груп."; + +/* No comment provided by engineer. */ +"Advanced network settings" = "Розширені налаштування мережі"; + +/* chat item text */ +"agreeing encryption for %@…" = "узгодження шифрування для %@…"; + +/* chat item text */ +"agreeing encryption…" = "узгодження шифрування…"; + +/* No comment provided by engineer. */ +"All app data is deleted." = "Всі дані програми видаляються."; + +/* No comment provided by engineer. */ +"All chats and messages will be deleted - this cannot be undone!" = "Всі чати та повідомлення будуть видалені - це неможливо скасувати!"; + +/* No comment provided by engineer. */ +"All data is erased when it is entered." = "Всі дані стираються при введенні."; + +/* No comment provided by engineer. */ +"All group members will remain connected." = "Всі учасники групи залишаться на зв'язку."; + +/* No comment provided by engineer. */ +"All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." = "Всі повідомлення будуть видалені - це неможливо скасувати! Повідомлення будуть видалені ТІЛЬКИ для вас."; + +/* No comment provided by engineer. */ +"All your contacts will remain connected." = "Всі ваші контакти залишаться на зв'язку."; + +/* No comment provided by engineer. */ +"All your contacts will remain connected. Profile update will be sent to your contacts." = "Всі ваші контакти залишаться на зв'язку. Повідомлення про оновлення профілю буде надіслано вашим контактам."; + +/* No comment provided by engineer. */ +"Allow" = "Дозволити"; + +/* No comment provided by engineer. */ +"Allow calls only if your contact allows them." = "Дозволяйте дзвінки, тільки якщо ваш контакт дозволяє їх."; + +/* No comment provided by engineer. */ +"Allow disappearing messages only if your contact allows it to you." = "Дозволяйте зникати повідомленням, тільки якщо контакт дозволяє вам це робити."; + +/* No comment provided by engineer. */ +"Allow irreversible message deletion only if your contact allows it to you." = "Дозволяйте безповоротне видалення повідомлень, тільки якщо контакт дозволяє вам це зробити."; + +/* No comment provided by engineer. */ +"Allow message reactions only if your contact allows them." = "Дозволяйте реакції на повідомлення, тільки якщо ваш контакт дозволяє їх."; + +/* No comment provided by engineer. */ +"Allow message reactions." = "Дозволити реакцію на повідомлення."; + +/* No comment provided by engineer. */ +"Allow sending direct messages to members." = "Дозволяє надсилати прямі повідомлення користувачам."; + +/* No comment provided by engineer. */ +"Allow sending disappearing messages." = "Дозволити надсилання зникаючих повідомлень."; + +/* No comment provided by engineer. */ +"Allow to irreversibly delete sent messages." = "Дозволяє безповоротно видаляти надіслані повідомлення."; + +/* No comment provided by engineer. */ +"Allow to send files and media." = "Дозволяє надсилати файли та медіа."; + +/* No comment provided by engineer. */ +"Allow to send voice messages." = "Дозволити надсилати голосові повідомлення."; + +/* No comment provided by engineer. */ +"Allow voice messages only if your contact allows them." = "Дозволяйте голосові повідомлення, тільки якщо ваш контакт дозволяє їх."; + +/* No comment provided by engineer. */ +"Allow voice messages?" = "Дозволити голосові повідомлення?"; + +/* No comment provided by engineer. */ +"Allow your contacts adding message reactions." = "Дозвольте вашим контактам додавати реакції на повідомлення."; + +/* No comment provided by engineer. */ +"Allow your contacts to call you." = "Дозвольте вашим контактам телефонувати вам."; + +/* No comment provided by engineer. */ +"Allow your contacts to irreversibly delete sent messages." = "Дозвольте вашим контактам безповоротно видаляти надіслані повідомлення."; + +/* No comment provided by engineer. */ +"Allow your contacts to send disappearing messages." = "Дозвольте своїм контактам надсилати зникаючі повідомлення."; + +/* No comment provided by engineer. */ +"Allow your contacts to send voice messages." = "Дозвольте своїм контактам надсилати голосові повідомлення."; + +/* No comment provided by engineer. */ +"Already connected?" = "Вже підключено?"; + +/* pref value */ +"always" = "завжди"; + +/* No comment provided by engineer. */ +"Always use relay" = "Завжди використовуйте реле"; + +/* No comment provided by engineer. */ +"An empty chat profile with the provided name is created, and the app opens as usual." = "Створюється порожній профіль чату з вказаним ім'ям, і додаток відкривається у звичайному режимі."; + +/* No comment provided by engineer. */ +"Answer call" = "Відповісти на дзвінок"; + +/* No comment provided by engineer. */ +"App build: %@" = "Збірка програми: %@"; + +/* No comment provided by engineer. */ +"App icon" = "Іконка програми"; + +/* No comment provided by engineer. */ +"App passcode" = "Пароль додатку"; + +/* No comment provided by engineer. */ +"App passcode is replaced with self-destruct passcode." = "Пароль програми замінено на пароль самознищення."; + +/* No comment provided by engineer. */ +"App version" = "Версія програми"; + +/* No comment provided by engineer. */ +"App version: v%@" = "Версія програми: v%@"; + +/* No comment provided by engineer. */ +"Appearance" = "Зовнішній вигляд"; + +/* No comment provided by engineer. */ +"Attach" = "Прикріпити"; + +/* No comment provided by engineer. */ +"Audio & video calls" = "Аудіо та відео дзвінки"; + +/* No comment provided by engineer. */ +"Audio and video calls" = "Аудіо та відеодзвінки"; + +/* No comment provided by engineer. */ +"audio call (not e2e encrypted)" = "аудіовиклик (без шифрування e2e)"; + +/* chat feature */ +"Audio/video calls" = "Аудіо/відео дзвінки"; + +/* No comment provided by engineer. */ +"Audio/video calls are prohibited." = "Аудіо/відео дзвінки заборонені."; + +/* PIN entry */ +"Authentication cancelled" = "Аутентифікацію скасовано"; + +/* No comment provided by engineer. */ +"Authentication failed" = "Не вдалося пройти автентифікацію"; + +/* No comment provided by engineer. */ +"Authentication is required before the call is connected, but you may miss calls." = "Перед з'єднанням дзвінка потрібно пройти автентифікацію, але ви можете пропустити дзвінки."; + +/* No comment provided by engineer. */ +"Authentication unavailable" = "Автентифікація недоступна"; + +/* No comment provided by engineer. */ +"Auto-accept" = "Автоприйняття"; + +/* No comment provided by engineer. */ +"Auto-accept contact requests" = "Автоматичне прийняття запитів на контакт"; + +/* No comment provided by engineer. */ +"Auto-accept images" = "Автоматичне прийняття зображень"; + +/* No comment provided by engineer. */ +"Back" = "Назад"; + +/* integrity error chat item */ +"bad message hash" = "невірний хеш повідомлення"; + +/* No comment provided by engineer. */ +"Bad message hash" = "Поганий хеш повідомлення"; + +/* integrity error chat item */ +"bad message ID" = "невірний ідентифікатор повідомлення"; + +/* No comment provided by engineer. */ +"Bad message ID" = "Неправильний ідентифікатор повідомлення"; + +/* No comment provided by engineer. */ +"Better messages" = "Кращі повідомлення"; + +/* No comment provided by engineer. */ +"bold" = "жирний"; + +/* No comment provided by engineer. */ +"Both you and your contact can add message reactions." = "Реакції на повідомлення можете додавати як ви, так і ваш контакт."; + +/* No comment provided by engineer. */ +"Both you and your contact can irreversibly delete sent messages." = "І ви, і ваш контакт можете безповоротно видалити надіслані повідомлення."; + +/* No comment provided by engineer. */ +"Both you and your contact can make calls." = "Дзвонити можете як ви, так і ваш контакт."; + +/* No comment provided by engineer. */ +"Both you and your contact can send disappearing messages." = "Ви і ваш контакт можете надсилати зникаючі повідомлення."; + +/* No comment provided by engineer. */ +"Both you and your contact can send voice messages." = "Надсилати голосові повідомлення можете як ви, так і ваш контакт."; + +/* No comment provided by engineer. */ +"By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Через профіль чату (за замовчуванням) або [за з'єднанням](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; + +/* No comment provided by engineer. */ +"Call already ended!" = "Дзвінок вже закінчився!"; + +/* call status */ +"call error" = "помилка дзвінка"; + +/* call status */ +"call in progress" = "виклик у процесі"; + +/* call status */ +"calling…" = "дзвоніть…"; + +/* No comment provided by engineer. */ +"Calls" = "Дзвінки"; + +/* No comment provided by engineer. */ +"Can't delete user profile!" = "Не можу видалити профіль користувача!"; + +/* No comment provided by engineer. */ +"Can't invite contact!" = "Не вдається запросити контакт!"; + +/* No comment provided by engineer. */ +"Can't invite contacts!" = "Неможливо запросити контакти!"; + +/* No comment provided by engineer. */ +"Cancel" = "Скасувати"; + +/* feature offered item */ +"cancelled %@" = "скасовано %@"; + +/* No comment provided by engineer. */ +"Cannot access keychain to save database password" = "Не вдається отримати доступ до зв'язки ключів для збереження пароля до бази даних"; + +/* No comment provided by engineer. */ +"Cannot receive file" = "Не вдається отримати файл"; + +/* No comment provided by engineer. */ +"Change" = "Зміна"; + +/* No comment provided by engineer. */ +"Change database passphrase?" = "Змінити пароль до бази даних?"; + +/* authentication reason */ +"Change lock mode" = "Зміна режиму блокування"; + +/* No comment provided by engineer. */ +"Change member role?" = "Змінити роль учасника?"; + +/* authentication reason */ +"Change passcode" = "Змінити пароль"; + +/* No comment provided by engineer. */ +"Change receiving address" = "Змінити адресу отримання"; + +/* No comment provided by engineer. */ +"Change receiving address?" = "Змінити адресу отримання?"; + +/* No comment provided by engineer. */ +"Change role" = "Змінити роль"; + +/* authentication reason */ +"Change self-destruct mode" = "Змінити режим самознищення"; + +/* authentication reason + set passcode view */ +"Change self-destruct passcode" = "Змінити пароль самознищення"; + +/* chat item text */ +"changed address for you" = "змінили для вас адресу"; + +/* rcv group event chat item */ +"changed role of %@ to %@" = "змінено роль %1$@ на %2$@"; + +/* rcv group event chat item */ +"changed your role to %@" = "змінили свою роль на %@"; + +/* chat item text */ +"changing address for %@…" = "зміна адреси для %@…"; + +/* chat item text */ +"changing address…" = "змінює адресу…"; + +/* No comment provided by engineer. */ +"Chat archive" = "Архів чату"; + +/* No comment provided by engineer. */ +"Chat console" = "Консоль чату"; + +/* No comment provided by engineer. */ +"Chat database" = "База даних чату"; + +/* No comment provided by engineer. */ +"Chat database deleted" = "Видалено базу даних чату"; + +/* No comment provided by engineer. */ +"Chat database imported" = "Імпорт бази даних чату"; + +/* No comment provided by engineer. */ +"Chat is running" = "Чат запущено"; + +/* No comment provided by engineer. */ +"Chat is stopped" = "Чат зупинено"; + +/* No comment provided by engineer. */ +"Chat preferences" = "Налаштування чату"; + +/* No comment provided by engineer. */ +"Chats" = "Чати"; + +/* No comment provided by engineer. */ +"Check server address and try again." = "Перевірте адресу сервера та спробуйте ще раз."; + +/* No comment provided by engineer. */ +"Chinese and Spanish interface" = "Інтерфейс китайською та іспанською мовами"; + +/* No comment provided by engineer. */ +"Choose file" = "Виберіть файл"; + +/* No comment provided by engineer. */ +"Choose from library" = "Виберіть з бібліотеки"; + +/* No comment provided by engineer. */ +"Clear" = "Чисто"; + +/* No comment provided by engineer. */ +"Clear conversation" = "Ясна розмова"; + +/* No comment provided by engineer. */ +"Clear conversation?" = "Відверта розмова?"; + +/* No comment provided by engineer. */ +"Clear verification" = "Очистити перевірку"; + +/* No comment provided by engineer. */ +"colored" = "кольоровий"; + +/* No comment provided by engineer. */ +"Colors" = "Кольори"; + +/* server test step */ +"Compare file" = "Порівняти файл"; + +/* No comment provided by engineer. */ +"Compare security codes with your contacts." = "Порівняйте коди безпеки зі своїми контактами."; + +/* No comment provided by engineer. */ +"complete" = "завершено"; + +/* No comment provided by engineer. */ +"Configure ICE servers" = "Налаштування серверів ICE"; + +/* No comment provided by engineer. */ +"Confirm" = "Підтвердити"; + +/* No comment provided by engineer. */ +"Confirm database upgrades" = "Підтвердити оновлення бази даних"; + +/* No comment provided by engineer. */ +"Confirm new passphrase…" = "Підтвердіть нову парольну фразу…"; + +/* No comment provided by engineer. */ +"Confirm Passcode" = "Підтвердити пароль"; + +/* No comment provided by engineer. */ +"Confirm password" = "Підтвердити пароль"; + +/* server test step */ +"Connect" = "Підключіться"; + +/* No comment provided by engineer. */ +"Connect directly" = "Підключіться безпосередньо"; + +/* No comment provided by engineer. */ +"Connect incognito" = "Підключайтеся інкогніто"; + +/* No comment provided by engineer. */ +"connect to SimpleX Chat developers." = "зв'язатися з розробниками SimpleX Chat."; + +/* No comment provided by engineer. */ +"Connect via contact link" = "Підключіться за контактним посиланням"; + +/* No comment provided by engineer. */ +"Connect via group link?" = "Підключитися за груповим посиланням?"; + +/* No comment provided by engineer. */ +"Connect via link" = "Підключіться за посиланням"; + +/* No comment provided by engineer. */ +"Connect via link / QR code" = "Підключитися за посиланням / QR-кодом"; + +/* No comment provided by engineer. */ +"Connect via one-time link" = "Під'єднатися за одноразовим посиланням"; + +/* No comment provided by engineer. */ +"connected" = "з'єднаний"; + +/* No comment provided by engineer. */ +"connecting" = "з'єднання"; + +/* No comment provided by engineer. */ +"connecting (accepted)" = "з'єднання (прийнято)"; + +/* No comment provided by engineer. */ +"connecting (announced)" = "з'єднання (оголошено)"; + +/* No comment provided by engineer. */ +"connecting (introduced)" = "з'єднання (введено)"; + +/* No comment provided by engineer. */ +"connecting (introduction invitation)" = "з'єднання (вступне запрошення)"; + +/* call status */ +"connecting call" = "підключення дзвінка…"; + +/* No comment provided by engineer. */ +"Connecting server…" = "Підключення до сервера…"; + +/* No comment provided by engineer. */ +"Connecting server… (error: %@)" = "Підключення до сервера... (помилка: %@)"; + +/* chat list item title */ +"connecting…" = "з'єднання…"; + +/* No comment provided by engineer. */ +"Connection" = "Підключення"; + +/* No comment provided by engineer. */ +"Connection error" = "Помилка підключення"; + +/* No comment provided by engineer. */ +"Connection error (AUTH)" = "Помилка підключення (AUTH)"; + +/* chat list item title (it should not be shown */ +"connection established" = "з'єднання встановлене"; + +/* No comment provided by engineer. */ +"Connection request sent!" = "Запит на підключення відправлено!"; + +/* No comment provided by engineer. */ +"Connection timeout" = "Тайм-аут з'єднання"; + +/* connection information */ +"connection:%@" = "з'єднання:%@"; + +/* No comment provided by engineer. */ +"Contact allows" = "Контакт дозволяє"; + +/* No comment provided by engineer. */ +"Contact already exists" = "Контакт вже існує"; + +/* No comment provided by engineer. */ +"Contact and all messages will be deleted - this cannot be undone!" = "Контакт і всі повідомлення будуть видалені - це неможливо скасувати!"; + +/* No comment provided by engineer. */ +"contact has e2e encryption" = "контакт має шифрування e2e"; + +/* No comment provided by engineer. */ +"contact has no e2e encryption" = "контакт не має шифрування e2e"; + +/* notification */ +"Contact hidden:" = "Контакт приховано:"; + +/* notification */ +"Contact is connected" = "Контакт підключений"; + +/* No comment provided by engineer. */ +"Contact is not connected yet!" = "Контакт ще не підключено!"; + +/* No comment provided by engineer. */ +"Contact name" = "Ім'я контактної особи"; + +/* No comment provided by engineer. */ +"Contact preferences" = "Налаштування контактів"; + +/* No comment provided by engineer. */ +"Contacts" = "Контакти"; + +/* No comment provided by engineer. */ +"Contacts can mark messages for deletion; you will be able to view them." = "Контакти можуть позначати повідомлення для видалення; ви зможете їх переглянути."; + +/* No comment provided by engineer. */ +"Continue" = "Продовжуйте"; + +/* chat item action */ +"Copy" = "Копіювати"; + +/* No comment provided by engineer. */ +"Core version: v%@" = "Основна версія: v%@"; + +/* No comment provided by engineer. */ +"Create" = "Створити"; + +/* No comment provided by engineer. */ +"Create an address to let people connect with you." = "Створіть адресу, щоб люди могли з вами зв'язатися."; + +/* server test step */ +"Create file" = "Створити файл"; + +/* No comment provided by engineer. */ +"Create group link" = "Створити групове посилання"; + +/* No comment provided by engineer. */ +"Create link" = "Створити посилання"; + +/* No comment provided by engineer. */ +"Create one-time invitation link" = "Створіть одноразове посилання-запрошення"; + +/* server test step */ +"Create queue" = "Створити чергу"; + +/* No comment provided by engineer. */ +"Create secret group" = "Створити секретну групу"; + +/* No comment provided by engineer. */ +"Create SimpleX address" = "Створіть адресу SimpleX"; + +/* No comment provided by engineer. */ +"Create your profile" = "Створіть свій профіль"; + +/* No comment provided by engineer. */ +"Created on %@" = "Створено %@"; + +/* No comment provided by engineer. */ +"creator" = "творець"; + +/* No comment provided by engineer. */ +"Current Passcode" = "Поточний пароль"; + +/* No comment provided by engineer. */ +"Current passphrase…" = "Поточна парольна фраза…"; + +/* No comment provided by engineer. */ +"Currently maximum supported file size is %@." = "Наразі максимальний підтримуваний розмір файлу - %@."; + +/* dropdown time picker choice */ +"custom" = "звичайний"; + +/* No comment provided by engineer. */ +"Custom time" = "Індивідуальний час"; + +/* No comment provided by engineer. */ +"Dark" = "Темний"; + +/* No comment provided by engineer. */ +"Database downgrade" = "Пониження версії бази даних"; + +/* No comment provided by engineer. */ +"Database encrypted!" = "База даних зашифрована!"; + +/* No comment provided by engineer. */ +"Database encryption passphrase will be updated and stored in the keychain.\n" = "Парольна фраза шифрування бази даних буде оновлена та збережена у в’язці ключів.\n"; + +/* No comment provided by engineer. */ +"Database encryption passphrase will be updated.\n" = "Ключову фразу шифрування бази даних буде оновлено.\n"; + +/* No comment provided by engineer. */ +"Database error" = "Помилка в базі даних"; + +/* No comment provided by engineer. */ +"Database ID" = "Ідентифікатор бази даних"; + +/* copied message info */ +"Database ID: %d" = "Ідентифікатор бази даних: %d"; + +/* No comment provided by engineer. */ +"Database IDs and Transport isolation option." = "Ідентифікатори бази даних та опція ізоляції транспорту."; + +/* No comment provided by engineer. */ +"Database is encrypted using a random passphrase, you can change it." = "База даних зашифрована за допомогою випадкової парольної фрази, яку ви можете змінити."; + +/* No comment provided by engineer. */ +"Database is encrypted using a random passphrase. Please change it before exporting." = "База даних зашифрована за допомогою випадкової парольної фрази. Будь ласка, змініть його перед експортом."; + +/* No comment provided by engineer. */ +"Database passphrase" = "Ключова фраза бази даних"; + +/* No comment provided by engineer. */ +"Database passphrase & export" = "Ключова фраза бази даних та експорт"; + +/* No comment provided by engineer. */ +"Database passphrase is different from saved in the keychain." = "Парольна фраза бази даних відрізняється від збереженої у в’язці ключів."; + +/* No comment provided by engineer. */ +"Database passphrase is required to open chat." = "Для відкриття чату потрібно ввести пароль до бази даних."; + +/* No comment provided by engineer. */ +"Database upgrade" = "Оновлення бази даних"; + +/* No comment provided by engineer. */ +"database version is newer than the app, but no down migration for: %@" = "версія бази даних новіша, ніж додаток, але без міграції вниз для: %@"; + +/* No comment provided by engineer. */ +"Database will be encrypted and the passphrase stored in the keychain.\n" = "База даних буде зашифрована, а парольна фраза збережена у в’язці ключів.\n"; + +/* No comment provided by engineer. */ +"Database will be encrypted.\n" = "База даних буде зашифрована.\n"; + +/* No comment provided by engineer. */ +"Database will be migrated when the app restarts" = "База даних буде перенесена під час перезапуску програми"; + +/* time unit */ +"days" = "днів"; + +/* No comment provided by engineer. */ +"Decentralized" = "Децентралізований"; + +/* message decrypt error item */ +"Decryption error" = "Помилка розшифровки"; + +/* pref value */ +"default (%@)" = "за замовчуванням (%@)"; + +/* No comment provided by engineer. */ +"default (no)" = "за замовчуванням (ні)"; + +/* No comment provided by engineer. */ +"default (yes)" = "за замовчуванням (так)"; + +/* chat item action */ +"Delete" = "Видалити"; + +/* No comment provided by engineer. */ +"Delete address" = "Видалити адресу"; + +/* No comment provided by engineer. */ +"Delete address?" = "Видалити адресу?"; + +/* No comment provided by engineer. */ +"Delete after" = "Видалити після"; + +/* No comment provided by engineer. */ +"Delete all files" = "Видалити всі файли"; + +/* No comment provided by engineer. */ +"Delete archive" = "Видалити архів"; + +/* No comment provided by engineer. */ +"Delete chat archive?" = "Видалити архів чату?"; + +/* No comment provided by engineer. */ +"Delete chat profile" = "Видалити профіль чату"; + +/* No comment provided by engineer. */ +"Delete chat profile?" = "Видалити профіль чату?"; + +/* No comment provided by engineer. */ +"Delete connection" = "Видалити підключення"; + +/* No comment provided by engineer. */ +"Delete contact" = "Видалити контакт"; + +/* No comment provided by engineer. */ +"Delete Contact" = "Видалити контакт"; + +/* No comment provided by engineer. */ +"Delete contact?" = "Видалити контакт?"; + +/* No comment provided by engineer. */ +"Delete database" = "Видалити базу даних"; + +/* server test step */ +"Delete file" = "Видалити файл"; + +/* No comment provided by engineer. */ +"Delete files and media?" = "Видаляти файли та медіа?"; + +/* No comment provided by engineer. */ +"Delete files for all chat profiles" = "Видалення файлів для всіх профілів чату"; + +/* chat feature */ +"Delete for everyone" = "Видалити для всіх"; + +/* No comment provided by engineer. */ +"Delete for me" = "Видалити для мене"; + +/* No comment provided by engineer. */ +"Delete group" = "Видалити групу"; + +/* No comment provided by engineer. */ +"Delete group?" = "Видалити групу?"; + +/* No comment provided by engineer. */ +"Delete invitation" = "Видалити запрошення"; + +/* No comment provided by engineer. */ +"Delete link" = "Видалити посилання"; + +/* No comment provided by engineer. */ +"Delete link?" = "Видалити посилання?"; + +/* No comment provided by engineer. */ +"Delete member message?" = "Видалити повідомлення учасника?"; + +/* No comment provided by engineer. */ +"Delete message?" = "Видалити повідомлення?"; + +/* No comment provided by engineer. */ +"Delete messages" = "Видалити повідомлення"; + +/* No comment provided by engineer. */ +"Delete messages after" = "Видаляйте повідомлення після"; + +/* No comment provided by engineer. */ +"Delete old database" = "Видалення старої бази даних"; + +/* No comment provided by engineer. */ +"Delete old database?" = "Видалити стару базу даних?"; + +/* No comment provided by engineer. */ +"Delete pending connection" = "Видалити очікуване з'єднання"; + +/* No comment provided by engineer. */ +"Delete pending connection?" = "Видалити очікуване з'єднання?"; + +/* No comment provided by engineer. */ +"Delete profile" = "Видалити профіль"; + +/* server test step */ +"Delete queue" = "Видалити чергу"; + +/* No comment provided by engineer. */ +"Delete user profile?" = "Видалити профіль користувача?"; + +/* deleted chat item */ +"deleted" = "видалено"; + +/* No comment provided by engineer. */ +"Deleted at" = "Видалено за"; + +/* copied message info */ +"Deleted at: %@" = "Видалено за: %@"; + +/* rcv group event chat item */ +"deleted group" = "видалено групу"; + +/* No comment provided by engineer. */ +"Delivery" = "Доставка"; + +/* No comment provided by engineer. */ +"Delivery receipts are disabled!" = "Квитанції про доставку відключені!"; + +/* No comment provided by engineer. */ +"Delivery receipts!" = "Квитанції про доставку!"; + +/* No comment provided by engineer. */ +"Description" = "Опис"; + +/* No comment provided by engineer. */ +"Develop" = "Розробник"; + +/* No comment provided by engineer. */ +"Developer tools" = "Інструменти для розробників"; + +/* No comment provided by engineer. */ +"Device" = "Пристрій"; + +/* No comment provided by engineer. */ +"Device authentication is disabled. Turning off SimpleX Lock." = "Автентифікацію пристрою вимкнено. Вимкнення SimpleX Lock."; + +/* No comment provided by engineer. */ +"Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication." = "Автентифікація пристрою не ввімкнена. Ви можете увімкнути SimpleX Lock у Налаштуваннях, коли увімкнете автентифікацію пристрою."; + +/* No comment provided by engineer. */ +"different migration in the app/database: %@ / %@" = "різна міграція в додатку/базі даних: %@ / %@"; + +/* No comment provided by engineer. */ +"Different names, avatars and transport isolation." = "Різні імена, аватарки та транспортна ізоляція."; + +/* connection level description */ +"direct" = "прямо"; + +/* chat feature */ +"Direct messages" = "Прямі повідомлення"; + +/* No comment provided by engineer. */ +"Direct messages between members are prohibited in this group." = "У цій групі заборонені прямі повідомлення між учасниками."; + +/* No comment provided by engineer. */ +"Disable (keep overrides)" = "Вимкнути (зберегти перевизначення)"; + +/* No comment provided by engineer. */ +"Disable for all" = "Вимкнути для всіх"; + +/* authentication reason */ +"Disable SimpleX Lock" = "Вимкнути SimpleX Lock"; + +/* No comment provided by engineer. */ +"disabled" = "вимкнено"; + +/* No comment provided by engineer. */ +"Disappearing message" = "Зникаюче повідомлення"; + +/* chat feature */ +"Disappearing messages" = "Зникаючі повідомлення"; + +/* No comment provided by engineer. */ +"Disappearing messages are prohibited in this chat." = "Зникаючі повідомлення в цьому чаті заборонені."; + +/* No comment provided by engineer. */ +"Disappearing messages are prohibited in this group." = "У цій групі заборонено зникаючі повідомлення."; + +/* No comment provided by engineer. */ +"Disappears at" = "Зникає за"; + +/* copied message info */ +"Disappears at: %@" = "Зникає за: %@"; + +/* server test step */ +"Disconnect" = "Від'єднати"; + +/* No comment provided by engineer. */ +"Display name" = "Відображуване ім'я"; + +/* No comment provided by engineer. */ +"Display name:" = "Відображуване ім'я:"; + +/* No comment provided by engineer. */ +"Do it later" = "Зробіть це пізніше"; + +/* No comment provided by engineer. */ +"Do NOT use SimpleX for emergency calls." = "НЕ використовуйте SimpleX для екстрених викликів."; + +/* No comment provided by engineer. */ +"Don't create address" = "Не створювати адресу"; + +/* No comment provided by engineer. */ +"Don't enable" = "Не вмикати"; + +/* No comment provided by engineer. */ +"Don't show again" = "Більше не показувати"; + +/* No comment provided by engineer. */ +"Downgrade and open chat" = "Пониження та відкритий чат"; + +/* server test step */ +"Download file" = "Завантажити файл"; + +/* No comment provided by engineer. */ +"Duplicate display name!" = "Дублююче ім'я користувача!"; + +/* integrity error chat item */ +"duplicate message" = "дублююче повідомлення"; + +/* No comment provided by engineer. */ +"Duration" = "Тривалість"; + +/* No comment provided by engineer. */ +"e2e encrypted" = "e2e зашифрований"; + +/* chat item action */ +"Edit" = "Редагувати"; + +/* No comment provided by engineer. */ +"Edit group profile" = "Редагування профілю групи"; + +/* No comment provided by engineer. */ +"Enable" = "Увімкнути"; + +/* No comment provided by engineer. */ +"Enable (keep overrides)" = "Увімкнути (зберегти перевизначення)"; + +/* No comment provided by engineer. */ +"Enable automatic message deletion?" = "Увімкнути автоматичне видалення повідомлень?"; + +/* No comment provided by engineer. */ +"Enable for all" = "Увімкнути для всіх"; + +/* No comment provided by engineer. */ +"Enable instant notifications?" = "Увімкнути миттєві сповіщення?"; + +/* No comment provided by engineer. */ +"Enable lock" = "Увімкнути блокування"; + +/* No comment provided by engineer. */ +"Enable notifications" = "Увімкнути сповіщення"; + +/* No comment provided by engineer. */ +"Enable periodic notifications?" = "Увімкнути періодичні сповіщення?"; + +/* No comment provided by engineer. */ +"Enable self-destruct" = "Увімкнути самознищення"; + +/* set passcode view */ +"Enable self-destruct passcode" = "Увімкнути пароль самознищення"; + +/* authentication reason */ +"Enable SimpleX Lock" = "Увімкнути SimpleX Lock"; + +/* No comment provided by engineer. */ +"Enable TCP keep-alive" = "Увімкнути TCP keep-alive"; + +/* enabled status */ +"enabled" = "увімкнено"; + +/* enabled status */ +"enabled for contact" = "увімкнено для контакту"; + +/* enabled status */ +"enabled for you" = "увімкнено для вас"; + +/* No comment provided by engineer. */ +"Encrypt" = "Зашифрувати"; + +/* No comment provided by engineer. */ +"Encrypt database?" = "Зашифрувати базу даних?"; + +/* No comment provided by engineer. */ +"Encrypted database" = "Зашифрована база даних"; + +/* notification */ +"Encrypted message or another event" = "Зашифроване повідомлення або інша подія"; + +/* notification */ +"Encrypted message: database error" = "Зашифроване повідомлення: помилка бази даних"; + +/* notification */ +"Encrypted message: database migration error" = "Зашифроване повідомлення: помилка міграції бази даних"; + +/* notification */ +"Encrypted message: keychain error" = "Зашифроване повідомлення: помилка ланцюжка ключів"; + +/* notification */ +"Encrypted message: no passphrase" = "Зашифроване повідомлення: без ключової фрази"; + +/* notification */ +"Encrypted message: unexpected error" = "Зашифроване повідомлення: несподівана помилка"; + +/* chat item text */ +"encryption agreed" = "узгоджено шифрування"; + +/* chat item text */ +"encryption agreed for %@" = "узгоджене шифрування для %@"; + +/* chat item text */ +"encryption ok" = "шифрування ok"; + +/* chat item text */ +"encryption ok for %@" = "шифрування ok для %@"; + +/* chat item text */ +"encryption re-negotiation allowed" = "переузгодження шифрування дозволено"; + +/* chat item text */ +"encryption re-negotiation allowed for %@" = "переузгодження шифрування дозволено для %@"; + +/* chat item text */ +"encryption re-negotiation required" = "потрібне повторне узгодження шифрування"; + +/* chat item text */ +"encryption re-negotiation required for %@" = "для %@ потрібне повторне узгодження шифрування"; + +/* No comment provided by engineer. */ +"ended" = "закінчився"; + +/* call status */ +"ended call %@" = "закінчився виклик %@"; + +/* No comment provided by engineer. */ +"Enter correct passphrase." = "Введіть правильну парольну фразу."; + +/* No comment provided by engineer. */ +"Enter Passcode" = "Введіть пароль"; + +/* No comment provided by engineer. */ +"Enter passphrase…" = "Введіть пароль…"; + +/* No comment provided by engineer. */ +"Enter password above to show!" = "Введіть пароль вище, щоб показати!"; + +/* No comment provided by engineer. */ +"Enter server manually" = "Увійдіть на сервер вручну"; + +/* placeholder */ +"Enter welcome message…" = "Введіть вітальне повідомлення…"; + +/* placeholder */ +"Enter welcome message… (optional)" = "Введіть вітальне повідомлення... (необов'язково)"; + +/* No comment provided by engineer. */ +"error" = "помилка"; + +/* No comment provided by engineer. */ +"Error" = "Помилка"; + +/* No comment provided by engineer. */ +"Error aborting address change" = "Помилка скасування зміни адреси"; + +/* No comment provided by engineer. */ +"Error accepting contact request" = "Помилка при прийнятті запиту на контакт"; + +/* No comment provided by engineer. */ +"Error accessing database file" = "Помилка доступу до файлу бази даних"; + +/* No comment provided by engineer. */ +"Error adding member(s)" = "Помилка додавання користувача(ів)"; + +/* No comment provided by engineer. */ +"Error changing address" = "Помилка зміни адреси"; + +/* No comment provided by engineer. */ +"Error changing role" = "Помилка зміни ролі"; + +/* No comment provided by engineer. */ +"Error changing setting" = "Помилка зміни налаштування"; + +/* No comment provided by engineer. */ +"Error creating address" = "Помилка створення адреси"; + +/* No comment provided by engineer. */ +"Error creating group" = "Помилка створення групи"; + +/* No comment provided by engineer. */ +"Error creating group link" = "Помилка створення посилання на групу"; + +/* No comment provided by engineer. */ +"Error creating profile!" = "Помилка створення профілю!"; + +/* No comment provided by engineer. */ +"Error deleting chat database" = "Помилка видалення бази даних чату"; + +/* No comment provided by engineer. */ +"Error deleting chat!" = "Помилка видалення чату!"; + +/* No comment provided by engineer. */ +"Error deleting connection" = "Помилка видалення з'єднання"; + +/* No comment provided by engineer. */ +"Error deleting contact" = "Помилка видалення контакту"; + +/* No comment provided by engineer. */ +"Error deleting database" = "Помилка видалення бази даних"; + +/* No comment provided by engineer. */ +"Error deleting old database" = "Помилка видалення старої бази даних"; + +/* No comment provided by engineer. */ +"Error deleting token" = "Помилка видалення токена"; + +/* No comment provided by engineer. */ +"Error deleting user profile" = "Помилка видалення профілю користувача"; + +/* No comment provided by engineer. */ +"Error enabling delivery receipts!" = "Помилка активації підтвердження доставлення!"; + +/* No comment provided by engineer. */ +"Error enabling notifications" = "Помилка увімкнення сповіщень"; + +/* No comment provided by engineer. */ +"Error encrypting database" = "Помилка шифрування бази даних"; + +/* No comment provided by engineer. */ +"Error exporting chat database" = "Помилка експорту бази даних чату"; + +/* No comment provided by engineer. */ +"Error importing chat database" = "Помилка імпорту бази даних чату"; + +/* No comment provided by engineer. */ +"Error joining group" = "Помилка приєднання до групи"; + +/* No comment provided by engineer. */ +"Error loading %@ servers" = "Помилка завантаження %@ серверів"; + +/* No comment provided by engineer. */ +"Error receiving file" = "Помилка отримання файлу"; + +/* No comment provided by engineer. */ +"Error removing member" = "Помилка видалення учасника"; + +/* No comment provided by engineer. */ +"Error saving %@ servers" = "Помилка збереження %@ серверів"; + +/* No comment provided by engineer. */ +"Error saving group profile" = "Помилка збереження профілю групи"; + +/* No comment provided by engineer. */ +"Error saving ICE servers" = "Помилка збереження серверів ICE"; + +/* No comment provided by engineer. */ +"Error saving passcode" = "Помилка збереження пароля"; + +/* No comment provided by engineer. */ +"Error saving passphrase to keychain" = "Помилка збереження пароля на keychain"; + +/* No comment provided by engineer. */ +"Error saving user password" = "Помилка збереження пароля користувача"; + +/* No comment provided by engineer. */ +"Error sending email" = "Помилка надсилання електронного листа"; + +/* No comment provided by engineer. */ +"Error sending message" = "Помилка надсилання повідомлення"; + +/* No comment provided by engineer. */ +"Error setting delivery receipts!" = "Помилка встановлення підтвердження доставлення!"; + +/* No comment provided by engineer. */ +"Error starting chat" = "Помилка запуску чату"; + +/* No comment provided by engineer. */ +"Error stopping chat" = "Помилка зупинки чату"; + +/* No comment provided by engineer. */ +"Error switching profile!" = "Помилка перемикання профілю!"; + +/* No comment provided by engineer. */ +"Error synchronizing connection" = "Помилка синхронізації з'єднання"; + +/* No comment provided by engineer. */ +"Error updating group link" = "Помилка оновлення посилання на групу"; + +/* No comment provided by engineer. */ +"Error updating message" = "Повідомлення про помилку оновлення"; + +/* No comment provided by engineer. */ +"Error updating settings" = "Помилка оновлення налаштувань"; + +/* No comment provided by engineer. */ +"Error updating user privacy" = "Помилка оновлення конфіденційності користувача"; + +/* No comment provided by engineer. */ +"Error: " = "Помилка: "; + +/* No comment provided by engineer. */ +"Error: %@" = "Помилка: %@"; + +/* No comment provided by engineer. */ +"Error: no database file" = "Помилка: немає файлу бази даних"; + +/* No comment provided by engineer. */ +"Error: URL is invalid" = "Помилка: URL-адреса невірна"; + +/* No comment provided by engineer. */ +"Even when disabled in the conversation." = "Навіть коли вимкнений у розмові."; + +/* No comment provided by engineer. */ +"event happened" = "відбулася подія"; + +/* No comment provided by engineer. */ +"Exit without saving" = "Вихід без збереження"; + +/* No comment provided by engineer. */ +"Export database" = "Експорт бази даних"; + +/* No comment provided by engineer. */ +"Export error:" = "Помилка експорту:"; + +/* No comment provided by engineer. */ +"Exported database archive." = "Експортований архів бази даних."; + +/* No comment provided by engineer. */ +"Exporting database archive…" = "Експорт архіву бази даних…"; + +/* No comment provided by engineer. */ +"Failed to remove passphrase" = "Не вдалося видалити парольну фразу"; + +/* No comment provided by engineer. */ +"Fast and no wait until the sender is online!" = "Швидко і без очікування, поки відправник буде онлайн!"; + +/* No comment provided by engineer. */ +"Favorite" = "Улюблений"; + +/* No comment provided by engineer. */ +"File will be deleted from servers." = "Файл буде видалено з серверів."; + +/* No comment provided by engineer. */ +"File will be received when your contact completes uploading it." = "Файл буде отримано, коли ваш контакт завершить завантаження."; + +/* No comment provided by engineer. */ +"File will be received when your contact is online, please wait or check later!" = "Файл буде отримано, коли ваш контакт буде онлайн, будь ласка, зачекайте або перевірте пізніше!"; + +/* No comment provided by engineer. */ +"File: %@" = "Файл: %@"; + +/* No comment provided by engineer. */ +"Files & media" = "Файли та медіа"; + +/* chat feature */ +"Files and media" = "Файли і медіа"; + +/* No comment provided by engineer. */ +"Files and media are prohibited in this group." = "Файли та медіа в цій групі заборонені."; + +/* No comment provided by engineer. */ +"Files and media prohibited!" = "Файли та медіа заборонені!"; + +/* No comment provided by engineer. */ +"Filter unread and favorite chats." = "Фільтруйте непрочитані та улюблені чати."; + +/* No comment provided by engineer. */ +"Finally, we have them! 🚀" = "Нарешті, вони у нас є! 🚀"; + +/* No comment provided by engineer. */ +"Find chats faster" = "Швидше знаходьте чати"; + +/* No comment provided by engineer. */ +"Fix" = "Виправити"; + +/* No comment provided by engineer. */ +"Fix connection" = "Виправити з'єднання"; + +/* No comment provided by engineer. */ +"Fix connection?" = "Полагодити зв'язок?"; + +/* No comment provided by engineer. */ +"Fix encryption after restoring backups." = "Виправити шифрування після відновлення резервних копій."; + +/* No comment provided by engineer. */ +"Fix not supported by contact" = "Виправлення не підтримується контактом"; + +/* No comment provided by engineer. */ +"Fix not supported by group member" = "Виправлення не підтримується учасником групи"; + +/* No comment provided by engineer. */ +"For console" = "Для консолі"; + +/* No comment provided by engineer. */ +"French interface" = "Французький інтерфейс"; + +/* No comment provided by engineer. */ +"Full link" = "Повне посилання"; + +/* No comment provided by engineer. */ +"Full name (optional)" = "Повне ім'я (необов'язково)"; + +/* No comment provided by engineer. */ +"Full name:" = "Повне ім'я:"; + +/* No comment provided by engineer. */ +"Fully re-implemented - work in background!" = "Повністю перероблено - робота у фоновому режимі!"; + +/* No comment provided by engineer. */ +"Further reduced battery usage" = "Подальше зменшення використання акумулятора"; + +/* No comment provided by engineer. */ +"GIFs and stickers" = "GIF-файли та наклейки"; + +/* No comment provided by engineer. */ +"Group" = "Група"; + +/* No comment provided by engineer. */ +"group deleted" = "групу видалено"; + +/* No comment provided by engineer. */ +"Group display name" = "Назва групи для відображення"; + +/* No comment provided by engineer. */ +"Group full name (optional)" = "Повна назва групи (необов'язково)"; + +/* No comment provided by engineer. */ +"Group image" = "Зображення групи"; + +/* No comment provided by engineer. */ +"Group invitation" = "Групове запрошення"; + +/* No comment provided by engineer. */ +"Group invitation expired" = "Термін дії групового запрошення закінчився"; + +/* No comment provided by engineer. */ +"Group invitation is no longer valid, it was removed by sender." = "Групове запрошення більше не дійсне, воно було видалено відправником."; + +/* No comment provided by engineer. */ +"Group link" = "Посилання на групу"; + +/* No comment provided by engineer. */ +"Group links" = "Групові посилання"; + +/* No comment provided by engineer. */ +"Group members can add message reactions." = "Учасники групи можуть додавати реакції на повідомлення."; + +/* No comment provided by engineer. */ +"Group members can irreversibly delete sent messages." = "Учасники групи можуть безповоротно видаляти надіслані повідомлення."; + +/* No comment provided by engineer. */ +"Group members can send direct messages." = "Учасники групи можуть надсилати прямі повідомлення."; + +/* No comment provided by engineer. */ +"Group members can send disappearing messages." = "Учасники групи можуть надсилати зникаючі повідомлення."; + +/* No comment provided by engineer. */ +"Group members can send files and media." = "Учасники групи можуть надсилати файли та медіа."; + +/* No comment provided by engineer. */ +"Group members can send voice messages." = "Учасники групи можуть надсилати голосові повідомлення."; + +/* notification */ +"Group message:" = "Групове повідомлення:"; + +/* No comment provided by engineer. */ +"Group moderation" = "Модерація груп"; + +/* No comment provided by engineer. */ +"Group preferences" = "Параметри груп"; + +/* No comment provided by engineer. */ +"Group profile" = "Профіль групи"; + +/* No comment provided by engineer. */ +"Group profile is stored on members' devices, not on the servers." = "Профіль групи зберігається на пристроях учасників, а не на серверах."; + +/* snd group event chat item */ +"group profile updated" = "оновлено профіль групи"; + +/* No comment provided by engineer. */ +"Group welcome message" = "Привітальне повідомлення групи"; + +/* No comment provided by engineer. */ +"Group will be deleted for all members - this cannot be undone!" = "Група буде видалена для всіх учасників - це неможливо скасувати!"; + +/* No comment provided by engineer. */ +"Group will be deleted for you - this cannot be undone!" = "Група буде видалена для вас - це не може бути скасовано!"; + +/* No comment provided by engineer. */ +"Help" = "Довідка"; + +/* No comment provided by engineer. */ +"Hidden" = "Приховано"; + +/* No comment provided by engineer. */ +"Hidden chat profiles" = "Приховані профілі чату"; + +/* No comment provided by engineer. */ +"Hidden profile password" = "Прихований пароль до профілю"; + +/* chat item action */ +"Hide" = "Приховати"; + +/* No comment provided by engineer. */ +"Hide app screen in the recent apps." = "Приховати екран програми в останніх програмах."; + +/* No comment provided by engineer. */ +"Hide profile" = "Приховати профіль"; + +/* No comment provided by engineer. */ +"Hide:" = "Приховати:"; + +/* No comment provided by engineer. */ +"History" = "Історія"; + +/* time unit */ +"hours" = "години"; + +/* No comment provided by engineer. */ +"How it works" = "Як це працює"; + +/* No comment provided by engineer. */ +"How SimpleX works" = "Як працює SimpleX"; + +/* No comment provided by engineer. */ +"How to" = "Як зробити"; + +/* No comment provided by engineer. */ +"How to use it" = "Як ним користуватися"; + +/* No comment provided by engineer. */ +"How to use your servers" = "Як користуватися вашими серверами"; + +/* No comment provided by engineer. */ +"ICE servers (one per line)" = "Сервери ICE (по одному на лінію)"; + +/* No comment provided by engineer. */ +"If you can't meet in person, show QR code in a video call, or share the link." = "Якщо ви не можете зустрітися особисто, покажіть QR-код у відеодзвінку або поділіться посиланням."; + +/* No comment provided by engineer. */ +"If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link." = "Якщо ви не можете зустрітися особисто, ви можете **сканувати QR-код у відеодзвінку**, або ваш контакт може поділитися посиланням на запрошення."; + +/* No comment provided by engineer. */ +"If you enter this passcode when opening the app, all app data will be irreversibly removed!" = "Якщо ви введете цей пароль при відкритті програми, всі дані програми будуть безповоротно видалені!"; + +/* No comment provided by engineer. */ +"If you enter your self-destruct passcode while opening the app:" = "Якщо ви введете пароль самознищення під час відкриття програми:"; + +/* No comment provided by engineer. */ +"If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app)." = "Якщо вам потрібно скористатися чатом зараз, натисніть **Зробити це пізніше** нижче (вам буде запропоновано перенести базу даних при перезапуску програми)."; + +/* No comment provided by engineer. */ +"Ignore" = "Ігнорувати"; + +/* No comment provided by engineer. */ +"Image will be received when your contact completes uploading it." = "Зображення буде отримано, коли ваш контакт завершить завантаження."; + +/* No comment provided by engineer. */ +"Image will be received when your contact is online, please wait or check later!" = "Зображення буде отримано, коли ваш контакт буде онлайн, будь ласка, зачекайте або перевірте пізніше!"; + +/* No comment provided by engineer. */ +"Immediately" = "Негайно"; + +/* No comment provided by engineer. */ +"Immune to spam and abuse" = "Імунітет до спаму та зловживань"; + +/* No comment provided by engineer. */ +"Import" = "Імпорт"; + +/* No comment provided by engineer. */ +"Import chat database?" = "Імпортувати базу даних чату?"; + +/* No comment provided by engineer. */ +"Import database" = "Імпорт бази даних"; + +/* No comment provided by engineer. */ +"Improved privacy and security" = "Покращена конфіденційність та безпека"; + +/* No comment provided by engineer. */ +"Improved server configuration" = "Покращена конфігурація сервера"; + +/* No comment provided by engineer. */ +"In reply to" = "У відповідь на"; + +/* No comment provided by engineer. */ +"Incognito" = "Інкогніто"; + +/* No comment provided by engineer. */ +"Incognito mode" = "Режим інкогніто"; + +/* No comment provided by engineer. */ +"Incognito mode protects your privacy by using a new random profile for each contact." = "Режим інкогніто захищає вашу конфіденційність, використовуючи новий випадковий профіль для кожного контакту."; + +/* chat list item description */ +"incognito via contact address link" = "інкогніто за посиланням на контактну адресу"; + +/* chat list item description */ +"incognito via group link" = "інкогніто через групове посилання"; + +/* chat list item description */ +"incognito via one-time link" = "інкогніто за одноразовим посиланням"; + +/* notification */ +"Incoming audio call" = "Вхідний аудіовиклик"; + +/* notification */ +"Incoming call" = "Вхідний дзвінок"; + +/* notification */ +"Incoming video call" = "Вхідний відеодзвінок"; + +/* No comment provided by engineer. */ +"Incompatible database version" = "Несумісна версія бази даних"; + +/* PIN entry */ +"Incorrect passcode" = "Неправильний пароль"; + +/* No comment provided by engineer. */ +"Incorrect security code!" = "Неправильний код безпеки!"; + +/* connection level description */ +"indirect (%d)" = "непрямий (%d)"; + +/* chat item action */ +"Info" = "Інформація"; + +/* No comment provided by engineer. */ +"Initial role" = "Початкова роль"; + +/* No comment provided by engineer. */ +"Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "Встановіть [SimpleX Chat для терміналу](https://github.com/simplex-chat/simplex-chat)"; + +/* No comment provided by engineer. */ +"Instant push notifications will be hidden!\n" = "Миттєві пуш-сповіщення будуть приховані!\n"; + +/* No comment provided by engineer. */ +"Instantly" = "Миттєво"; + +/* No comment provided by engineer. */ +"Interface" = "Інтерфейс"; + +/* invalid chat data */ +"invalid chat" = "недійсний чат"; + +/* No comment provided by engineer. */ +"invalid chat data" = "невірні дані чату"; + +/* No comment provided by engineer. */ +"Invalid connection link" = "Неправильне посилання для підключення"; + +/* invalid chat item */ +"invalid data" = "невірні дані"; + +/* No comment provided by engineer. */ +"Invalid server address!" = "Неправильна адреса сервера!"; + +/* item status text */ +"Invalid status" = "Недійсний статус"; + +/* No comment provided by engineer. */ +"Invitation expired!" = "Термін дії запрошення закінчився!"; + +/* group name */ +"invitation to group %@" = "запрошення до групи %@"; + +/* No comment provided by engineer. */ +"Invite friends" = "Запросити друзів"; + +/* No comment provided by engineer. */ +"Invite members" = "Запросити учасників"; + +/* No comment provided by engineer. */ +"Invite to group" = "Запросити до групи"; + +/* No comment provided by engineer. */ +"invited" = "запрошені"; + +/* rcv group event chat item */ +"invited %@" = "запрошений %@"; + +/* chat list item title */ +"invited to connect" = "запрошуємо приєднатися"; + +/* rcv group event chat item */ +"invited via your group link" = "запрошені за посиланням у вашій групі"; + +/* No comment provided by engineer. */ +"iOS Keychain is used to securely store passphrase - it allows receiving push notifications." = "iOS Keychain використовується для безпечного зберігання пароля - це дає змогу отримувати миттєві повідомлення."; + +/* No comment provided by engineer. */ +"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Пароль бази даних буде безпечно збережено в iOS Keychain після запуску чату або зміни пароля - це дасть змогу отримувати миттєві повідомлення."; + +/* No comment provided by engineer. */ +"Irreversible message deletion" = "Безповоротне видалення повідомлення"; + +/* No comment provided by engineer. */ +"Irreversible message deletion is prohibited in this chat." = "У цьому чаті заборонено безповоротне видалення повідомлень."; + +/* No comment provided by engineer. */ +"Irreversible message deletion is prohibited in this group." = "У цій групі заборонено безповоротне видалення повідомлень."; + +/* No comment provided by engineer. */ +"It allows having many anonymous connections without any shared data between them in a single chat profile." = "Це дозволяє мати багато анонімних з'єднань без будь-яких спільних даних між ними в одному профілі чату."; + +/* No comment provided by engineer. */ +"It can happen when you or your connection used the old database backup." = "Це може статися, якщо ви або ваше з'єднання використовували стару резервну копію бази даних."; + +/* No comment provided by engineer. */ +"It can happen when:\n1. The messages expired in the sending client after 2 days or on the server after 30 days.\n2. Message decryption failed, because you or your contact used old database backup.\n3. The connection was compromised." = "Це може статися, коли:\n1. Термін дії повідомлень закінчився в клієнті-відправнику через 2 дні або на сервері через 30 днів.\n2. Не вдалося розшифрувати повідомлення, тому що ви або ваш контакт використовували стару резервну копію бази даних.\n3. З'єднання було скомпрометовано."; + +/* No comment provided by engineer. */ +"It seems like you are already connected via this link. If it is not the case, there was an error (%@)." = "Схоже, що ви вже підключені за цим посиланням. Якщо це не так, сталася помилка (%@)."; + +/* No comment provided by engineer. */ +"Italian interface" = "Італійський інтерфейс"; + +/* No comment provided by engineer. */ +"italic" = "курсив"; + +/* No comment provided by engineer. */ +"Japanese interface" = "Японський інтерфейс"; + +/* No comment provided by engineer. */ +"Join" = "Приєднуйтесь"; + +/* No comment provided by engineer. */ +"join as %@" = "приєднатися як %@"; + +/* No comment provided by engineer. */ +"Join group" = "Приєднуйтесь до групи"; + +/* No comment provided by engineer. */ +"Join incognito" = "Приєднуйтесь інкогніто"; + +/* No comment provided by engineer. */ +"Joining group" = "Приєднання до групи"; + +/* No comment provided by engineer. */ +"Keep your connections" = "Зберігайте свої зв'язки"; + +/* No comment provided by engineer. */ +"Keychain error" = "помилка KeyChain"; + +/* No comment provided by engineer. */ +"KeyChain error" = "помилка KeyChain"; + +/* No comment provided by engineer. */ +"Large file!" = "Великий файл!"; + +/* No comment provided by engineer. */ +"Learn more" = "Дізнайтеся більше"; + +/* No comment provided by engineer. */ +"Leave" = "Залишити"; + +/* No comment provided by engineer. */ +"Leave group" = "Покинути групу"; + +/* No comment provided by engineer. */ +"Leave group?" = "Покинути групу?"; + +/* rcv group event chat item */ +"left" = "ліворуч"; + +/* email subject */ +"Let's talk in SimpleX Chat" = "Поговоримо в чаті SimpleX"; + +/* No comment provided by engineer. */ +"Light" = "Світлий"; + +/* No comment provided by engineer. */ +"Limitations" = "Обмеження"; + +/* No comment provided by engineer. */ +"LIVE" = "НАЖИВО"; + +/* No comment provided by engineer. */ +"Live message!" = "Живе повідомлення!"; + +/* No comment provided by engineer. */ +"Live messages" = "Живі повідомлення"; + +/* No comment provided by engineer. */ +"Local name" = "Місцева назва"; + +/* No comment provided by engineer. */ +"Local profile data only" = "Тільки локальні дані профілю"; + +/* No comment provided by engineer. */ +"Lock after" = "Блокування після"; + +/* No comment provided by engineer. */ +"Lock mode" = "Режим блокування"; + +/* No comment provided by engineer. */ +"Make a private connection" = "Створіть приватне з'єднання"; + +/* No comment provided by engineer. */ +"Make one message disappear" = "Зробити так, щоб одне повідомлення зникло"; + +/* No comment provided by engineer. */ +"Make profile private!" = "Зробіть профіль приватним!"; + +/* No comment provided by engineer. */ +"Make sure %@ server addresses are in correct format, line separated and are not duplicated (%@)." = "Переконайтеся, що адреси серверів %@ мають правильний формат, розділені рядками і не дублюються (%@)."; + +/* No comment provided by engineer. */ +"Make sure WebRTC ICE server addresses are in correct format, line separated and are not duplicated." = "Переконайтеся, що адреси серверів WebRTC ICE мають правильний формат, розділені рядками і не дублюються."; + +/* No comment provided by engineer. */ +"Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?*" = "Багато людей запитували: *якщо SimpleX не має ідентифікаторів користувачів, як він може доставляти повідомлення?*"; + +/* No comment provided by engineer. */ +"Mark deleted for everyone" = "Позначити видалено для всіх"; + +/* No comment provided by engineer. */ +"Mark read" = "Позначити прочитано"; + +/* No comment provided by engineer. */ +"Mark verified" = "Позначити перевірено"; + +/* No comment provided by engineer. */ +"Markdown in messages" = "Виправлення в повідомленнях"; + +/* marked deleted chat item preview text */ +"marked deleted" = "з позначкою видалено"; + +/* No comment provided by engineer. */ +"Max 30 seconds, received instantly." = "Максимум 30 секунд, отримується миттєво."; + +/* member role */ +"member" = "учасник"; + +/* No comment provided by engineer. */ +"Member" = "Учасник"; + +/* rcv group event chat item */ +"member connected" = "з'єднаний"; + +/* No comment provided by engineer. */ +"Member role will be changed to \"%@\". All group members will be notified." = "Роль учасника буде змінено на \"%@\". Всі учасники групи будуть повідомлені про це."; + +/* No comment provided by engineer. */ +"Member role will be changed to \"%@\". The member will receive a new invitation." = "Роль учасника буде змінено на \"%@\". Учасник отримає нове запрошення."; + +/* No comment provided by engineer. */ +"Member will be removed from group - this cannot be undone!" = "Учасник буде видалений з групи - це неможливо скасувати!"; + +/* item status text */ +"Message delivery error" = "Помилка доставки повідомлення"; + +/* No comment provided by engineer. */ +"Message delivery receipts!" = "Підтвердження доставки повідомлення!"; + +/* No comment provided by engineer. */ +"Message draft" = "Чернетка повідомлення"; + +/* chat feature */ +"Message reactions" = "Реакції на повідомлення"; + +/* No comment provided by engineer. */ +"Message reactions are prohibited in this chat." = "Реакції на повідомлення в цьому чаті заборонені."; + +/* No comment provided by engineer. */ +"Message reactions are prohibited in this group." = "Реакції на повідомлення в цій групі заборонені."; + +/* notification */ +"message received" = "повідомлення отримано"; + +/* No comment provided by engineer. */ +"Message text" = "Текст повідомлення"; + +/* No comment provided by engineer. */ +"Messages" = "Повідомлення"; + +/* No comment provided by engineer. */ +"Messages & files" = "Повідомлення та файли"; + +/* No comment provided by engineer. */ +"Migrating database archive…" = "Перенесення архіву бази даних…"; + +/* No comment provided by engineer. */ +"Migration error:" = "Помилка міграції:"; + +/* No comment provided by engineer. */ +"Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat)." = "Міграція не вдалася. Натисніть **Пропустити** нижче, щоб продовжити використовувати поточну базу даних. Будь ласка, повідомте про проблему розробникам програми через чат або електронну пошту [chat@simplex.chat](mailto:chat@simplex.chat)."; + +/* No comment provided by engineer. */ +"Migration is completed" = "Міграцію завершено"; + +/* No comment provided by engineer. */ +"Migrations: %@" = "Міграції: %@"; + +/* time unit */ +"minutes" = "хвилини"; + +/* call status */ +"missed call" = "пропущений дзвінок"; + +/* chat item action */ +"Moderate" = "Модерується"; + +/* moderated chat item */ +"moderated" = "модерується"; + +/* No comment provided by engineer. */ +"Moderated at" = "Модерується на"; + +/* copied message info */ +"Moderated at: %@" = "Модерується за: %@"; + +/* No comment provided by engineer. */ +"moderated by %@" = "модерується %@"; + +/* time unit */ +"months" = "місяців"; + +/* No comment provided by engineer. */ +"More improvements are coming soon!" = "Незабаром буде ще більше покращень!"; + +/* item status description */ +"Most likely this connection is deleted." = "Швидше за все, це з'єднання видалено."; + +/* No comment provided by engineer. */ +"Most likely this contact has deleted the connection with you." = "Швидше за все, цей контакт видалив зв'язок з вами."; + +/* No comment provided by engineer. */ +"Multiple chat profiles" = "Кілька профілів чату"; + +/* No comment provided by engineer. */ +"Mute" = "Вимкнути звук"; + +/* No comment provided by engineer. */ +"Muted when inactive!" = "Вимкнено, коли неактивний!"; + +/* No comment provided by engineer. */ +"Name" = "Ім'я"; + +/* No comment provided by engineer. */ +"Network & servers" = "Мережа та сервери"; + +/* No comment provided by engineer. */ +"Network settings" = "Налаштування мережі"; + +/* No comment provided by engineer. */ +"Network status" = "Стан мережі"; + +/* No comment provided by engineer. */ +"never" = "ніколи"; + +/* notification */ +"New contact request" = "Новий запит на контакт"; + +/* notification */ +"New contact:" = "Новий контакт:"; + +/* No comment provided by engineer. */ +"New database archive" = "Новий архів бази даних"; + +/* No comment provided by engineer. */ +"New display name" = "Нове ім'я відображення"; + +/* No comment provided by engineer. */ +"New in %@" = "Нове в %@"; + +/* No comment provided by engineer. */ +"New member role" = "Нова роль учасника"; + +/* notification */ +"new message" = "нове повідомлення"; + +/* notification */ +"New message" = "Нове повідомлення"; + +/* No comment provided by engineer. */ +"New Passcode" = "Новий пароль"; + +/* No comment provided by engineer. */ +"New passphrase…" = "Новий пароль…"; + +/* pref value */ +"no" = "ні"; + +/* No comment provided by engineer. */ +"No" = "Ні"; + +/* Authentication unavailable */ +"No app password" = "Немає пароля програми"; + +/* No comment provided by engineer. */ +"No contacts selected" = "Не вибрано жодного контакту"; + +/* No comment provided by engineer. */ +"No contacts to add" = "Немає контактів для додавання"; + +/* No comment provided by engineer. */ +"No delivery information" = "Немає інформації про доставку"; + +/* No comment provided by engineer. */ +"No device token!" = "Токен пристрою відсутній!"; + +/* No comment provided by engineer. */ +"no e2e encryption" = "без шифрування e2e"; + +/* No comment provided by engineer. */ +"No filtered chats" = "Немає фільтрованих чатів"; + +/* No comment provided by engineer. */ +"No group!" = "Групу не знайдено!"; + +/* No comment provided by engineer. */ +"No history" = "Немає історії"; + +/* No comment provided by engineer. */ +"No permission to record voice message" = "Немає дозволу на запис голосового повідомлення"; + +/* No comment provided by engineer. */ +"No received or sent files" = "Немає отриманих або відправлених файлів"; + +/* copied message info in history */ +"no text" = "без тексту"; + +/* No comment provided by engineer. */ +"Notifications" = "Сповіщення"; + +/* No comment provided by engineer. */ +"Notifications are disabled!" = "Сповіщення вимкнено!"; + +/* No comment provided by engineer. */ +"Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" = "Тепер адміністратори можуть\n- видаляти повідомлення користувачів.\n- відключати користувачів (роль \"спостерігач\")"; + +/* member role */ +"observer" = "спостерігач"; + +/* enabled status + group pref value */ +"off" = "вимкнено"; + +/* No comment provided by engineer. */ +"Off" = "Вимкнено"; + +/* No comment provided by engineer. */ +"Off (Local)" = "Вимкнено (локально)"; + +/* feature offered item */ +"offered %@" = "запропоновано %@"; + +/* feature offered item */ +"offered %@: %@" = "запропонував %1$@: %2$@"; + +/* No comment provided by engineer. */ +"Ok" = "Гаразд"; + +/* No comment provided by engineer. */ +"Old database" = "Стара база даних"; + +/* No comment provided by engineer. */ +"Old database archive" = "Старий архів бази даних"; + +/* group pref value */ +"on" = "увімкнено"; + +/* No comment provided by engineer. */ +"One-time invitation link" = "Посилання на одноразове запрошення"; + +/* No comment provided by engineer. */ +"Onion hosts will be required for connection. Requires enabling VPN." = "Для підключення будуть потрібні хости onion. Потрібно увімкнути VPN."; + +/* No comment provided by engineer. */ +"Onion hosts will be used when available. Requires enabling VPN." = "Onion хости будуть використовуватися, коли вони будуть доступні. Потрібно увімкнути VPN."; + +/* No comment provided by engineer. */ +"Onion hosts will not be used." = "Onion хости не будуть використовуватися."; + +/* No comment provided by engineer. */ +"Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." = "Тільки клієнтські пристрої зберігають профілі користувачів, контакти, групи та повідомлення, надіслані за допомогою **2-шарового наскрізного шифрування**."; + +/* No comment provided by engineer. */ +"Only group owners can change group preferences." = "Тільки власники груп можуть змінювати налаштування групи."; + +/* No comment provided by engineer. */ +"Only group owners can enable files and media." = "Тільки власники груп можуть вмикати файли та медіа."; + +/* No comment provided by engineer. */ +"Only group owners can enable voice messages." = "Тільки власники груп можуть вмикати голосові повідомлення."; + +/* No comment provided by engineer. */ +"Only you can add message reactions." = "Тільки ви можете додавати реакції на повідомлення."; + +/* No comment provided by engineer. */ +"Only you can irreversibly delete messages (your contact can mark them for deletion)." = "Тільки ви можете безповоротно видалити повідомлення (ваш контакт може позначити їх для видалення)."; + +/* No comment provided by engineer. */ +"Only you can make calls." = "Дзвонити можете тільки ви."; + +/* No comment provided by engineer. */ +"Only you can send disappearing messages." = "Тільки ви можете надсилати зникаючі повідомлення."; + +/* No comment provided by engineer. */ +"Only you can send voice messages." = "Тільки ви можете надсилати голосові повідомлення."; + +/* No comment provided by engineer. */ +"Only your contact can add message reactions." = "Тільки ваш контакт може додавати реакції на повідомлення."; + +/* No comment provided by engineer. */ +"Only your contact can irreversibly delete messages (you can mark them for deletion)." = "Тільки ваш контакт може безповоротно видалити повідомлення (ви можете позначити їх для видалення)."; + +/* No comment provided by engineer. */ +"Only your contact can make calls." = "Тільки ваш контакт може здійснювати дзвінки."; + +/* No comment provided by engineer. */ +"Only your contact can send disappearing messages." = "Тільки ваш контакт може надсилати зникаючі повідомлення."; + +/* No comment provided by engineer. */ +"Only your contact can send voice messages." = "Тільки ваш контакт може надсилати голосові повідомлення."; + +/* No comment provided by engineer. */ +"Open chat" = "Відкритий чат"; + +/* authentication reason */ +"Open chat console" = "Відкрийте консоль чату"; + +/* No comment provided by engineer. */ +"Open Settings" = "Відкрийте Налаштування"; + +/* authentication reason */ +"Open user profiles" = "Відкрити профілі користувачів"; + +/* No comment provided by engineer. */ +"Open-source protocol and code – anybody can run the servers." = "Протокол і код з відкритим вихідним кодом - будь-хто може запускати сервери."; + +/* No comment provided by engineer. */ +"Opening database…" = "Відкриття бази даних…"; + +/* No comment provided by engineer. */ +"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "Відкриття посилання в браузері може знизити конфіденційність і безпеку з'єднання. Ненадійні посилання SimpleX будуть червоного кольору."; + +/* No comment provided by engineer. */ +"or chat with the developers" = "або поспілкуйтеся з розробниками"; + +/* member role */ +"owner" = "власник"; + +/* No comment provided by engineer. */ +"Passcode" = "Пароль"; + +/* No comment provided by engineer. */ +"Passcode changed!" = "Пароль змінено!"; + +/* No comment provided by engineer. */ +"Passcode entry" = "Введення пароля"; + +/* No comment provided by engineer. */ +"Passcode not changed!" = "Пароль не змінено!"; + +/* No comment provided by engineer. */ +"Passcode set!" = "Пароль встановлено!"; + +/* No comment provided by engineer. */ +"Password to show" = "Показати пароль"; + +/* No comment provided by engineer. */ +"Paste" = "Вставити"; + +/* No comment provided by engineer. */ +"Paste image" = "Вставити зображення"; + +/* No comment provided by engineer. */ +"Paste received link" = "Вставте отримане посилання"; + +/* placeholder */ +"Paste the link you received to connect with your contact." = "Вставте отримане посилання для зв'язку з вашим контактом."; + +/* No comment provided by engineer. */ +"peer-to-peer" = "одноранговий"; + +/* No comment provided by engineer. */ +"People can connect to you only via the links you share." = "Люди можуть зв'язатися з вами лише за посиланнями, якими ви ділитеся."; + +/* No comment provided by engineer. */ +"Periodically" = "Періодично"; + +/* message decrypt error item */ +"Permanent decryption error" = "Постійна помилка розшифрування"; + +/* No comment provided by engineer. */ +"PING count" = "Кількість PING"; + +/* No comment provided by engineer. */ +"PING interval" = "Інтервал PING"; + +/* No comment provided by engineer. */ +"Please ask your contact to enable sending voice messages." = "Будь ласка, попросіть вашого контакту увімкнути відправку голосових повідомлень."; + +/* No comment provided by engineer. */ +"Please check that you used the correct link or ask your contact to send you another one." = "Будь ласка, перевірте, чи ви скористалися правильним посиланням, або попросіть контактну особу надіслати вам інше."; + +/* No comment provided by engineer. */ +"Please check your network connection with %@ and try again." = "Будь ласка, перевірте підключення до мережі за допомогою %@ і спробуйте ще раз."; + +/* No comment provided by engineer. */ +"Please check yours and your contact preferences." = "Будь ласка, перевірте свої та контактні налаштування."; + +/* No comment provided by engineer. */ +"Please contact group admin." = "Зверніться до адміністратора групи."; + +/* No comment provided by engineer. */ +"Please enter correct current passphrase." = "Будь ласка, введіть правильний поточний пароль."; + +/* No comment provided by engineer. */ +"Please enter the previous password after restoring database backup. This action can not be undone." = "Будь ласка, введіть попередній пароль після відновлення резервної копії бази даних. Ця дія не може бути скасована."; + +/* No comment provided by engineer. */ +"Please remember or store it securely - there is no way to recover a lost passcode!" = "Будь ласка, запам'ятайте або надійно зберігайте його - втрачений пароль неможливо відновити!"; + +/* No comment provided by engineer. */ +"Please report it to the developers." = "Будь ласка, повідомте про це розробникам."; + +/* No comment provided by engineer. */ +"Please restart the app and migrate the database to enable push notifications." = "Будь ласка, перезапустіть додаток і перенесіть базу даних, щоб увімкнути push-сповіщення."; + +/* No comment provided by engineer. */ +"Please store passphrase securely, you will NOT be able to access chat if you lose it." = "Будь ласка, зберігайте пароль надійно, ви НЕ зможете отримати доступ до чату, якщо втратите його."; + +/* No comment provided by engineer. */ +"Please store passphrase securely, you will NOT be able to change it if you lose it." = "Будь ласка, зберігайте пароль надійно, ви НЕ зможете змінити його, якщо втратите."; + +/* No comment provided by engineer. */ +"Polish interface" = "Польський інтерфейс"; + +/* server test error */ +"Possibly, certificate fingerprint in server address is incorrect" = "Можливо, в адресі сервера неправильно вказано відбиток сертифіката"; + +/* No comment provided by engineer. */ +"Preserve the last message draft, with attachments." = "Зберегти чернетку останнього повідомлення з вкладеннями."; + +/* No comment provided by engineer. */ +"Preset server" = "Попередньо встановлений сервер"; + +/* No comment provided by engineer. */ +"Preset server address" = "Попередньо встановлена адреса сервера"; + +/* No comment provided by engineer. */ +"Preview" = "Попередній перегляд"; + +/* No comment provided by engineer. */ +"Privacy & security" = "Конфіденційність і безпека"; + +/* No comment provided by engineer. */ +"Privacy redefined" = "Конфіденційність переглянута"; + +/* No comment provided by engineer. */ +"Private filenames" = "Приватні імена файлів"; + +/* No comment provided by engineer. */ +"Profile and server connections" = "З'єднання профілю та сервера"; + +/* No comment provided by engineer. */ +"Profile image" = "Зображення профілю"; + +/* No comment provided by engineer. */ +"Profile password" = "Пароль до профілю"; + +/* No comment provided by engineer. */ +"Profile update will be sent to your contacts." = "Оновлення профілю буде надіслано вашим контактам."; + +/* No comment provided by engineer. */ +"Prohibit audio/video calls." = "Заборонити аудіо/відеодзвінки."; + +/* No comment provided by engineer. */ +"Prohibit irreversible message deletion." = "Заборонити незворотне видалення повідомлень."; + +/* No comment provided by engineer. */ +"Prohibit message reactions." = "Заборонити реакцію на повідомлення."; + +/* No comment provided by engineer. */ +"Prohibit messages reactions." = "Заборонити реакції на повідомлення."; + +/* No comment provided by engineer. */ +"Prohibit sending direct messages to members." = "Заборонити надсилати прямі повідомлення учасникам."; + +/* No comment provided by engineer. */ +"Prohibit sending disappearing messages." = "Заборонити надсилання зникаючих повідомлень."; + +/* No comment provided by engineer. */ +"Prohibit sending files and media." = "Заборонити надсилання файлів і медіа."; + +/* No comment provided by engineer. */ +"Prohibit sending voice messages." = "Заборонити надсилання голосових повідомлень."; + +/* No comment provided by engineer. */ +"Protect app screen" = "Захистіть екран програми"; + +/* No comment provided by engineer. */ +"Protect your chat profiles with a password!" = "Захистіть свої профілі чату паролем!"; + +/* No comment provided by engineer. */ +"Protocol timeout" = "Тайм-аут протоколу"; + +/* No comment provided by engineer. */ +"Protocol timeout per KB" = "Тайм-аут протоколу на КБ"; + +/* No comment provided by engineer. */ +"Push notifications" = "Push-повідомлення"; + +/* No comment provided by engineer. */ +"Rate the app" = "Оцініть додаток"; + +/* chat item menu */ +"React…" = "Реагуй…"; + +/* No comment provided by engineer. */ +"Read" = "Читати"; + +/* No comment provided by engineer. */ +"Read more" = "Читати далі"; + +/* No comment provided by engineer. */ +"Read more in [User Guide](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address)." = "Читайте більше в [Посібнику користувача](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address)."; + +/* No comment provided by engineer. */ +"Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends)." = "Читайте більше в [Посібнику користувача](https://simplex.chat/docs/guide/readme.html#connect-to-friends)."; + +/* No comment provided by engineer. */ +"Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme)." = "Читайте більше в нашому [GitHub репозиторії](https://github.com/simplex-chat/simplex-chat#readme)."; + +/* No comment provided by engineer. */ +"Read more in our GitHub repository." = "Читайте більше в нашому репозиторії на GitHub."; + +/* No comment provided by engineer. */ +"Receipts are disabled" = "Підтвердження виключені"; + +/* No comment provided by engineer. */ +"received answer…" = "отримали відповідь…"; + +/* No comment provided by engineer. */ +"Received at" = "Отримано за"; + +/* copied message info */ +"Received at: %@" = "Отримано за: %@"; + +/* No comment provided by engineer. */ +"received confirmation…" = "отримали підтвердження…"; + +/* notification */ +"Received file event" = "Подія отримання файлу"; + +/* message info title */ +"Received message" = "Отримано повідомлення"; + +/* No comment provided by engineer. */ +"Receiving address will be changed to a different server. Address change will complete after sender comes online." = "Адреса отримувача буде змінена на інший сервер. Зміна адреси завершиться після того, як відправник з'явиться в мережі."; + +/* No comment provided by engineer. */ +"Receiving file will be stopped." = "Отримання файлу буде зупинено."; + +/* No comment provided by engineer. */ +"Receiving via" = "Отримання через"; + +/* No comment provided by engineer. */ +"Recipients see updates as you type them." = "Одержувачі бачать оновлення, коли ви їх вводите."; + +/* No comment provided by engineer. */ +"Reconnect all connected servers to force message delivery. It uses additional traffic." = "Перепідключіть всі підключені сервери, щоб примусово доставити повідомлення. Це використовує додатковий трафік."; + +/* No comment provided by engineer. */ +"Reconnect servers?" = "Перепідключити сервери?"; + +/* No comment provided by engineer. */ +"Record updated at" = "Запис оновлено за"; + +/* copied message info */ +"Record updated at: %@" = "Запис оновлено за: %@"; + +/* No comment provided by engineer. */ +"Reduced battery usage" = "Зменшення використання акумулятора"; + +/* reject incoming call via notification */ +"Reject" = "Відхилити"; + +/* No comment provided by engineer. */ +"Reject (sender NOT notified)" = "Відхилити (відправника НЕ повідомлено)"; + +/* No comment provided by engineer. */ +"Reject contact request" = "Відхилити запит на контакт"; + +/* call status */ +"rejected call" = "відхилений виклик"; + +/* No comment provided by engineer. */ +"Relay server is only used if necessary. Another party can observe your IP address." = "Релейний сервер використовується тільки в разі потреби. Інша сторона може бачити вашу IP-адресу."; + +/* No comment provided by engineer. */ +"Relay server protects your IP address, but it can observe the duration of the call." = "Сервер ретрансляції захищає вашу IP-адресу, але він може спостерігати за тривалістю дзвінка."; + +/* No comment provided by engineer. */ +"Remove" = "Видалити"; + +/* No comment provided by engineer. */ +"Remove member" = "Видалити учасника"; + +/* No comment provided by engineer. */ +"Remove member?" = "Видалити учасника?"; + +/* No comment provided by engineer. */ +"Remove passphrase from keychain?" = "Видалити парольну фразу з брелока?"; + +/* No comment provided by engineer. */ +"removed" = "видалено"; + +/* rcv group event chat item */ +"removed %@" = "видалено %@"; + +/* rcv group event chat item */ +"removed you" = "прибрали вас"; + +/* No comment provided by engineer. */ +"Renegotiate" = "Переузгодьте"; + +/* No comment provided by engineer. */ +"Renegotiate encryption" = "Переузгодьте шифрування"; + +/* No comment provided by engineer. */ +"Renegotiate encryption?" = "Переузгодьте шифрування?"; + +/* chat item action */ +"Reply" = "Відповісти"; + +/* No comment provided by engineer. */ +"Required" = "Потрібно"; + +/* No comment provided by engineer. */ +"Reset" = "Перезавантаження"; + +/* No comment provided by engineer. */ +"Reset colors" = "Скинути кольори"; + +/* No comment provided by engineer. */ +"Reset to defaults" = "Відновити налаштування за замовчуванням"; + +/* No comment provided by engineer. */ +"Restart the app to create a new chat profile" = "Перезапустіть програму, щоб створити новий профіль чату"; + +/* No comment provided by engineer. */ +"Restart the app to use imported chat database" = "Перезапустіть програму, щоб використовувати імпортовану базу даних чату"; + +/* No comment provided by engineer. */ +"Restore" = "Відновити"; + +/* No comment provided by engineer. */ +"Restore database backup" = "Відновлення резервної копії бази даних"; + +/* No comment provided by engineer. */ +"Restore database backup?" = "Відновити резервну копію бази даних?"; + +/* No comment provided by engineer. */ +"Restore database error" = "Відновлення помилки бази даних"; + +/* chat item action */ +"Reveal" = "Показувати"; + +/* No comment provided by engineer. */ +"Revert" = "Повернутися"; + +/* No comment provided by engineer. */ +"Revoke" = "Відкликати"; + +/* cancel file action */ +"Revoke file" = "Відкликати файл"; + +/* No comment provided by engineer. */ +"Revoke file?" = "Відкликати файл?"; + +/* No comment provided by engineer. */ +"Role" = "Роль"; + +/* No comment provided by engineer. */ +"Run chat" = "Запустити чат"; + +/* chat item action */ +"Save" = "Зберегти"; + +/* No comment provided by engineer. */ +"Save (and notify contacts)" = "Зберегти (і повідомити контактам)"; + +/* No comment provided by engineer. */ +"Save and notify contact" = "Зберегти та повідомити контакт"; + +/* No comment provided by engineer. */ +"Save and notify group members" = "Зберегти та повідомити учасників групи"; + +/* No comment provided by engineer. */ +"Save and update group profile" = "Збереження та оновлення профілю групи"; + +/* No comment provided by engineer. */ +"Save archive" = "Зберегти архів"; + +/* No comment provided by engineer. */ +"Save auto-accept settings" = "Зберегти налаштування автоприйому"; + +/* No comment provided by engineer. */ +"Save group profile" = "Зберегти профіль групи"; + +/* No comment provided by engineer. */ +"Save passphrase and open chat" = "Збережіть пароль і відкрийте чат"; + +/* No comment provided by engineer. */ +"Save passphrase in Keychain" = "Збережіть парольну фразу в Keychain"; + +/* No comment provided by engineer. */ +"Save preferences?" = "Зберегти налаштування?"; + +/* No comment provided by engineer. */ +"Save profile password" = "Зберегти пароль профілю"; + +/* No comment provided by engineer. */ +"Save servers" = "Зберегти сервери"; + +/* No comment provided by engineer. */ +"Save servers?" = "Зберегти сервери?"; + +/* No comment provided by engineer. */ +"Save settings?" = "Зберегти налаштування?"; + +/* No comment provided by engineer. */ +"Save welcome message?" = "Зберегти вітальне повідомлення?"; + +/* No comment provided by engineer. */ +"Saved WebRTC ICE servers will be removed" = "Збережені сервери WebRTC ICE буде видалено"; + +/* No comment provided by engineer. */ +"Scan code" = "Сканувати код"; + +/* No comment provided by engineer. */ +"Scan QR code" = "Відскануйте QR-код"; + +/* No comment provided by engineer. */ +"Scan security code from your contact's app." = "Відскануйте код безпеки з додатку вашого контакту."; + +/* No comment provided by engineer. */ +"Scan server QR code" = "Відскануйте QR-код сервера"; + +/* No comment provided by engineer. */ +"Search" = "Пошук"; + +/* network option */ +"sec" = "сек"; + +/* time unit */ +"seconds" = "секунди"; + +/* No comment provided by engineer. */ +"secret" = "таємниця"; + +/* server test step */ +"Secure queue" = "Безпечна черга"; + +/* No comment provided by engineer. */ +"Security assessment" = "Оцінка безпеки"; + +/* No comment provided by engineer. */ +"Security code" = "Код безпеки"; + +/* chat item text */ +"security code changed" = "змінено код безпеки"; + +/* No comment provided by engineer. */ +"Select" = "Виберіть"; + +/* No comment provided by engineer. */ +"Self-destruct" = "Самознищення"; + +/* No comment provided by engineer. */ +"Self-destruct passcode" = "Пароль самознищення"; + +/* No comment provided by engineer. */ +"Self-destruct passcode changed!" = "Пароль самознищення змінено!"; + +/* No comment provided by engineer. */ +"Self-destruct passcode enabled!" = "Пароль самознищення ввімкнено!"; + +/* No comment provided by engineer. */ +"Send" = "Надіслати"; + +/* No comment provided by engineer. */ +"Send a live message - it will update for the recipient(s) as you type it" = "Надішліть повідомлення в реальному часі - воно буде оновлюватися для одержувача (одержувачів), поки ви його вводите"; + +/* No comment provided by engineer. */ +"Send delivery receipts to" = "Надсилання звітів про доставку"; + +/* No comment provided by engineer. */ +"Send direct message" = "Надішліть пряме повідомлення"; + +/* No comment provided by engineer. */ +"Send disappearing message" = "Надіслати зникаюче повідомлення"; + +/* No comment provided by engineer. */ +"Send link previews" = "Надіслати попередній перегляд за посиланням"; + +/* No comment provided by engineer. */ +"Send live message" = "Надіслати живе повідомлення"; + +/* No comment provided by engineer. */ +"Send notifications" = "Надсилати сповіщення"; + +/* No comment provided by engineer. */ +"Send notifications:" = "Надсилати сповіщення:"; + +/* No comment provided by engineer. */ +"Send questions and ideas" = "Надсилайте запитання та ідеї"; + +/* No comment provided by engineer. */ +"Send receipts" = "Надіслати підтвердження"; + +/* No comment provided by engineer. */ +"Send them from gallery or custom keyboards." = "Надсилайте їх із галереї чи власних клавіатур."; + +/* No comment provided by engineer. */ +"Sender cancelled file transfer." = "Відправник скасував передачу файлу."; + +/* No comment provided by engineer. */ +"Sender may have deleted the connection request." = "Можливо, відправник видалив запит на підключення."; + +/* No comment provided by engineer. */ +"Sending delivery receipts will be enabled for all contacts in all visible chat profiles." = "Надсилання підтверджень доставки буде ввімкнено для всіх контактів у всіх видимих профілях чату."; + +/* No comment provided by engineer. */ +"Sending delivery receipts will be enabled for all contacts." = "Надсилання підтверджень доставки буде ввімкнено для всіх контактів."; + +/* No comment provided by engineer. */ +"Sending file will be stopped." = "Надсилання файлу буде зупинено."; + +/* No comment provided by engineer. */ +"Sending receipts is disabled for %lld contacts" = "Надсилання підтвердження вимкнено для контактів %lld"; + +/* No comment provided by engineer. */ +"Sending receipts is disabled for %lld groups" = "Відправлення підтверджень вимкнено для груп %lld"; + +/* No comment provided by engineer. */ +"Sending receipts is enabled for %lld contacts" = "Для контактів %lld увімкнено надсилання підтвердження"; + +/* No comment provided by engineer. */ +"Sending receipts is enabled for %lld groups" = "Для груп %lld увімкнено надсилання підтвердження"; + +/* No comment provided by engineer. */ +"Sending via" = "Надсилання через"; + +/* No comment provided by engineer. */ +"Sent at" = "Надіслано за"; + +/* copied message info */ +"Sent at: %@" = "Надіслано за: %@"; + +/* notification */ +"Sent file event" = "Подія надісланого файлу"; + +/* message info title */ +"Sent message" = "Надіслано повідомлення"; + +/* No comment provided by engineer. */ +"Sent messages will be deleted after set time." = "Надіслані повідомлення будуть видалені через встановлений час."; + +/* server test error */ +"Server requires authorization to create queues, check password" = "Сервер вимагає авторизації для створення черг, перевірте пароль"; + +/* server test error */ +"Server requires authorization to upload, check password" = "Сервер вимагає авторизації для завантаження, перевірте пароль"; + +/* No comment provided by engineer. */ +"Server test failed!" = "Тест сервера завершився невдало!"; + +/* No comment provided by engineer. */ +"Servers" = "Сервери"; + +/* No comment provided by engineer. */ +"Set 1 day" = "Встановити 1 день"; + +/* No comment provided by engineer. */ +"Set contact name…" = "Встановити ім'я контакту…"; + +/* No comment provided by engineer. */ +"Set group preferences" = "Встановіть налаштування групи"; + +/* No comment provided by engineer. */ +"Set it instead of system authentication." = "Встановіть його замість аутентифікації системи."; + +/* No comment provided by engineer. */ +"Set passcode" = "Встановити пароль"; + +/* No comment provided by engineer. */ +"Set passphrase to export" = "Встановити ключову фразу для експорту"; + +/* No comment provided by engineer. */ +"Set the message shown to new members!" = "Налаштуйте повідомлення, яке показуватиметься новим користувачам!"; + +/* No comment provided by engineer. */ +"Set timeouts for proxy/VPN" = "Встановлення таймаутів для проксі/VPN"; + +/* No comment provided by engineer. */ +"Settings" = "Налаштування"; + +/* chat item action */ +"Share" = "Поділіться"; + +/* No comment provided by engineer. */ +"Share 1-time link" = "Поділитися 1-разовим посиланням"; + +/* No comment provided by engineer. */ +"Share address" = "Поділитися адресою"; + +/* No comment provided by engineer. */ +"Share address with contacts?" = "Поділіться адресою з контактами?"; + +/* No comment provided by engineer. */ +"Share link" = "Поділіться посиланням"; + +/* No comment provided by engineer. */ +"Share one-time invitation link" = "Поділіться посиланням на одноразове запрошення"; + +/* No comment provided by engineer. */ +"Share with contacts" = "Поділіться з контактами"; + +/* No comment provided by engineer. */ +"Show calls in phone history" = "Показувати дзвінки в історії дзвінків"; + +/* No comment provided by engineer. */ +"Show developer options" = "Показати опції розробника"; + +/* No comment provided by engineer. */ +"Show last messages" = "Показати останні повідомлення"; + +/* No comment provided by engineer. */ +"Show preview" = "Показати попередній перегляд"; + +/* No comment provided by engineer. */ +"Show:" = "Показати:"; + +/* No comment provided by engineer. */ +"SimpleX address" = "Адреса SimpleX"; + +/* No comment provided by engineer. */ +"SimpleX Address" = "Адреса SimpleX"; + +/* No comment provided by engineer. */ +"SimpleX Chat security was audited by Trail of Bits." = "Безпека SimpleX Chat була перевірена компанією Trail of Bits."; + +/* simplex link type */ +"SimpleX contact address" = "Контактна адреса SimpleX"; + +/* notification */ +"SimpleX encrypted message or connection event" = "Зашифроване повідомлення SimpleX або подія підключення"; + +/* simplex link type */ +"SimpleX group link" = "Посилання на групу SimpleX"; + +/* No comment provided by engineer. */ +"SimpleX links" = "Посилання SimpleX"; + +/* No comment provided by engineer. */ +"SimpleX Lock" = "SimpleX Lock"; + +/* No comment provided by engineer. */ +"SimpleX Lock mode" = "Режим SimpleX Lock"; + +/* No comment provided by engineer. */ +"SimpleX Lock not enabled!" = "SimpleX Lock не ввімкнено!"; + +/* No comment provided by engineer. */ +"SimpleX Lock turned on" = "SimpleX Lock увімкнено"; + +/* simplex link type */ +"SimpleX one-time invitation" = "Одноразове запрошення SimpleX"; + +/* No comment provided by engineer. */ +"Skip" = "Пропустити"; + +/* No comment provided by engineer. */ +"Skipped messages" = "Пропущені повідомлення"; + +/* No comment provided by engineer. */ +"Small groups (max 20)" = "Невеликі групи (максимум 20 осіб)"; + +/* No comment provided by engineer. */ +"SMP servers" = "Сервери SMP"; + +/* No comment provided by engineer. */ +"Some non-fatal errors occurred during import - you may see Chat console for more details." = "Під час імпорту виникли деякі нефатальні помилки – ви можете переглянути консоль чату, щоб дізнатися більше."; + +/* notification title */ +"Somebody" = "Хтось"; + +/* No comment provided by engineer. */ +"Start a new chat" = "Почніть новий чат"; + +/* No comment provided by engineer. */ +"Start chat" = "Почати чат"; + +/* No comment provided by engineer. */ +"Start migration" = "Почати міграцію"; + +/* No comment provided by engineer. */ +"starting…" = "починаючи…"; + +/* No comment provided by engineer. */ +"Stop" = "Зупинити"; + +/* No comment provided by engineer. */ +"Stop chat to enable database actions" = "Зупиніть чат, щоб увімкнути дії з базою даних"; + +/* No comment provided by engineer. */ +"Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped." = "Зупиніть чат, щоб експортувати, імпортувати або видалити базу даних чату. Ви не зможете отримувати та надсилати повідомлення, поки чат зупинено."; + +/* No comment provided by engineer. */ +"Stop chat?" = "Зупинити чат?"; + +/* cancel file action */ +"Stop file" = "Зупинити файл"; + +/* No comment provided by engineer. */ +"Stop receiving file?" = "Припинити отримання файлу?"; + +/* No comment provided by engineer. */ +"Stop sending file?" = "Припинити надсилання файлу?"; + +/* No comment provided by engineer. */ +"Stop sharing" = "Припиніть ділитися"; + +/* No comment provided by engineer. */ +"Stop sharing address?" = "Припинити ділитися адресою?"; + +/* authentication reason */ +"Stop SimpleX" = "Зупинити SimpleX"; + +/* No comment provided by engineer. */ +"strike" = "закреслено"; + +/* No comment provided by engineer. */ +"Submit" = "Надіслати"; + +/* No comment provided by engineer. */ +"Support SimpleX Chat" = "Підтримка чату SimpleX"; + +/* No comment provided by engineer. */ +"System" = "Система"; + +/* No comment provided by engineer. */ +"System authentication" = "Автентифікація системи"; + +/* No comment provided by engineer. */ +"Take picture" = "Сфотографуйте"; + +/* No comment provided by engineer. */ +"Tap button " = "Натисніть кнопку "; + +/* No comment provided by engineer. */ +"Tap to activate profile." = "Натисніть, щоб активувати профіль."; + +/* No comment provided by engineer. */ +"Tap to join" = "Натисніть, щоб приєднатися"; + +/* No comment provided by engineer. */ +"Tap to join incognito" = "Натисніть, щоб приєднатися інкогніто"; + +/* No comment provided by engineer. */ +"Tap to start a new chat" = "Натисніть, щоб почати новий чат"; + +/* No comment provided by engineer. */ +"TCP connection timeout" = "Тайм-аут TCP-з'єднання"; + +/* No comment provided by engineer. */ +"TCP_KEEPCNT" = "TCP_KEEPCNT"; + +/* No comment provided by engineer. */ +"TCP_KEEPIDLE" = "TCP_KEEPIDLE"; + +/* No comment provided by engineer. */ +"TCP_KEEPINTVL" = "TCP_KEEPINTVL"; + +/* server test failure */ +"Test failed at step %@." = "Тест завершився невдало на кроці %@."; + +/* No comment provided by engineer. */ +"Test server" = "Тестовий сервер"; + +/* No comment provided by engineer. */ +"Test servers" = "Тестові сервери"; + +/* No comment provided by engineer. */ +"Tests failed!" = "Тести не пройшли!"; + +/* No comment provided by engineer. */ +"Thank you for installing SimpleX Chat!" = "Дякуємо, що встановили SimpleX Chat!"; + +/* No comment provided by engineer. */ +"Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Дякуємо користувачам - [внесок через Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; + +/* No comment provided by engineer. */ +"Thanks to the users – contribute via Weblate!" = "Дякуємо користувачам - зробіть свій внесок через Weblate!"; + +/* No comment provided by engineer. */ +"The 1st platform without any user identifiers – private by design." = "Перша платформа без жодних ідентифікаторів користувачів – приватна за дизайном."; + +/* No comment provided by engineer. */ +"The app can notify you when you receive messages or contact requests - please open settings to enable." = "Додаток може сповіщати вас, коли ви отримуєте повідомлення або запити на контакт - будь ласка, відкрийте налаштування, щоб увімкнути цю функцію."; + +/* No comment provided by engineer. */ +"The attempt to change database passphrase was not completed." = "Спроба змінити пароль до бази даних не була завершена."; + +/* No comment provided by engineer. */ +"The connection you accepted will be cancelled!" = "Прийняте вами з'єднання буде скасовано!"; + +/* No comment provided by engineer. */ +"The contact you shared this link with will NOT be able to connect!" = "Контакт, з яким ви поділилися цим посиланням, НЕ зможе підключитися!"; + +/* No comment provided by engineer. */ +"The created archive is available via app Settings / Database / Old database archive." = "Створений архів доступний через Налаштування програми / База даних / Старий архів бази даних."; + +/* No comment provided by engineer. */ +"The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Шифрування працює і нова угода про шифрування не потрібна. Це може призвести до помилок з'єднання!"; + +/* No comment provided by engineer. */ +"The group is fully decentralized – it is visible only to the members." = "Група повністю децентралізована - її бачать лише учасники."; + +/* No comment provided by engineer. */ +"The hash of the previous message is different." = "Хеш попереднього повідомлення відрізняється."; + +/* No comment provided by engineer. */ +"The ID of the next message is incorrect (less or equal to the previous).\nIt can happen because of some bug or when the connection is compromised." = "Ідентифікатор наступного повідомлення неправильний (менше або дорівнює попередньому).\nЦе може статися через помилку або коли з'єднання скомпрометовано."; + +/* No comment provided by engineer. */ +"The message will be deleted for all members." = "Повідомлення буде видалено для всіх учасників."; + +/* No comment provided by engineer. */ +"The message will be marked as moderated for all members." = "Повідомлення буде позначено як модероване для всіх учасників."; + +/* No comment provided by engineer. */ +"The next generation of private messaging" = "Наступне покоління приватних повідомлень"; + +/* No comment provided by engineer. */ +"The old database was not removed during the migration, it can be deleted." = "Стара база даних не була видалена під час міграції, її можна видалити."; + +/* No comment provided by engineer. */ +"The profile is only shared with your contacts." = "Профіль доступний лише вашим контактам."; + +/* No comment provided by engineer. */ +"The second tick we missed! ✅" = "Другу галочку ми пропустили! ✅"; + +/* No comment provided by engineer. */ +"The sender will NOT be notified" = "Відправник НЕ буде повідомлений"; + +/* No comment provided by engineer. */ +"The servers for new connections of your current chat profile **%@**." = "Сервери для нових підключень вашого поточного профілю чату **%@**."; + +/* No comment provided by engineer. */ +"Theme" = "Тема"; + +/* No comment provided by engineer. */ +"There should be at least one user profile." = "Повинен бути принаймні один профіль користувача."; + +/* No comment provided by engineer. */ +"There should be at least one visible user profile." = "Повинен бути принаймні один видимий профіль користувача."; + +/* No comment provided by engineer. */ +"These settings are for your current profile **%@**." = "Ці налаштування стосуються вашого поточного профілю **%@**."; + +/* No comment provided by engineer. */ +"They can be overridden in contact and group settings." = "Їх можна перевизначити в налаштуваннях контактів і груп."; + +/* No comment provided by engineer. */ +"This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Цю дію неможливо скасувати - всі отримані та надіслані файли і медіа будуть видалені. Зображення з низькою роздільною здатністю залишаться."; + +/* No comment provided by engineer. */ +"This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Цю дію неможливо скасувати - повідомлення, надіслані та отримані раніше, ніж вибрані, будуть видалені. Це може зайняти кілька хвилин."; + +/* No comment provided by engineer. */ +"This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Цю дію неможливо скасувати - ваш профіль, контакти, повідомлення та файли будуть безповоротно втрачені."; + +/* notification title */ +"this contact" = "цей контакт"; + +/* No comment provided by engineer. */ +"This group has over %lld members, delivery receipts are not sent." = "У цій групі більше %lld учасників, підтвердження доставки не надсилаються."; + +/* No comment provided by engineer. */ +"This group no longer exists." = "Цієї групи більше не існує."; + +/* No comment provided by engineer. */ +"This setting applies to messages in your current chat profile **%@**." = "Це налаштування застосовується до повідомлень у вашому поточному профілі чату **%@**."; + +/* No comment provided by engineer. */ +"To ask any questions and to receive updates:" = "Задати будь-які питання та отримувати новини:"; + +/* No comment provided by engineer. */ +"To connect, your contact can scan QR code or use the link in the app." = "Щоб підключитися, ваш контакт може відсканувати QR-код або скористатися посиланням у додатку."; + +/* No comment provided by engineer. */ +"To make a new connection" = "Щоб створити нове з'єднання"; + +/* No comment provided by engineer. */ +"To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts." = "Щоб захистити конфіденційність, замість ідентифікаторів користувачів, які використовуються на всіх інших платформах, SimpleX має ідентифікатори для черг повідомлень, окремі для кожного з ваших контактів."; + +/* No comment provided by engineer. */ +"To protect timezone, image/voice files use UTC." = "Для захисту часового поясу у файлах зображень/голосу використовується UTC."; + +/* No comment provided by engineer. */ +"To protect your information, turn on SimpleX Lock.\nYou will be prompted to complete authentication before this feature is enabled." = "Щоб захистити вашу інформацію, увімкніть SimpleX Lock.\nПеред увімкненням цієї функції вам буде запропоновано пройти автентифікацію."; + +/* No comment provided by engineer. */ +"To record voice message please grant permission to use Microphone." = "Щоб записати голосове повідомлення, будь ласка, надайте дозвіл на використання мікрофону."; + +/* No comment provided by engineer. */ +"To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "Щоб відкрити свій прихований профіль, введіть повний пароль у поле пошуку на сторінці **Ваші профілі чату**."; + +/* No comment provided by engineer. */ +"To support instant push notifications the chat database has to be migrated." = "Для підтримки миттєвих push-повідомлень необхідно перенести базу даних чату."; + +/* No comment provided by engineer. */ +"To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "Щоб перевірити наскрізне шифрування з вашим контактом, порівняйте (або відскануйте) код на ваших пристроях."; + +/* No comment provided by engineer. */ +"Transport isolation" = "Транспортна ізоляція"; + +/* No comment provided by engineer. */ +"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Спроба з'єднатися з сервером, який використовується для отримання повідомлень від цього контакту (помилка: %@)."; + +/* No comment provided by engineer. */ +"Trying to connect to the server used to receive messages from this contact." = "Спроба з'єднатися з сервером, який використовується для отримання повідомлень від цього контакту."; + +/* No comment provided by engineer. */ +"Turn off" = "Вимкнути"; + +/* No comment provided by engineer. */ +"Turn off notifications?" = "Вимкнути сповіщення?"; + +/* No comment provided by engineer. */ +"Turn on" = "Ввімкнути"; + +/* No comment provided by engineer. */ +"Unable to record voice message" = "Не вдається записати голосове повідомлення"; + +/* item status description */ +"Unexpected error: %@" = "Неочікувана помилка: %@"; + +/* No comment provided by engineer. */ +"Unexpected migration state" = "Неочікуваний стан міграції"; + +/* No comment provided by engineer. */ +"Unfav." = "Нелюб."; + +/* No comment provided by engineer. */ +"Unhide" = "Показати"; + +/* No comment provided by engineer. */ +"Unhide chat profile" = "Показати профіль чату"; + +/* No comment provided by engineer. */ +"Unhide profile" = "Показати профіль"; + +/* No comment provided by engineer. */ +"Unit" = "Одиниця"; + +/* connection info */ +"unknown" = "невідомий"; + +/* callkit banner */ +"Unknown caller" = "Невідомий абонент"; + +/* No comment provided by engineer. */ +"Unknown database error: %@" = "Невідома помилка бази даних: %@"; + +/* No comment provided by engineer. */ +"Unknown error" = "Невідома помилка"; + +/* No comment provided by engineer. */ +"Unless you use iOS call interface, enable Do Not Disturb mode to avoid interruptions." = "Якщо ви не користуєтеся інтерфейсом виклику iOS, увімкніть режим \"Не турбувати\", щоб уникнути переривань."; + +/* No comment provided by engineer. */ +"Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." = "Якщо ваш контакт не видалив з'єднання або якщо це посилання вже використовувалося, це може бути помилкою - будь ласка, повідомте про це.\nЩоб підключитися, попросіть вашого контакта створити інше посилання і перевірте, чи маєте ви стабільне з'єднання з мережею."; + +/* No comment provided by engineer. */ +"Unlock" = "Розблокувати"; + +/* authentication reason */ +"Unlock app" = "Розблокувати додаток"; + +/* No comment provided by engineer. */ +"Unmute" = "Увімкнути звук"; + +/* No comment provided by engineer. */ +"Unread" = "Непрочитане"; + +/* No comment provided by engineer. */ +"Update" = "Оновлення"; + +/* No comment provided by engineer. */ +"Update .onion hosts setting?" = "Оновити налаштування хостів .onion?"; + +/* No comment provided by engineer. */ +"Update database passphrase" = "Оновити парольну фразу бази даних"; + +/* No comment provided by engineer. */ +"Update network settings?" = "Оновити налаштування мережі?"; + +/* No comment provided by engineer. */ +"Update transport isolation mode?" = "Оновити режим транспортної ізоляції?"; + +/* rcv group event chat item */ +"updated group profile" = "оновлений профіль групи"; + +/* No comment provided by engineer. */ +"Updating settings will re-connect the client to all servers." = "Оновлення налаштувань призведе до перепідключення клієнта до всіх серверів."; + +/* No comment provided by engineer. */ +"Updating this setting will re-connect the client to all servers." = "Оновлення цього параметра призведе до перепідключення клієнта до всіх серверів."; + +/* No comment provided by engineer. */ +"Upgrade and open chat" = "Оновлення та відкритий чат"; + +/* server test step */ +"Upload file" = "Завантажити файл"; + +/* No comment provided by engineer. */ +"Use .onion hosts" = "Використовуйте хости .onion"; + +/* No comment provided by engineer. */ +"Use chat" = "Використовуйте чат"; + +/* No comment provided by engineer. */ +"Use current profile" = "Використовувати поточний профіль"; + +/* No comment provided by engineer. */ +"Use for new connections" = "Використовуйте для нових з'єднань"; + +/* No comment provided by engineer. */ +"Use iOS call interface" = "Використовуйте інтерфейс виклику iOS"; + +/* No comment provided by engineer. */ +"Use new incognito profile" = "Використовуйте новий профіль інкогніто"; + +/* No comment provided by engineer. */ +"Use server" = "Використовувати сервер"; + +/* No comment provided by engineer. */ +"Use SimpleX Chat servers?" = "Використовувати сервери SimpleX Chat?"; + +/* No comment provided by engineer. */ +"User profile" = "Профіль користувача"; + +/* No comment provided by engineer. */ +"Using .onion hosts requires compatible VPN provider." = "Для використання хостів .onion потрібен сумісний VPN-провайдер."; + +/* No comment provided by engineer. */ +"Using SimpleX Chat servers." = "Використання серверів SimpleX Chat."; + +/* No comment provided by engineer. */ +"v%@ (%@)" = "v%@ (%@)"; + +/* No comment provided by engineer. */ +"Verify connection security" = "Перевірте безпеку з'єднання"; + +/* No comment provided by engineer. */ +"Verify security code" = "Підтвердіть код безпеки"; + +/* No comment provided by engineer. */ +"Via browser" = "Через браузер"; + +/* chat list item description */ +"via contact address link" = "за посиланням на контактну адресу"; + +/* chat list item description */ +"via group link" = "за посиланням на групу"; + +/* chat list item description */ +"via one-time link" = "за одноразовим посиланням"; + +/* No comment provided by engineer. */ +"via relay" = "за допомогою ретранслятора"; + +/* No comment provided by engineer. */ +"Video call" = "Відеодзвінок"; + +/* No comment provided by engineer. */ +"video call (not e2e encrypted)" = "відеодзвінок (без шифрування e2e)"; + +/* No comment provided by engineer. */ +"Video will be received when your contact completes uploading it." = "Відео буде отримано, коли ваш контакт завершить завантаження."; + +/* No comment provided by engineer. */ +"Video will be received when your contact is online, please wait or check later!" = "Відео буде отримано, коли ваш контакт буде онлайн, будь ласка, зачекайте або перевірте пізніше!"; + +/* No comment provided by engineer. */ +"Videos and files up to 1gb" = "Відео та файли до 1 Гб"; + +/* No comment provided by engineer. */ +"View security code" = "Переглянути код безпеки"; + +/* No comment provided by engineer. */ +"Voice message…" = "Голосове повідомлення…"; + +/* chat feature */ +"Voice messages" = "Голосові повідомлення"; + +/* No comment provided by engineer. */ +"Voice messages are prohibited in this chat." = "Голосові повідомлення в цьому чаті заборонені."; + +/* No comment provided by engineer. */ +"Voice messages are prohibited in this group." = "Голосові повідомлення в цій групі заборонені."; + +/* No comment provided by engineer. */ +"Voice messages prohibited!" = "Голосові повідомлення заборонені!"; + +/* No comment provided by engineer. */ +"waiting for answer…" = "в очікуванні відповіді…"; + +/* No comment provided by engineer. */ +"waiting for confirmation…" = "чекаємо на підтвердження…"; + +/* No comment provided by engineer. */ +"Waiting for file" = "Очікування файлу"; + +/* No comment provided by engineer. */ +"Waiting for image" = "Очікування зображення"; + +/* No comment provided by engineer. */ +"Waiting for video" = "Чекаємо на відео"; + +/* No comment provided by engineer. */ +"wants to connect to you!" = "хоче зв'язатися з вами!"; + +/* No comment provided by engineer. */ +"Warning: you may lose some data!" = "Попередження: ви можете втратити деякі дані!"; + +/* No comment provided by engineer. */ +"WebRTC ICE servers" = "Сервери WebRTC ICE"; + +/* time unit */ +"weeks" = "тижнів"; + +/* No comment provided by engineer. */ +"Welcome %@!" = "Ласкаво просимо %@!"; + +/* No comment provided by engineer. */ +"Welcome message" = "Вітальне повідомлення"; + +/* No comment provided by engineer. */ +"What's new" = "Що нового"; + +/* No comment provided by engineer. */ +"When available" = "За наявності"; + +/* No comment provided by engineer. */ +"When people request to connect, you can accept or reject it." = "Коли люди звертаються із запитом на підключення, ви можете прийняти або відхилити його."; + +/* No comment provided by engineer. */ +"When you share an incognito profile with somebody, this profile will be used for the groups they invite you to." = "Коли ви ділитеся з кимось своїм профілем інкогніто, цей профіль буде використовуватися для груп, до яких вас запрошують."; + +/* No comment provided by engineer. */ +"With optional welcome message." = "З необов'язковим вітальним повідомленням."; + +/* No comment provided by engineer. */ +"Wrong database passphrase" = "Неправильний пароль до бази даних"; + +/* No comment provided by engineer. */ +"Wrong passphrase!" = "Неправильний пароль!"; + +/* No comment provided by engineer. */ +"XFTP servers" = "Сервери XFTP"; + +/* pref value */ +"yes" = "так"; + +/* No comment provided by engineer. */ +"You" = "Ти"; + +/* No comment provided by engineer. */ +"You accepted connection" = "Ви прийняли підключення"; + +/* No comment provided by engineer. */ +"You allow" = "Ви дозволяєте"; + +/* No comment provided by engineer. */ +"You already have a chat profile with the same display name. Please choose another name." = "Ви вже маєте профіль у чаті з таким самим іменем. Будь ласка, виберіть інше ім'я."; + +/* No comment provided by engineer. */ +"You are already connected to %@." = "Ви вже підключені до %@."; + +/* No comment provided by engineer. */ +"You are connected to the server used to receive messages from this contact." = "Ви підключені до сервера, який використовується для отримання повідомлень від цього контакту."; + +/* No comment provided by engineer. */ +"you are invited to group" = "вас запрошують до групи"; + +/* No comment provided by engineer. */ +"You are invited to group" = "Запрошуємо вас до групи"; + +/* No comment provided by engineer. */ +"you are observer" = "ви спостерігач"; + +/* No comment provided by engineer. */ +"You can accept calls from lock screen, without device and app authentication." = "Ви можете приймати дзвінки з екрана блокування без автентифікації пристрою та програми."; + +/* No comment provided by engineer. */ +"You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button." = "Ви також можете підключитися за посиланням. Якщо воно відкриється в браузері, натисніть кнопку **Відкрити в мобільному додатку**."; + +/* No comment provided by engineer. */ +"You can create it later" = "Ви можете створити його пізніше"; + +/* No comment provided by engineer. */ +"You can enable later via Settings" = "Ви можете увімкнути пізніше в Налаштуваннях"; + +/* No comment provided by engineer. */ +"You can enable them later via app Privacy & Security settings." = "Ви можете увімкнути їх пізніше в налаштуваннях конфіденційності та безпеки програми."; + +/* No comment provided by engineer. */ +"You can hide or mute a user profile - swipe it to the right." = "Ви можете приховати або вимкнути звук профілю користувача - проведіть по ньому вправо."; + +/* notification body */ +"You can now send messages to %@" = "Тепер ви можете надсилати повідомлення на адресу %@"; + +/* No comment provided by engineer. */ +"You can set lock screen notification preview via settings." = "Ви можете налаштувати попередній перегляд сповіщень на екрані блокування за допомогою налаштувань."; + +/* No comment provided by engineer. */ +"You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it." = "Ви можете поділитися посиланням або QR-кодом - будь-хто зможе приєднатися до групи. Ви не втратите учасників групи, якщо згодом видалите її."; + +/* No comment provided by engineer. */ +"You can share this address with your contacts to let them connect with **%@**." = "Ви можете поділитися цією адресою зі своїми контактами, щоб вони могли зв'язатися з **%@**."; + +/* No comment provided by engineer. */ +"You can share your address as a link or QR code - anybody can connect to you." = "Ви можете поділитися своєю адресою у вигляді посилання або QR-коду - будь-хто зможе зв'язатися з вами."; + +/* No comment provided by engineer. */ +"You can start chat via app Settings / Database or by restarting the app" = "Запустити чат можна через Налаштування програми / База даних або перезапустивши програму"; + +/* No comment provided by engineer. */ +"You can turn on SimpleX Lock via Settings." = "Увімкнути SimpleX Lock можна в Налаштуваннях."; + +/* No comment provided by engineer. */ +"You can use markdown to format messages:" = "Ви можете використовувати розмітку для форматування повідомлень:"; + +/* No comment provided by engineer. */ +"You can't send messages!" = "Ви не можете надсилати повідомлення!"; + +/* chat item text */ +"you changed address" = "ви змінили адресу"; + +/* chat item text */ +"you changed address for %@" = "ви змінили адресу на %@"; + +/* snd group event chat item */ +"you changed role for yourself to %@" = "ви змінили роль для себе на %@"; + +/* snd group event chat item */ +"you changed role of %@ to %@" = "ви змінили роль %1$@ на %2$@"; + +/* No comment provided by engineer. */ +"You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them." = "Ви контролюєте, через який(і) сервер(и) **отримувати** повідомлення, ваші контакти - сервери, які ви використовуєте для надсилання їм повідомлень."; + +/* No comment provided by engineer. */ +"You could not be verified; please try again." = "Вас не вдалося верифікувати, спробуйте ще раз."; + +/* No comment provided by engineer. */ +"You have no chats" = "У вас немає чатів"; + +/* No comment provided by engineer. */ +"You have to enter passphrase every time the app starts - it is not stored on the device." = "Вам доведеться вводити парольну фразу щоразу під час запуску програми - вона не зберігається на пристрої."; + +/* No comment provided by engineer. */ +"You invited a contact" = "Ви запросили контакт"; + +/* No comment provided by engineer. */ +"You joined this group" = "Ви приєдналися до цієї групи"; + +/* No comment provided by engineer. */ +"You joined this group. Connecting to inviting group member." = "Ви приєдналися до цієї групи. Підключення до запрошеного учасника групи."; + +/* snd group event chat item */ +"you left" = "ти пішов"; + +/* No comment provided by engineer. */ +"You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts." = "Ви повинні використовувати найновішу версію бази даних чату ТІЛЬКИ на одному пристрої, інакше ви можете перестати отримувати повідомлення від деяких контактів."; + +/* No comment provided by engineer. */ +"You need to allow your contact to send voice messages to be able to send them." = "Щоб мати змогу надсилати голосові повідомлення, вам потрібно дозволити контакту надсилати їх."; + +/* No comment provided by engineer. */ +"You rejected group invitation" = "Ви відхилили запрошення до групи"; + +/* snd group event chat item */ +"you removed %@" = "ви видалили %@"; + +/* No comment provided by engineer. */ +"You sent group invitation" = "Ви надіслали запрошення до групи"; + +/* chat list item description */ +"you shared one-time link" = "ви поділилися одноразовим посиланням"; + +/* chat list item description */ +"you shared one-time link incognito" = "ви поділилися одноразовим посиланням інкогніто"; + +/* No comment provided by engineer. */ +"You will be connected to group when the group host's device is online, please wait or check later!" = "Ви будете підключені до групи, коли пристрій господаря групи буде в мережі, будь ласка, зачекайте або перевірте пізніше!"; + +/* No comment provided by engineer. */ +"You will be connected when your connection request is accepted, please wait or check later!" = "Ви будете підключені, коли ваш запит на підключення буде прийнято, будь ласка, зачекайте або перевірте пізніше!"; + +/* No comment provided by engineer. */ +"You will be connected when your contact's device is online, please wait or check later!" = "Ви будете з'єднані, коли пристрій вашого контакту буде онлайн, будь ласка, зачекайте або перевірте пізніше!"; + +/* No comment provided by engineer. */ +"You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Вам потрібно буде пройти автентифікацію при запуску або відновленні програми після 30 секунд роботи у фоновому режимі."; + +/* No comment provided by engineer. */ +"You will join a group this link refers to and connect to its group members." = "Ви приєднаєтеся до групи, на яку посилається це посилання, і з'єднаєтеся з її учасниками."; + +/* No comment provided by engineer. */ +"You will still receive calls and notifications from muted profiles when they are active." = "Ви все одно отримуватимете дзвінки та сповіщення від вимкнених профілів, якщо вони активні."; + +/* No comment provided by engineer. */ +"You will stop receiving messages from this group. Chat history will be preserved." = "Ви перестанете отримувати повідомлення від цієї групи. Історія чату буде збережена."; + +/* No comment provided by engineer. */ +"You won't lose your contacts if you later delete your address." = "Ви не втратите свої контакти, якщо згодом видалите свою адресу."; + +/* No comment provided by engineer. */ +"you: " = "ти: "; + +/* No comment provided by engineer. */ +"You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile" = "Ви намагаєтеся запросити контакт, з яким ви поділилися профілем інкогніто, до групи, в якій ви використовуєте свій основний профіль"; + +/* No comment provided by engineer. */ +"You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed" = "Ви використовуєте профіль інкогніто для цієї групи - щоб запобігти поширенню вашого основного профілю, запрошення контактів заборонено"; + +/* No comment provided by engineer. */ +"Your %@ servers" = "Ваші сервери %@"; + +/* No comment provided by engineer. */ +"Your calls" = "Твої дзвінки"; + +/* No comment provided by engineer. */ +"Your chat database" = "Ваша база даних чату"; + +/* No comment provided by engineer. */ +"Your chat database is not encrypted - set passphrase to encrypt it." = "Ваша база даних чату не зашифрована - встановіть ключову фразу, щоб зашифрувати її."; + +/* No comment provided by engineer. */ +"Your chat profile will be sent to group members" = "Ваш профіль у чаті буде надіслано учасникам групи"; + +/* No comment provided by engineer. */ +"Your chat profiles" = "Ваші профілі чату"; + +/* No comment provided by engineer. */ +"Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)." = "Для завершення з'єднання ваш контакт має бути онлайн.\nВи можете скасувати це з'єднання і видалити контакт (і спробувати пізніше з новим посиланням)."; + +/* No comment provided by engineer. */ +"Your contact sent a file that is larger than currently supported maximum size (%@)." = "Ваш контакт надіслав файл, розмір якого перевищує підтримуваний на цей момент максимальний розмір (%@)."; + +/* No comment provided by engineer. */ +"Your contacts can allow full message deletion." = "Ваші контакти можуть дозволити повне видалення повідомлень."; + +/* No comment provided by engineer. */ +"Your contacts in SimpleX will see it.\nYou can change it in Settings." = "Ваші контакти в SimpleX побачать це.\nВи можете змінити його в Налаштуваннях."; + +/* No comment provided by engineer. */ +"Your contacts will remain connected." = "Ваші контакти залишаться на зв'язку."; + +/* No comment provided by engineer. */ +"Your current chat database will be DELETED and REPLACED with the imported one." = "Ваша поточна база даних чату буде ВИДАЛЕНА і ЗАМІНЕНА імпортованою."; + +/* No comment provided by engineer. */ +"Your current profile" = "Ваш поточний профіль"; + +/* No comment provided by engineer. */ +"Your ICE servers" = "Ваші сервери ICE"; + +/* No comment provided by engineer. */ +"Your preferences" = "Ваші уподобання"; + +/* No comment provided by engineer. */ +"Your privacy" = "Ваша конфіденційність"; + +/* No comment provided by engineer. */ +"Your profile **%@** will be shared." = "Ваш профіль **%@** буде опублікований."; + +/* No comment provided by engineer. */ +"Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "Ваш профіль зберігається на вашому пристрої і доступний лише вашим контактам.\nСервери SimpleX не бачать ваш профіль."; + +/* No comment provided by engineer. */ +"Your profile, contacts and delivered messages are stored on your device." = "Ваш профіль, контакти та доставлені повідомлення зберігаються на вашому пристрої."; + +/* No comment provided by engineer. */ +"Your random profile" = "Ваш випадковий профіль"; + +/* No comment provided by engineer. */ +"Your server" = "Ваш сервер"; + +/* No comment provided by engineer. */ +"Your server address" = "Адреса вашого сервера"; + +/* No comment provided by engineer. */ +"Your settings" = "Ваші налаштування"; + +/* No comment provided by engineer. */ +"Your SimpleX address" = "Ваша адреса SimpleX"; + +/* No comment provided by engineer. */ +"Your SMP servers" = "Ваші SMP-сервери"; + +/* No comment provided by engineer. */ +"Your XFTP servers" = "Ваші XFTP-сервери"; + diff --git a/apps/ios/uk.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/uk.lproj/SimpleX--iOS--InfoPlist.strings new file mode 100644 index 0000000000..2e3c6b8930 --- /dev/null +++ b/apps/ios/uk.lproj/SimpleX--iOS--InfoPlist.strings @@ -0,0 +1,15 @@ +/* Bundle name */ +"CFBundleName" = "SimpleX"; + +/* Privacy - Camera Usage Description */ +"NSCameraUsageDescription" = "SimpleX потребує доступу до камери, щоб сканувати QR-коди для з'єднання з іншими користувачами та для відеодзвінків."; + +/* Privacy - Face ID Usage Description */ +"NSFaceIDUsageDescription" = "SimpleX використовує Face ID для локальної автентифікації"; + +/* Privacy - Microphone Usage Description */ +"NSMicrophoneUsageDescription" = "SimpleX потребує доступу до мікрофона для аудіо та відео дзвінків, а також для запису голосових повідомлень."; + +/* Privacy - Photo Library Additions Usage Description */ +"NSPhotoLibraryAddUsageDescription" = "SimpleX потребує доступу до фототеки для збереження захоплених та отриманих медіафайлів"; + diff --git a/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings index 4741828461..a7f42837e3 100644 --- a/apps/ios/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/zh-Hans.lproj/Localizable.strings @@ -19,6 +19,9 @@ /* No comment provided by engineer. */ "_italic_" = "\\_斜体_"; +/* No comment provided by engineer. */ +"- more stable message delivery.\n- a bit better groups.\n- and more!" = "- 更稳定的传输!\n- 更好的社群!\n- 以及更多!"; + /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- 语音消息最长5分钟。\n- 自定义限时消息。\n- 编辑消息历史。"; @@ -85,6 +88,15 @@ /* No comment provided by engineer. */ "*bold*" = "\\*加粗*"; +/* copied message info title, # <title> */ +"# %@" = "# %@"; + +/* copied message info */ +"## History" = "## 历史"; + +/* copied message info */ +"## In reply to" = "## 回复"; + /* No comment provided by engineer. */ "#secret#" = "#秘密#"; @@ -103,6 +115,12 @@ /* No comment provided by engineer. */ "%@ %@" = "%@ %@"; +/* No comment provided by engineer. */ +"%@ and %@ connected" = "%@ 和%@ 以建立连接"; + +/* copied message info, <sender> at <time> */ +"%@ at %@:" = "%2$@:"; + /* notification title */ "%@ is connected!" = "%@ 已连接!"; @@ -118,6 +136,9 @@ /* notification title */ "%@ wants to connect!" = "%@ 要连接!"; +/* No comment provided by engineer. */ +"%@, %@ and %lld other members connected" = "%@, %@ 和 %lld 个成员"; + /* copied message info */ "%@:" = "%@:"; @@ -232,9 +253,15 @@ /* No comment provided by engineer. */ "30 seconds" = "30秒"; +/* No comment provided by engineer. */ +"A few more things" = ""; + /* notification title */ "A new contact" = "新联系人"; +/* No comment provided by engineer. */ +"A new random profile will be shared." = "创建一个随机的共享文件"; + /* No comment provided by engineer. */ "A separate TCP connection will be used **for each chat profile you have in the app**." = "一个单独的 TCP 连接将被用于**您在应用程序中的每个聊天资料**。"; diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts index e896047ca9..bd45ee125f 100644 --- a/apps/multiplatform/android/build.gradle.kts +++ b/apps/multiplatform/android/build.gradle.kts @@ -83,12 +83,15 @@ android { // Comma separated list of languages that will be included in the apk android.defaultConfig.resConfigs( "en", + "ar", "bg", "cs", "de", "es", + "fi", "fr", "it", + "iw", "ja", "nl", "pl", diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt index 55d8202f89..06def4ce10 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt @@ -141,7 +141,12 @@ fun processExternalIntent(intent: Intent?) { when { intent.type == "text/plain" -> { val text = intent.getStringExtra(Intent.EXTRA_TEXT) - if (text != null) { + val uri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri + if (uri != null) { + // Shared file that contains plain text, like `*.log` file + chatModel.sharedContent.value = SharedContent.File(text ?: "", uri.toURI()) + } else if (text != null) { + // Shared just a text chatModel.sharedContent.value = SharedContent.Text(text) } } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index c94194a358..f70032788b 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -71,7 +71,7 @@ class SimplexApp: Application(), LifecycleEventObserver { } Lifecycle.Event.ON_RESUME -> { isAppOnForeground = true - if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) { + if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) { SimplexService.showBackgroundServiceNoticeIfNeeded() } /** @@ -80,7 +80,7 @@ class SimplexApp: Application(), LifecycleEventObserver { * It can happen when app was started and a user enables battery optimization while app in background * */ if (chatModel.chatRunning.value != false && - chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete && + chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete && appPrefs.notificationsMode.get() == NotificationsMode.SERVICE ) { SimplexService.start() @@ -191,7 +191,7 @@ class SimplexApp: Application(), LifecycleEventObserver { override fun androidChatInitializedAndStarted() { // Prevents from showing "Enable notifications" alert when onboarding wasn't complete yet - if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) { + if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) { SimplexService.showBackgroundServiceNoticeIfNeeded() if (appPrefs.notificationsMode.get() == NotificationsMode.SERVICE) withBGApi { diff --git a/apps/multiplatform/build.gradle.kts b/apps/multiplatform/build.gradle.kts index f277da4bde..3a6fbcbf94 100644 --- a/apps/multiplatform/build.gradle.kts +++ b/apps/multiplatform/build.gradle.kts @@ -46,7 +46,7 @@ buildscript { classpath("com.android.tools.build:gradle:${rootProject.extra["gradle.plugin.version"]}") classpath(kotlin("gradle-plugin", version = rootProject.extra["kotlin.version"] as String)) classpath("org.jetbrains.kotlin:kotlin-serialization:1.3.2") - classpath("dev.icerock.moko:resources-generator:0.22.3") + classpath("dev.icerock.moko:resources-generator:0.23.0") // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 14caaa252d..5b9560b07e 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -39,7 +39,7 @@ kotlin { api("org.jetbrains.kotlinx:kotlinx-datetime:0.3.2") api("com.russhwolf:multiplatform-settings:1.0.0") api("com.charleskorn.kaml:kaml:0.43.0") - api("dev.icerock.moko:resources-compose:0.22.3") + api("dev.icerock.moko:resources-compose:0.23.0") api("org.jetbrains.compose.ui:ui-text:${rootProject.extra["compose.version"] as String}") implementation("org.jetbrains.compose.components:components-animatedimage:${rootProject.extra["compose.version"] as String}") //Barcode @@ -48,7 +48,7 @@ kotlin { // Link Previews implementation("org.jsoup:jsoup:1.13.1") // Resources - implementation("dev.icerock.moko:resources:0.22.3") + implementation("dev.icerock.moko:resources:0.23.0") } } val commonTest by getting { @@ -62,7 +62,7 @@ kotlin { val work_version = "2.7.1" implementation("androidx.work:work-runtime-ktx:$work_version") implementation("com.google.accompanist:accompanist-insets:0.23.0") - implementation("dev.icerock.moko:resources:0.22.3") + implementation("dev.icerock.moko:resources:0.23.0") // Video support implementation("com.google.android.exoplayer:exoplayer:2.17.1") diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt index 35c29371e0..161bc51e61 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt @@ -23,6 +23,8 @@ actual val agentDatabaseFileName: String = "files_agent.db" actual val databaseExportDir: File = androidAppContext.cacheDir +actual fun desktopOpenDatabaseDir() {} + @Composable actual fun rememberFileChooserLauncher(getContent: Boolean, rememberedValue: Any?, onResult: (URI?) -> Unit): FileChooserLauncher { val launcher = rememberLauncherForActivityResult( diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt index c24ade47d9..ebc1b416b5 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt @@ -1,6 +1,5 @@ package chat.simplex.common.platform -import android.app.Application import android.content.Context import android.media.* import android.media.AudioManager.AudioPlaybackCallback @@ -8,10 +7,10 @@ import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED import android.os.Build import androidx.compose.runtime.* -import chat.simplex.res.MR -import chat.simplex.common.model.ChatItem +import chat.simplex.common.model.* import chat.simplex.common.platform.AudioPlayer.duration import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR import kotlinx.coroutines.* import java.io.* @@ -134,20 +133,25 @@ actual object AudioPlayer: AudioPlayerInterface { } // Returns real duration of the track - private fun start(filePath: String, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> Unit): Int? { - if (!File(filePath).exists()) { - Log.e(TAG, "No such file: $filePath") + private fun start(fileSource: CryptoFile, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> Unit): Int? { + val absoluteFilePath = getAppFilePath(fileSource.filePath) + if (!File(absoluteFilePath).exists()) { + Log.e(TAG, "No such file: ${fileSource.filePath}") return null } VideoPlayer.stopAll() RecorderInterface.stopRecording?.invoke() val current = currentlyPlaying.value - if (current == null || current.first != filePath) { + if (current == null || current.first != fileSource.filePath) { stopListener() player.reset() runCatching { - player.setDataSource(filePath) + if (fileSource.cryptoArgs != null) { + player.setDataSource(CryptoMediaSource(readCryptoFile(absoluteFilePath, fileSource.cryptoArgs))) + } else { + player.setDataSource(absoluteFilePath) + } }.onFailure { Log.e(TAG, it.stackTraceToString()) AlertManager.shared.showAlertMsg(generalGetString(MR.strings.unknown_error), it.message) @@ -162,7 +166,7 @@ actual object AudioPlayer: AudioPlayerInterface { } if (seek != null) player.seekTo(seek) player.start() - currentlyPlaying.value = filePath to onProgressUpdate + currentlyPlaying.value = fileSource.filePath to onProgressUpdate progressJob = CoroutineScope(Dispatchers.Default).launch { onProgressUpdate(player.currentPosition, TrackState.PLAYING) while(isActive && player.isPlaying) { @@ -229,7 +233,7 @@ actual object AudioPlayer: AudioPlayerInterface { } override fun play( - filePath: String?, + fileSource: CryptoFile, audioPlaying: MutableState<Boolean>, progress: MutableState<Int>, duration: MutableState<Int>, @@ -238,7 +242,7 @@ actual object AudioPlayer: AudioPlayerInterface { if (progress.value == duration.value) { progress.value = 0 } - val realDuration = start(filePath ?: return, progress.value) { pro, state -> + val realDuration = start(fileSource, progress.value) { pro, state -> if (pro != null) { progress.value = pro } @@ -283,3 +287,21 @@ actual object AudioPlayer: AudioPlayerInterface { } actual typealias SoundPlayer = chat.simplex.common.helpers.SoundPlayer + +class CryptoMediaSource(val data: ByteArray) : MediaDataSource() { + override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int { + if (position >= data.size) return -1 + + val endPosition: Int = (position + size).toInt() + var sizeLeft: Int = size + if (endPosition > data.size) { + sizeLeft -= endPosition - data.size + } + + System.arraycopy(data, position.toInt(), buffer, offset, sizeLeft) + return sizeLeft + } + + override fun getSize(): Long = data.size.toLong() + override fun close() {} +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt index 811974b2d5..a370bbf405 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt @@ -8,13 +8,15 @@ import android.provider.MediaStore import android.webkit.MimeTypeMap import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.UriHandler -import chat.simplex.common.helpers.toUri -import chat.simplex.common.model.CIFile -import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.common.views.helpers.getAppFileUri +import androidx.core.content.FileProvider +import androidx.core.net.toUri +import chat.simplex.common.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.views.helpers.* import java.io.BufferedOutputStream import java.io.File import chat.simplex.res.MR +import java.io.ByteArrayOutputStream actual fun ClipboardManager.shareText(text: String) { val sendIntent: Intent = Intent().apply { @@ -28,9 +30,17 @@ actual fun ClipboardManager.shareText(text: String) { androidAppContext.startActivity(shareIntent) } -actual fun shareFile(text: String, filePath: String) { - val uri = getAppFileUri(filePath.substringAfterLast(File.separator)) - val ext = filePath.substringAfterLast(".") +actual fun shareFile(text: String, fileSource: CryptoFile) { + val uri = if (fileSource.cryptoArgs != null) { + val tmpFile = File(tmpDir, fileSource.filePath) + tmpFile.deleteOnExit() + ChatModel.filesToDelete.add(tmpFile) + decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, tmpFile.absolutePath) + FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", File(tmpFile.absolutePath)).toURI() + } else { + getAppFileUri(fileSource.filePath) + } + val ext = fileSource.filePath.substringAfterLast(".") val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: return val sendIntent: Intent = Intent().apply { action = Intent.ACTION_SEND @@ -84,8 +94,16 @@ fun saveImage(ciFile: CIFile?) { uri?.let { androidAppContext.contentResolver.openOutputStream(uri)?.let { stream -> val outputStream = BufferedOutputStream(stream) - File(filePath).inputStream().use { it.copyTo(outputStream) } - outputStream.close() + if (ciFile.fileSource?.cryptoArgs != null) { + createTmpFileAndDelete { tmpFile -> + decryptCryptoFile(filePath, ciFile.fileSource.cryptoArgs, tmpFile.absolutePath) + tmpFile.inputStream().use { it.copyTo(outputStream) } + } + outputStream.close() + } else { + File(filePath).inputStream().use { it.copyTo(outputStream) } + outputStream.close() + } showToast(generalGetString(MR.strings.image_saved)) } } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.android.kt index dc8e9dd541..28c00ec018 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.android.kt @@ -19,7 +19,7 @@ import java.net.URI @Composable actual fun SimpleAndAnimatedImageView( - uri: URI, + data: ByteArray, imageBitmap: ImageBitmap, file: CIFile?, imageProvider: () -> ImageGalleryProvider, @@ -27,7 +27,7 @@ actual fun SimpleAndAnimatedImageView( ) { val context = LocalContext.current val imagePainter = rememberAsyncImagePainter( - ImageRequest.Builder(context).data(data = uri.toUri()).size(coil.size.Size.ORIGINAL).build(), + ImageRequest.Builder(context).data(data = data).size(coil.size.Size.ORIGINAL).build(), placeholder = BitmapPainter(imageBitmap), // show original image while it's still loading by coil imageLoader = imageLoader ) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt index 15421299a8..8bb70c4a09 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt @@ -26,7 +26,7 @@ actual fun ReactionIcon(text: String, fontSize: TextUnit) { @Composable actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState<Boolean>) { val writePermissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE) - ItemAction(stringResource(MR.strings.save_verb), painterResource(MR.images.ic_download), onClick = { + ItemAction(stringResource(MR.strings.save_verb), painterResource(if (cItem.file?.fileSource?.cryptoArgs == null) MR.images.ic_download else MR.images.ic_lock_open_right), onClick = { when (cItem.content.msgContent) { is MsgContent.MCImage -> { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || writePermissionState.hasPermission) { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt index d23ee58db2..ade538a044 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt @@ -26,7 +26,7 @@ import dev.icerock.moko.resources.compose.stringResource import java.net.URI @Composable -actual fun FullScreenImageView(modifier: Modifier, uri: URI, imageBitmap: ImageBitmap) { +actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap) { // I'm making a new instance of imageLoader here because if I use one instance in multiple places // after end of composition here a GIF from the first instance will be paused automatically which isn't what I want val imageLoader = ImageLoader.Builder(LocalContext.current) @@ -40,7 +40,7 @@ actual fun FullScreenImageView(modifier: Modifier, uri: URI, imageBitmap: ImageB .build() Image( rememberAsyncImagePainter( - ImageRequest.Builder(LocalContext.current).data(data = uri.toUri()).size(Size.ORIGINAL).build(), + ImageRequest.Builder(LocalContext.current).data(data = data).size(Size.ORIGINAL).build(), placeholder = BitmapPainter(imageBitmap), // show original image while it's still loading by coil imageLoader = imageLoader ), diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt new file mode 100644 index 0000000000..df2499926f --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt @@ -0,0 +1,106 @@ +package chat.simplex.common.views.database + +import SectionItemView +import SectionTextFooter +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import chat.simplex.common.ui.theme.SimplexGreen +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +actual fun SavePassphraseSetting( + useKeychain: Boolean, + initialRandomDBPassphrase: Boolean, + storedKey: Boolean, + progressIndicator: Boolean, + minHeight: Dp, + onCheckedChange: (Boolean) -> Unit, +) { + SectionItemView(minHeight = minHeight) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + if (storedKey) painterResource(MR.images.ic_vpn_key_filled) else painterResource(MR.images.ic_vpn_key_off_filled), + stringResource(MR.strings.save_passphrase_in_keychain), + tint = if (storedKey) SimplexGreen else MaterialTheme.colors.secondary + ) + Spacer(Modifier.padding(horizontal = 4.dp)) + Text( + stringResource(MR.strings.save_passphrase_in_keychain), + Modifier.padding(end = 24.dp), + color = Color.Unspecified + ) + Spacer(Modifier.fillMaxWidth().weight(1f)) + DefaultSwitch( + checked = useKeychain, + onCheckedChange = onCheckedChange, + enabled = !initialRandomDBPassphrase && !progressIndicator + ) + } + } +} + +@Composable +actual fun DatabaseEncryptionFooter( + useKeychain: MutableState<Boolean>, + chatDbEncrypted: Boolean?, + storedKey: MutableState<Boolean>, + initialRandomDBPassphrase: MutableState<Boolean>, +) { + if (chatDbEncrypted == false) { + SectionTextFooter(generalGetString(MR.strings.database_is_not_encrypted)) + } else if (useKeychain.value) { + if (storedKey.value) { + SectionTextFooter(generalGetString(MR.strings.keychain_is_storing_securely)) + if (initialRandomDBPassphrase.value) { + SectionTextFooter(generalGetString(MR.strings.encrypted_with_random_passphrase)) + } else { + SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase)) + } + } else { + SectionTextFooter(generalGetString(MR.strings.keychain_allows_to_receive_ntfs)) + } + } else { + SectionTextFooter(generalGetString(MR.strings.you_have_to_enter_passphrase_every_time)) + SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase)) + } +} + +actual fun encryptDatabaseSavedAlert(onConfirm: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.encrypt_database_question), + text = generalGetString(MR.strings.database_will_be_encrypted_and_passphrase_stored) + "\n" + storeSecurelySaved(), + confirmText = generalGetString(MR.strings.encrypt_database), + onConfirm = onConfirm, + destructive = true, + ) +} + +actual fun changeDatabaseKeySavedAlert(onConfirm: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.change_database_passphrase_question), + text = generalGetString(MR.strings.database_encryption_will_be_updated) + "\n" + storeSecurelySaved(), + confirmText = generalGetString(MR.strings.update_database), + onConfirm = onConfirm, + destructive = false, + ) +} + +actual fun removePassphraseAlert(onConfirm: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.remove_passphrase_from_keychain), + text = generalGetString(MR.strings.notifications_will_be_hidden) + "\n" + storeSecurelyDanger(), + confirmText = generalGetString(MR.strings.remove_passphrase), + onConfirm = onConfirm, + destructive = true, + ) +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt index 67c41c3d79..e3c857716d 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt @@ -1,6 +1,5 @@ package chat.simplex.common.views.helpers -import android.app.Application import android.content.res.Resources import android.graphics.* import android.graphics.Typeface @@ -12,11 +11,8 @@ import android.text.Spanned import android.text.SpannedString import android.text.style.* import android.util.Base64 -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.* import androidx.compose.ui.text.* import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.BaselineShift @@ -159,17 +155,18 @@ actual fun getAppFileUri(fileName: String): URI = FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", File(getAppFilePath(fileName))).toURI() // https://developer.android.com/training/data-storage/shared/documents-files#bitmap -actual fun getLoadedImage(file: CIFile?): ImageBitmap? { +actual fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>? { val filePath = getLoadedFilePath(file) - return if (filePath != null) { + return if (filePath != null && file != null) { try { - val uri = getAppFileUri(filePath.substringAfterLast(File.separator)) - val parcelFileDescriptor = androidAppContext.contentResolver.openFileDescriptor(uri.toUri(), "r") - val fileDescriptor = parcelFileDescriptor?.fileDescriptor - val image = decodeSampledBitmapFromFileDescriptor(fileDescriptor, 1000, 1000) - parcelFileDescriptor?.close() - image.asImageBitmap() + val data = if (file.fileSource?.cryptoArgs != null) { + readCryptoFile(getAppFilePath(file.fileSource.filePath), file.fileSource.cryptoArgs) + } else { + File(getAppFilePath(file.fileName)).readBytes() + } + decodeSampledBitmapFromByteArray(data, 1000, 1000).asImageBitmap() to data } catch (e: Exception) { + Log.e(TAG, e.stackTraceToString()) null } } else { @@ -178,17 +175,17 @@ actual fun getLoadedImage(file: CIFile?): ImageBitmap? { } // https://developer.android.com/topic/performance/graphics/load-bitmap#load-bitmap -private fun decodeSampledBitmapFromFileDescriptor(fileDescriptor: FileDescriptor?, reqWidth: Int, reqHeight: Int): Bitmap { +private fun decodeSampledBitmapFromByteArray(data: ByteArray, reqWidth: Int, reqHeight: Int): Bitmap { // First decode with inJustDecodeBounds=true to check dimensions return BitmapFactory.Options().run { inJustDecodeBounds = true - BitmapFactory.decodeFileDescriptor(fileDescriptor, null, this) + BitmapFactory.decodeByteArray(data, 0, data.size) // Calculate inSampleSize inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight) // Decode bitmap with inSampleSize set inJustDecodeBounds = false - BitmapFactory.decodeFileDescriptor(fileDescriptor, null, this) + BitmapFactory.decodeByteArray(data, 0, data.size) } } @@ -254,6 +251,26 @@ actual fun getBitmapFromUri(uri: URI, withAlertOnException: Boolean): ImageBitma }?.asImageBitmap() } +actual fun getBitmapFromByteArray(data: ByteArray, withAlertOnException: Boolean): ImageBitmap? { + return if (Build.VERSION.SDK_INT >= 31) { + val source = ImageDecoder.createSource(data) + try { + ImageDecoder.decodeBitmap(source) + } catch (e: android.graphics.ImageDecoder.DecodeException) { + Log.e(TAG, "Unable to decode the image: ${e.stackTraceToString()}") + if (withAlertOnException) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.image_decoding_exception_title), + text = generalGetString(MR.strings.image_decoding_exception_desc) + ) + } + null + } + } else { + BitmapFactory.decodeByteArray(data, 0, data.size) + }?.asImageBitmap() +} + actual fun getDrawableFromUri(uri: URI, withAlertOnException: Boolean): Any? { return if (Build.VERSION.SDK_INT >= 28) { val source = ImageDecoder.createSource(androidAppContext.contentResolver, uri.toUri()) diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c index 7b6c032c8a..eb4714710c 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c @@ -1,5 +1,6 @@ #include <jni.h> -//#include <string.h> +#include <string.h> +#include <stdint.h> //#include <stdlib.h> //#include <android/log.h> @@ -45,6 +46,10 @@ extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait); extern char *chat_parse_markdown(const char *str); extern char *chat_parse_server(const char *str); extern char *chat_password_hash(const char *pwd, const char *salt); +extern char *chat_write_file(const char *path, char *ptr, int length); +extern char *chat_read_file(const char *path, const char *key, const char *nonce); +extern char *chat_encrypt_file(const char *from_path, const char *to_path); +extern char *chat_decrypt_file(const char *from_path, const char *key, const char *nonce, const char *to_path); JNIEXPORT jobjectArray JNICALL Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey, jstring confirm) { @@ -115,3 +120,76 @@ Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, __unused (*env)->ReleaseStringUTFChars(env, salt, _salt); return res; } + +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jstring path, jobject buffer) { + const char *_path = (*env)->GetStringUTFChars(env, path, JNI_FALSE); + jbyte *buff = (jbyte *) (*env)->GetDirectBufferAddress(env, buffer); + jlong capacity = (*env)->GetDirectBufferCapacity(env, buffer); + jstring res = (*env)->NewStringUTF(env, chat_write_file(_path, buff, capacity)); + (*env)->ReleaseStringUTFChars(env, path, _path); + return res; +} + +JNIEXPORT jobjectArray JNICALL +Java_chat_simplex_common_platform_CoreKt_chatReadFile(JNIEnv *env, jclass clazz, jstring path, jstring key, jstring nonce) { + const char *_path = (*env)->GetStringUTFChars(env, path, JNI_FALSE); + const char *_key = (*env)->GetStringUTFChars(env, key, JNI_FALSE); + const char *_nonce = (*env)->GetStringUTFChars(env, nonce, JNI_FALSE); + + jbyte *res = chat_read_file(_path, _key, _nonce); + (*env)->ReleaseStringUTFChars(env, path, _path); + (*env)->ReleaseStringUTFChars(env, key, _key); + (*env)->ReleaseStringUTFChars(env, nonce, _nonce); + + jint status = (jint)res[0]; + jbyteArray arr; + if (status == 0) { + union { + uint32_t w; + uint8_t b[4]; + } len; + len.b[0] = (uint8_t)res[1]; + len.b[1] = (uint8_t)res[2]; + len.b[2] = (uint8_t)res[3]; + len.b[3] = (uint8_t)res[4]; + arr = (*env)->NewByteArray(env, len.w); + (*env)->SetByteArrayRegion(env, arr, 0, len.w, res + 5); + } else { + int len = strlen(res + 1); // + 1 offset here is to not include status byte + arr = (*env)->NewByteArray(env, len); + (*env)->SetByteArrayRegion(env, arr, 0, len, res + 1); + } + + jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL); + jobject statusObj = (*env)->NewObject(env, (*env)->FindClass(env, "java/lang/Integer"), + (*env)->GetMethodID(env, (*env)->FindClass(env, "java/lang/Integer"), "<init>", "(I)V"), + status); + (*env)->SetObjectArrayElement(env, ret, 0, statusObj); + (*env)->SetObjectArrayElement(env, ret, 1, arr); + return ret; +} + +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatEncryptFile(JNIEnv *env, jclass clazz, jstring from_path, jstring to_path) { + const char *_from_path = (*env)->GetStringUTFChars(env, from_path, JNI_FALSE); + const char *_to_path = (*env)->GetStringUTFChars(env, to_path, JNI_FALSE); + jstring res = (*env)->NewStringUTF(env, chat_encrypt_file(_from_path, _to_path)); + (*env)->ReleaseStringUTFChars(env, from_path, _from_path); + (*env)->ReleaseStringUTFChars(env, to_path, _to_path); + return res; +} + +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatDecryptFile(JNIEnv *env, jclass clazz, jstring from_path, jstring key, jstring nonce, jstring to_path) { + const char *_from_path = (*env)->GetStringUTFChars(env, from_path, JNI_FALSE); + const char *_key = (*env)->GetStringUTFChars(env, key, JNI_FALSE); + const char *_nonce = (*env)->GetStringUTFChars(env, nonce, JNI_FALSE); + const char *_to_path = (*env)->GetStringUTFChars(env, to_path, JNI_FALSE); + jstring res = (*env)->NewStringUTF(env, chat_decrypt_file(_from_path, _key, _nonce, _to_path)); + (*env)->ReleaseStringUTFChars(env, from_path, _from_path); + (*env)->ReleaseStringUTFChars(env, key, _key); + (*env)->ReleaseStringUTFChars(env, nonce, _nonce); + (*env)->ReleaseStringUTFChars(env, to_path, _to_path); + return res; +} diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c index 8e869ca2d9..ddc5c92f93 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c @@ -1,6 +1,7 @@ #include <jni.h> #include <string.h> #include <stdlib.h> +#include <stdint.h> // from the RTS void hs_init(int * argc, char **argv[]); @@ -20,7 +21,10 @@ extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait); extern char *chat_parse_markdown(const char *str); extern char *chat_parse_server(const char *str); extern char *chat_password_hash(const char *pwd, const char *salt); - +extern char *chat_write_file(const char *path, char *ptr, int length); +extern char *chat_read_file(const char *path, const char *key, const char *nonce); +extern char *chat_encrypt_file(const char *from_path, const char *to_path); +extern char *chat_decrypt_file(const char *from_path, const char *key, const char *nonce, const char *to_path); // As a reference: https://stackoverflow.com/a/60002045 jstring decode_to_utf8_string(JNIEnv *env, char *string) { @@ -128,3 +132,76 @@ Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, jclass cl (*env)->ReleaseStringUTFChars(env, salt, _salt); return res; } + +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jstring path, jobject buffer) { + const char *_path = encode_to_utf8_chars(env, path); + jbyte *buff = (jbyte *) (*env)->GetDirectBufferAddress(env, buffer); + jlong capacity = (*env)->GetDirectBufferCapacity(env, buffer); + jstring res = decode_to_utf8_string(env, chat_write_file(_path, buff, capacity)); + (*env)->ReleaseStringUTFChars(env, path, _path); + return res; +} + +JNIEXPORT jobjectArray JNICALL +Java_chat_simplex_common_platform_CoreKt_chatReadFile(JNIEnv *env, jclass clazz, jstring path, jstring key, jstring nonce) { + const char *_path = encode_to_utf8_chars(env, path); + const char *_key = encode_to_utf8_chars(env, key); + const char *_nonce = encode_to_utf8_chars(env, nonce); + + jbyte *res = chat_read_file(_path, _key, _nonce); + (*env)->ReleaseStringUTFChars(env, path, _path); + (*env)->ReleaseStringUTFChars(env, key, _key); + (*env)->ReleaseStringUTFChars(env, nonce, _nonce); + + jint status = (jint)res[0]; + jbyteArray arr; + if (status == 0) { + union { + uint32_t w; + uint8_t b[4]; + } len; + len.b[0] = (uint8_t)res[1]; + len.b[1] = (uint8_t)res[2]; + len.b[2] = (uint8_t)res[3]; + len.b[3] = (uint8_t)res[4]; + arr = (*env)->NewByteArray(env, len.w); + (*env)->SetByteArrayRegion(env, arr, 0, len.w, res + 5); + } else { + int len = strlen(res + 1); // + 1 offset here is to not include status byte + arr = (*env)->NewByteArray(env, len); + (*env)->SetByteArrayRegion(env, arr, 0, len, res + 1); + } + + jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL); + jobject statusObj = (*env)->NewObject(env, (*env)->FindClass(env, "java/lang/Integer"), + (*env)->GetMethodID(env, (*env)->FindClass(env, "java/lang/Integer"), "<init>", "(I)V"), + status); + (*env)->SetObjectArrayElement(env, ret, 0, statusObj); + (*env)->SetObjectArrayElement(env, ret, 1, arr); + return ret; +} + +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatEncryptFile(JNIEnv *env, jclass clazz, jstring from_path, jstring to_path) { + const char *_from_path = encode_to_utf8_chars(env, from_path); + const char *_to_path = encode_to_utf8_chars(env, to_path); + jstring res = decode_to_utf8_string(env, chat_encrypt_file(_from_path, _to_path)); + (*env)->ReleaseStringUTFChars(env, from_path, _from_path); + (*env)->ReleaseStringUTFChars(env, to_path, _to_path); + return res; +} + +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatDecryptFile(JNIEnv *env, jclass clazz, jstring from_path, jstring key, jstring nonce, jstring to_path) { + const char *_from_path = encode_to_utf8_chars(env, from_path); + const char *_key = encode_to_utf8_chars(env, key); + const char *_nonce = encode_to_utf8_chars(env, nonce); + const char *_to_path = encode_to_utf8_chars(env, to_path); + jstring res = decode_to_utf8_string(env, chat_decrypt_file(_from_path, _key, _nonce, _to_path)); + (*env)->ReleaseStringUTFChars(env, from_path, _from_path); + (*env)->ReleaseStringUTFChars(env, key, _key); + (*env)->ReleaseStringUTFChars(env, nonce, _nonce); + (*env)->ReleaseStringUTFChars(env, to_path, _to_path); + return res; +} 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 cb386be7a3..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 @@ -32,8 +32,7 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.* data class SettingsViewState( val userPickerState: MutableStateFlow<AnimatedViewState>, @@ -64,7 +63,7 @@ fun MainScreen() { if ( !chatModel.controller.appPrefs.laNoticeShown.get() && showAdvertiseLAAlert - && chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete + && chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete && chatModel.chats.isNotEmpty() && chatModel.activeCallInvitation.value == null ) { @@ -102,7 +101,10 @@ fun MainScreen() { } Box { - val onboarding = chatModel.onboardingStage.value + var onboarding by remember { mutableStateOf(chatModel.controller.appPrefs.onboardingStage.get()) } + LaunchedEffect(Unit) { + snapshotFlow { chatModel.controller.appPrefs.onboardingStage.state.value }.distinctUntilChanged().collect { onboarding = it } + } val userCreated = chatModel.userCreated.value var showInitializationView by remember { mutableStateOf(false) } when { @@ -112,7 +114,7 @@ fun MainScreen() { DatabaseErrorView(chatModel.chatDbStatus, chatModel.controller.appPrefs) } } - onboarding == null || userCreated == null -> SplashView() + remember { chatModel.chatDbEncrypted }.value == null || userCreated == null -> SplashView() onboarding == OnboardingStage.OnboardingComplete && userCreated -> { Box { showAdvertiseLAAlert = true @@ -134,6 +136,7 @@ fun MainScreen() { } } onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel) {} + onboarding == OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel) onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel) onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel) } @@ -194,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/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 629d4b8699..fc0867aad6 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 @@ -13,6 +13,7 @@ import chat.simplex.common.views.chat.ComposeState import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.platform.AudioPlayer +import chat.simplex.common.platform.chatController import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource @@ -38,7 +39,6 @@ import kotlin.time.* @Stable object ChatModel { val controller: ChatController = ChatController - val onboardingStage = mutableStateOf<OnboardingStage?>(null) val setDeliveryReceipts = mutableStateOf(false) val currentUser = mutableStateOf<User?>(null) val users = mutableStateListOf<UserInfo>() @@ -1395,6 +1395,13 @@ data class ChatItem ( private val isLiveDummy: Boolean get() = meta.itemId == TEMP_LIVE_CHAT_ITEM_ID + val encryptedFile: Boolean? = if (file?.fileSource == null) null else file.fileSource.cryptoArgs != null + + val encryptLocalFile: Boolean + get() = file?.fileProtocol == FileProtocol.XFTP && + content.msgContent !is MsgContent.MCVideo && + chatController.appPrefs.privacyEncryptLocalFiles.get() + val memberDisplayName: String? get() = if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.displayName else null @@ -2025,7 +2032,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 ) { @@ -2073,10 +2080,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 +data class CryptoFile( + val filePath: String, + val cryptoArgs: CryptoFileArgs? +) { + companion object { + fun plain(f: String): CryptoFile = CryptoFile(f, null) + } +} + +@Serializable +data 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/CryptoFile.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt new file mode 100644 index 0000000000..037d27af33 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt @@ -0,0 +1,59 @@ +package chat.simplex.common.model + +import chat.simplex.common.platform.* +import kotlinx.serialization.* +import java.nio.ByteBuffer + +@Serializable +sealed class WriteFileResult { + @Serializable @SerialName("result") data class Result(val cryptoArgs: CryptoFileArgs): WriteFileResult() + @Serializable @SerialName("error") data class Error(val writeError: String): WriteFileResult() +} + +/* + fun writeCryptoFile(path: String, data: ByteArray): CryptoFileArgs { + val str = chatWriteFile(path, data) + return when (val d = json.decodeFromString(WriteFileResult.serializer(), str)) { + is WriteFileResult.Result -> d.cryptoArgs + is WriteFileResult.Error -> throw Exception(d.writeError) + } +} +* */ + +fun writeCryptoFile(path: String, data: ByteArray): CryptoFileArgs { + val buffer = ByteBuffer.allocateDirect(data.size) + buffer.put(data) + buffer.rewind() + val str = chatWriteFile(path, buffer) + return when (val d = json.decodeFromString(WriteFileResult.serializer(), str)) { + is WriteFileResult.Result -> d.cryptoArgs + is WriteFileResult.Error -> throw Exception(d.writeError) + } +} + +fun readCryptoFile(path: String, cryptoArgs: CryptoFileArgs): ByteArray { + val res: Array<Any> = chatReadFile(path, cryptoArgs.fileKey, cryptoArgs.fileNonce) + val status = (res[0] as Integer).toInt() + val arr = res[1] as ByteArray + if (status == 0) { + return arr + } else { + throw Exception(String(arr)) + } +} + +fun encryptCryptoFile(fromPath: String, toPath: String): CryptoFileArgs { + val str = chatEncryptFile(fromPath, toPath) + val d = json.decodeFromString(WriteFileResult.serializer(), str) + return when (d) { + is WriteFileResult.Result -> d.cryptoArgs + is WriteFileResult.Error -> throw Exception(d.writeError) + } +} + +fun decryptCryptoFile(fromPath: String, cryptoArgs: CryptoFileArgs, toPath: String) { + val err = chatDecryptFile(fromPath, cryptoArgs.fileKey, cryptoArgs.fileNonce, toPath) + if (err != "") { + throw Exception(err) + } +} 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..0a178ca2f7 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 @@ -5,7 +5,6 @@ import androidx.compose.runtime.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import dev.icerock.moko.resources.compose.painterResource -import chat.simplex.common.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* @@ -94,6 +93,7 @@ class AppPreferences { val privacyShowChatPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_SHOW_CHAT_PREVIEWS, true) val privacySaveLastDraft = mkBoolPreference(SHARED_PREFS_PRIVACY_SAVE_LAST_DRAFT, true) val privacyDeliveryReceiptsSet = mkBoolPreference(SHARED_PREFS_PRIVACY_DELIVERY_RECEIPTS_SET, false) + val privacyEncryptLocalFiles = mkBoolPreference(SHARED_PREFS_PRIVACY_ENCRYPT_LOCAL_FILES, true) val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false) val showUnreadAndFavorites = mkBoolPreference(SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES, false) val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null) @@ -249,6 +249,7 @@ class AppPreferences { private const val SHARED_PREFS_PRIVACY_SHOW_CHAT_PREVIEWS = "PrivacyShowChatPreviews" private const val SHARED_PREFS_PRIVACY_SAVE_LAST_DRAFT = "PrivacySaveLastDraft" private const val SHARED_PREFS_PRIVACY_DELIVERY_RECEIPTS_SET = "PrivacyDeliveryReceiptsSet" + private const val SHARED_PREFS_PRIVACY_ENCRYPT_LOCAL_FILES = "PrivacyEncryptLocalFiles" const val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup" private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls" private const val SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES = "ShowUnreadAndFavorites" @@ -586,7 +587,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 +1080,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 +1414,7 @@ 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) } + withApi { receiveFile(r.user, file.fileId, encrypted = cItem.encryptLocalFile && chatController.appPrefs.privacyEncryptLocalFiles.get(), 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/AppCommon.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt index fff77ee23b..d36a6aec16 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt @@ -1,8 +1,9 @@ package chat.simplex.common.platform import chat.simplex.common.BuildConfigCommon -import chat.simplex.common.model.ChatController +import chat.simplex.common.model.* import chat.simplex.common.ui.theme.DefaultTheme +import java.io.File import java.util.* enum class AppPlatform { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index c39c000807..801a0270e2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -4,6 +4,7 @@ import chat.simplex.common.model.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingStage import kotlinx.serialization.decodeFromString +import java.nio.ByteBuffer // ghc's rts external fun initHS() @@ -19,6 +20,10 @@ external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String external fun chatParseMarkdown(str: String): String external fun chatParseServer(str: String): String external fun chatPasswordHash(pwd: String, salt: String): String +external fun chatWriteFile(path: String, buffer: ByteBuffer): String +external fun chatReadFile(path: String, key: String, nonce: String): Array<Any> +external fun chatEncryptFile(fromPath: String, toPath: String): String +external fun chatDecryptFile(fromPath: String, key: String, nonce: String, toPath: String): String val chatModel: ChatModel get() = chatController.chatModel @@ -50,17 +55,16 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat if (user == null) { chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) - chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo chatModel.currentUser.value = null chatModel.users.clear() } else { val savedOnboardingStage = appPreferences.onboardingStage.get() - chatModel.onboardingStage.value = if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) { + appPreferences.onboardingStage.set(if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) { OnboardingStage.Step3_CreateSimpleXAddress } else { savedOnboardingStage - } - if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete && !chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.get()) { + }) + if (appPreferences.onboardingStage.get() == OnboardingStage.OnboardingComplete && !chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.get()) { chatModel.setDeliveryReceipts.value = true } chatController.startChat(user) 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 9bc26d4459..71a9f204f8 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 @@ -2,6 +2,7 @@ package chat.simplex.common.platform import androidx.compose.runtime.Composable import chat.simplex.common.model.CIFile +import chat.simplex.common.model.CryptoFile import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.MR import java.io.* @@ -23,6 +24,8 @@ expect val agentDatabaseFileName: String * */ expect val databaseExportDir: File +expect fun desktopOpenDatabaseDir() + fun copyFileToFile(from: File, to: URI, finally: () -> Unit) { try { to.outputStream().use { stream -> @@ -60,14 +63,25 @@ 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 } } +fun getLoadedFileSource(file: CIFile?): CryptoFile? { + val f = file?.fileSource?.filePath + return if (f != null && file.loaded) { + val filePath = getAppFilePath(f) + if (File(filePath).exists()) file.fileSource else null + } else { + null + } +} + /** * [rememberedValue] is used in `remember(rememberedValue)`. So when the value changes, file saver will update a callback function * */ diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt index 6adadaffaa..a03df5addb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -100,7 +100,7 @@ abstract class NtfManager { if (chatModel.chatRunning.value == null) { val step = 50L for (i in 0..(timeout / step)) { - if (chatModel.chatRunning.value == true || chatModel.onboardingStage.value == OnboardingStage.Step1_SimpleXInfo) { + if (chatModel.chatRunning.value == true || chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.Step1_SimpleXInfo) { break } delay(step) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt index bbc5cbe667..2d6bb2a371 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt @@ -1,7 +1,7 @@ package chat.simplex.common.platform import androidx.compose.runtime.MutableState -import chat.simplex.common.model.ChatItem +import chat.simplex.common.model.* import kotlinx.coroutines.CoroutineScope interface RecorderInterface { @@ -18,7 +18,7 @@ expect class RecorderNative(): RecorderInterface interface AudioPlayerInterface { fun play( - filePath: String?, + fileSource: CryptoFile, audioPlaying: MutableState<Boolean>, progress: MutableState<Int>, duration: MutableState<Int>, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt index 03ad4b5441..72bb3caaac 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt @@ -2,8 +2,9 @@ package chat.simplex.common.platform import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.UriHandler +import chat.simplex.common.model.CryptoFile expect fun UriHandler.sendEmail(subject: String, body: CharSequence) expect fun ClipboardManager.shareText(text: String) -expect fun shareFile(text: String, filePath: String) +expect fun shareFile(text: String, fileSource: CryptoFile) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt index 9539a07903..13ce16d0a7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.ChatModel import chat.simplex.common.model.Profile +import chat.simplex.common.platform.appPlatform import chat.simplex.common.platform.navigationBarsWithImePadding import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* @@ -88,14 +89,20 @@ fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) { icon = painterResource(MR.images.ic_arrow_back_ios_new), textDecoration = TextDecoration.None, fontWeight = FontWeight.Medium - ) { chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo } + ) { chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) } } Spacer(Modifier.fillMaxWidth().weight(1f)) val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value) val createModifier: Modifier val createColor: Color if (enabled) { - createModifier = Modifier.clickable { createProfile(chatModel, displayName.value, fullName.value, close) }.padding(8.dp) + createModifier = Modifier.clickable { + if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) { + createProfileInProfiles(chatModel, displayName.value, fullName.value, close) + } else { + createProfileOnboarding(chatModel, displayName.value, fullName.value, close) + } + }.padding(8.dp) createColor = MaterialTheme.colors.primary } else { createModifier = Modifier.padding(8.dp) @@ -116,7 +123,7 @@ fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) { } } -fun createProfile(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) { +fun createProfileInProfiles(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) { withApi { val user = chatModel.controller.apiCreateActiveUser( Profile(displayName, fullName, null) @@ -125,16 +132,32 @@ fun createProfile(chatModel: ChatModel, displayName: String, fullName: String, c if (chatModel.users.isEmpty()) { chatModel.controller.startChat(user) chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress) - chatModel.onboardingStage.value = OnboardingStage.Step3_CreateSimpleXAddress } else { val users = chatModel.controller.listUsers() chatModel.users.clear() chatModel.users.addAll(users) chatModel.controller.getUserChatData() + close() + } + } +} + +fun createProfileOnboarding(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) { + withApi { + chatModel.controller.apiCreateActiveUser( + Profile(displayName, fullName, null) + ) ?: return@withApi + val onboardingStage = chatModel.controller.appPrefs.onboardingStage + if (chatModel.users.isEmpty()) { + onboardingStage.set(if (appPlatform.isDesktop && chatModel.controller.appPrefs.initialRandomDBPassphrase.get()) { + OnboardingStage.Step2_5_SetupDatabasePassphrase + } else { + OnboardingStage.Step3_CreateSimpleXAddress + }) + } else { // the next two lines are only needed for failure case when because of the database error the app gets stuck on on-boarding screen, // this will get it unstuck. - chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.OnboardingComplete) - chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete + onboardingStage.set(OnboardingStage.OnboardingComplete) close() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 87f8a7e651..170f870130 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.text.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -278,7 +279,7 @@ fun ChatInfoLayout( ChatInfoHeader(chat.chatInfo, contact) } - LocalAliasEditor(localAlias, updateValue = onLocalAliasChanged) + LocalAliasEditor(chat.id, localAlias, updateValue = onLocalAliasChanged) SectionSpacer() if (customUserProfile != null) { SectionView(generalGetString(MR.strings.incognito).uppercase()) { @@ -403,13 +404,16 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) { @Composable fun LocalAliasEditor( + chatId: String, initialValue: String, center: Boolean = true, leadingIcon: Boolean = false, focus: Boolean = false, updateValue: (String) -> Unit ) { - var value by rememberSaveable { mutableStateOf(initialValue) } + val state = remember(chatId) { + mutableStateOf(TextFieldValue(initialValue)) + } var updatedValueAtLeastOnce = remember { false } val modifier = if (center) Modifier.padding(horizontal = if (!leadingIcon) DEFAULT_PADDING else 0.dp).widthIn(min = 100.dp) @@ -418,7 +422,7 @@ fun LocalAliasEditor( Row(Modifier.fillMaxWidth(), horizontalArrangement = if (center) Arrangement.Center else Arrangement.Start) { DefaultBasicTextField( modifier, - value, + state, { Text( generalGetString(MR.strings.text_field_set_contact_placeholder), @@ -431,27 +435,27 @@ fun LocalAliasEditor( } else null, color = MaterialTheme.colors.secondary, focus = focus, - textStyle = TextStyle.Default.copy(textAlign = if (value.isEmpty() || !center) TextAlign.Start else TextAlign.Center), - keyboardActions = KeyboardActions(onDone = { updateValue(value) }) + textStyle = TextStyle.Default.copy(textAlign = if (state.value.text.isEmpty() || !center) TextAlign.Start else TextAlign.Center), + keyboardActions = KeyboardActions(onDone = { updateValue(state.value.text) }) ) { - value = it + state.value = it updatedValueAtLeastOnce = true } } - LaunchedEffect(Unit) { - var prevValue = value - snapshotFlow { value } + LaunchedEffect(chatId) { + var prevValue = state.value + snapshotFlow { state.value } .distinctUntilChanged() .onEach { delay(500) } // wait a little after every new character, don't emit until user stops typing .conflate() // get the latest value - .filter { it == value && it != prevValue } // don't process old ones + .filter { it == state.value && it != prevValue } // don't process old ones .collect { - updateValue(it) + updateValue(it.text) prevValue = it } } - DisposableEffect(Unit) { - onDispose { if (updatedValueAtLeastOnce) updateValue(value) } // just in case snapshotFlow will be canceled when user presses Back too fast + DisposableEffect(chatId) { + onDispose { if (updatedValueAtLeastOnce) updateValue(state.value.text) } // just in case snapshotFlow will be canceled when user presses Back too fast } } 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..c8381cdcb7 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 } } } @@ -245,8 +244,8 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { } } }, - 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) } @@ -404,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, @@ -422,7 +421,7 @@ fun ChatLayout( markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit, onSearchValueChanged: (String) -> Unit, - onComposed: () -> Unit, + onComposed: suspend (chatId: String) -> Unit, ) { val scope = rememberCoroutineScope() val attachmentDisabled = remember { derivedStateOf { composeState.value.attachmentDisabled } } @@ -657,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, @@ -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 } } @@ -1118,7 +1117,7 @@ private fun markUnreadChatAsRead(activeChat: MutableState<Chat?>, chatModel: Cha } sealed class ProviderMedia { - data class Image(val uri: URI, val image: ImageBitmap): ProviderMedia() + data class Image(val data: ByteArray, val image: ImageBitmap): ProviderMedia() data class Video(val uri: URI, val preview: String): ProviderMedia() } @@ -1156,11 +1155,11 @@ private fun providerForGallery( val item = item(internalIndex, initialChatId)?.second ?: return null return when (item.content.msgContent) { is MsgContent.MCImage -> { - val imageBitmap: ImageBitmap? = getLoadedImage(item.file) + val res = getLoadedImage(item.file) val filePath = getLoadedFilePath(item.file) - if (imageBitmap != null && filePath != null) { - val uri = getAppFileUri(filePath.substringAfterLast(File.separator)) - ProviderMedia.Image(uri, imageBitmap) + if (res != null && filePath != null) { + val (imageBitmap: ImageBitmap, data: ByteArray) = res + ProviderMedia.Image(data, imageBitmap) } else null } is MsgContent.MCVideo -> { @@ -1258,7 +1257,7 @@ fun PreviewChatLayout() { showMemberInfo = { _, _ -> }, loadPrevMessages = { _ -> }, deleteMessage = { _, _ -> }, - receiveFile = {}, + receiveFile = { _, _ -> }, cancelFile = {}, joinGroup = {}, startCall = {}, @@ -1325,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..4d6bc297f0 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,16 +404,16 @@ fun ComposeView( sent = updateMessage(liveMessage.chatItem, cInfo, live) } else { val msgs: ArrayList<MsgContent> = ArrayList() - val files: ArrayList<String> = ArrayList() + val files: ArrayList<CryptoFile> = ArrayList() when (val preview = cs.preview) { ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText)) is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview()) is ComposePreview.MediaPreview -> { preview.content.forEachIndexed { index, it -> val file = when (it) { - is UploadContent.SimpleImage -> saveImage(it.uri) - is UploadContent.AnimatedImage -> saveAnimImage(it.uri) - is UploadContent.Video -> saveFileFromUri(it.uri) + is UploadContent.SimpleImage -> saveImage(it.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get()) + is UploadContent.AnimatedImage -> saveAnimImage(it.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get()) + is UploadContent.Video -> saveFileFromUri(it.uri, encrypted = false) } if (file != null) { files.add(file) @@ -429,15 +429,21 @@ fun ComposeView( val tmpFile = File(preview.voice) AudioPlayer.stop(tmpFile.absolutePath) val actualFile = File(getAppFilePath(tmpFile.name.replaceAfter(RecorderInterface.extension, ""))) - withContext(Dispatchers.IO) { - Files.move(tmpFile.toPath(), actualFile.toPath()) - } - files.add(actualFile.name) + files.add(withContext(Dispatchers.IO) { + if (chatController.appPrefs.privacyEncryptLocalFiles.get()) { + val args = encryptCryptoFile(tmpFile.absolutePath, actualFile.absolutePath) + tmpFile.delete() + CryptoFile(actualFile.name, args) + } else { + Files.move(tmpFile.toPath(), actualFile.toPath()) + 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 = chatController.appPrefs.privacyEncryptLocalFiles.get()) 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/ComposeVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt index 99d7de96be..a4c90d30dd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt @@ -17,6 +17,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.simplex.common.model.CryptoFile import chat.simplex.common.model.durationText import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* @@ -52,7 +53,7 @@ fun ComposeVoiceView( IconButton( onClick = { if (!audioPlaying.value) { - AudioPlayer.play(filePath, audioPlaying, progress, duration, false) + AudioPlayer.play(CryptoFile.plain(filePath), audioPlaying, progress, duration, false) } else { AudioPlayer.pause(audioPlaying, progress) } 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..8de805ba54 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,8 @@ fun CIFileView( when (file.fileStatus) { is CIFileStatus.RcvInvitation -> { if (fileSizeValid()) { - receiveFile(file.fileId) + val encrypted = file.fileProtocol == FileProtocol.XFTP && chatController.appPrefs.privacyEncryptLocalFiles.get() + receiveFile(file.fileId, encrypted) } else { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.large_file), @@ -184,9 +185,9 @@ fun CIFileView( ) { fileIndicator() val metaReserve = if (edited) - " " + " " else - " " + " " if (file != null) { Column { Text( @@ -211,7 +212,15 @@ fun rememberSaveFileLauncher(ciFile: CIFile?): FileChooserLauncher = rememberFileChooserLauncher(false, ciFile) { to: URI? -> val filePath = getLoadedFilePath(ciFile) if (filePath != null && to != null) { - copyFileToFile(File(filePath), to) {} + if (ciFile?.fileSource?.cryptoArgs != null) { + createTmpFileAndDelete { tmpFile -> + decryptCryptoFile(filePath, ciFile.fileSource.cryptoArgs, tmpFile.absolutePath) + copyFileToFile(tmpFile, to) {} + tmpFile.delete() + } + } else { + copyFileToFile(File(filePath), to) {} + } } } 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..23d1f1d0cc 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 @@ -29,9 +29,11 @@ import java.net.URI fun CIImageView( image: String, file: CIFile?, + encryptLocalFile: Boolean, + metaColor: Color, imageProvider: () -> ImageGalleryProvider, showMenu: MutableState<Boolean>, - receiveFile: (Long) -> Unit + receiveFile: (Long, Boolean) -> Unit ) { @Composable fun progressIndicator() { @@ -48,7 +50,7 @@ fun CIImageView( icon, stringResource(stringId), Modifier.fillMaxSize(), - tint = Color.White + tint = metaColor ) } @@ -132,27 +134,31 @@ fun CIImageView( return false } - fun imageAndFilePath(file: CIFile?): Pair<ImageBitmap?, String?> { - val imageBitmap: ImageBitmap? = getLoadedImage(file) - val filePath = getLoadedFilePath(file) - return imageBitmap to filePath + fun imageAndFilePath(file: CIFile?): Triple<ImageBitmap, ByteArray, String>? { + val res = getLoadedImage(file) + if (res != null) { + val (imageBitmap: ImageBitmap, data: ByteArray) = res + val filePath = getLoadedFilePath(file)!! + return Triple(imageBitmap, data, filePath) + } + return null } Box( Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID), contentAlignment = Alignment.TopEnd ) { - val (imageBitmap, filePath) = remember(file) { imageAndFilePath(file) } - if (imageBitmap != null && filePath != null) { - val uri = remember(filePath) { getAppFileUri(filePath.substringAfterLast(File.separator)) } - SimpleAndAnimatedImageView(uri, imageBitmap, file, imageProvider, @Composable { painter, onClick -> ImageView(painter, onClick) }) + val res = remember(file) { imageAndFilePath(file) } + if (res != null) { + val (imageBitmap, data, _) = res + SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, @Composable { painter, onClick -> ImageView(painter, onClick) }) } else { imageView(base64ToBitmap(image), onClick = { if (file != null) { when (file.fileStatus) { CIFileStatus.RcvInvitation -> if (fileSizeValid()) { - receiveFile(file.fileId) + receiveFile(file.fileId, encryptLocalFile) } else { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.large_file), @@ -186,7 +192,7 @@ fun CIImageView( @Composable expect fun SimpleAndAnimatedImageView( - uri: URI, + data: ByteArray, imageBitmap: ImageBitmap, file: CIFile?, imageProvider: () -> ImageGalleryProvider, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt index ab121c6272..72f7137b55 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt @@ -44,14 +44,14 @@ fun CIMetaView( modifier = Modifier.padding(start = 3.dp) ) } else { - CIMetaText(chatItem.meta, timedMessagesTTL, metaColor, paleMetaColor) + CIMetaText(chatItem.meta, timedMessagesTTL, encrypted = chatItem.encryptedFile, metaColor, paleMetaColor) } } } @Composable // changing this function requires updating reserveSpaceForMeta -private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color, paleColor: Color) { +private fun CIMetaText(meta: CIMeta, chatTTL: Int?, encrypted: Boolean?, color: Color, paleColor: Color) { if (meta.itemEdited) { StatusIconText(painterResource(MR.images.ic_edit), color) Spacer(Modifier.width(3.dp)) @@ -77,11 +77,15 @@ private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color, paleColor: Col StatusIconText(painterResource(MR.images.ic_circle_filled), Color.Transparent) Spacer(Modifier.width(4.dp)) } + if (encrypted != null) { + StatusIconText(painterResource(if (encrypted) MR.images.ic_lock else MR.images.ic_lock_open_right), color) + Spacer(Modifier.width(4.dp)) + } Text(meta.timestampText, color = color, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) } // the conditions in this function should match CIMetaText -fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?): String { +fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?, encrypted: Boolean?): String { val iconSpace = " " var res = "" if (meta.itemEdited) res += iconSpace @@ -95,6 +99,9 @@ fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?): String { if (meta.statusIcon(CurrentColors.value.colors.secondary) != null || !meta.disappearing) { res += iconSpace } + if (encrypted != null) { + res += iconSpace + } return res + meta.timestampText } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt index 2918d885b1..8de309fc8f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt @@ -166,7 +166,7 @@ fun DecryptionErrorItemFixButton( Text( buildAnnotatedString { append(generalGetString(MR.strings.fix_connection)) - withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null)) } + withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null, encrypted = null)) } withStyle(reserveTimestampStyle) { append(" ") } // for icon }, color = if (syncSupported) MaterialTheme.colors.primary else MaterialTheme.colors.secondary @@ -196,7 +196,7 @@ fun DecryptionErrorItem( Text( buildAnnotatedString { withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) } - withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null)) } + withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null, encrypted = null)) } }, style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp) ) 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<Boolean>, - 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..941bc315b6 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 @@ -20,8 +20,7 @@ import androidx.compose.ui.unit.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* -import chat.simplex.common.platform.getLoadedFilePath -import chat.simplex.common.platform.AudioPlayer +import chat.simplex.common.platform.* import chat.simplex.res.MR import kotlinx.coroutines.flow.distinctUntilChanged @@ -37,21 +36,24 @@ 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 fileSource = remember(f, file.fileStatus) { getLoadedFileSource(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 + if (fileSource != null) { + AudioPlayer.play(fileSource, audioPlaying, progress, duration, true) + brokenAudio = !audioPlaying.value + } } val pause = { AudioPlayer.pause(audioPlaying, progress) @@ -66,7 +68,7 @@ fun CIVoiceView( } } VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick, receiveFile) { - AudioPlayer.seekTo(it, progress, filePath) + AudioPlayer.seekTo(it, progress, fileSource?.filePath) } } else { VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick, receiveFile) @@ -94,7 +96,7 @@ private fun VoiceLayout( play: () -> Unit, pause: () -> Unit, longClick: () -> Unit, - receiveFile: (Long) -> Unit, + receiveFile: (Long, Boolean) -> Unit, onProgressChanged: (Int) -> Unit, ) { @Composable @@ -248,7 +250,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 +270,7 @@ private fun VoiceMsgIndicator( } } else { if (file?.fileStatus is CIFileStatus.RcvInvitation) { - PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, { receiveFile(file.fileId) }, {}, longClick = longClick) + PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, { receiveFile(file.fileId, chatController.appPrefs.privacyEncryptLocalFiles.get()) }, {}, 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..60ef7e8cfe 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, @@ -191,9 +191,9 @@ fun ChatItemView( } val clipboard = LocalClipboardManager.current ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { - val filePath = getLoadedFilePath(cItem.file) + val fileSource = getLoadedFileSource(cItem.file) when { - filePath != null -> shareFile(cItem.text, filePath) + fileSource != null -> shareFile(cItem.text, fileSource) else -> clipboard.shareText(cItem.content.text) } showMenu.value = false @@ -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..122e54c3b2 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<Boolean>, - receiveFile: (Long) -> Unit, + receiveFile: (Long, Boolean) -> Unit, onLinkLongClick: (link: String) -> Unit = {}, scrollToItem: (Long) -> Unit = {}, ) { @@ -226,7 +226,7 @@ fun FramedItemView( } else { when (val mc = ci.content.msgContent) { is MsgContent.MCImage -> { - CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, receiveFile) + CIImageView(image = mc.image, file = ci.file, ci.encryptLocalFile, metaColor = metaColor, imageProvider ?: return@PriorityLayout, showMenu, receiveFile) if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt index 270c671fc1..9664cabc41 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt @@ -123,8 +123,8 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> // LALAL // https://github.com/JetBrains/compose-multiplatform/pull/2015/files#diff-841b3825c504584012e1d1c834d731bae794cce6acad425d81847c8bbbf239e0R24 if (media is ProviderMedia.Image) { - val (uri: URI, imageBitmap: ImageBitmap) = media - FullScreenImageView(modifier, uri, imageBitmap) + val (data: ByteArray, imageBitmap: ImageBitmap) = media + FullScreenImageView(modifier, data, imageBitmap) } else if (media is ProviderMedia.Video) { val preview = remember(media.uri.path) { base64ToBitmap(media.preview) } VideoView(modifier, media.uri, preview, index == settledCurrentPage) @@ -138,7 +138,7 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> } @Composable -expect fun FullScreenImageView(modifier: Modifier, uri: URI, imageBitmap: ImageBitmap) +expect fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap) @Composable private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap, currentPage: Boolean) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 64855e3195..eabab138ba 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -76,7 +76,7 @@ fun MarkdownText ( val reserve = if (textLayoutDirection != LocalLayoutDirection.current && meta != null) { "\n" } else if (meta != null) { - reserveSpaceForMeta(meta, chatTTL) + reserveSpaceForMeta(meta, chatTTL, null) // LALAL } else { " " } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt index 37080ebd87..e34f80a7ef 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt @@ -30,6 +30,7 @@ import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* +import chat.simplex.common.platform.appPlatform import chat.simplex.res.MR import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.datetime.Clock @@ -61,46 +62,8 @@ fun DatabaseEncryptionView(m: ChatModel) { initialRandomDBPassphrase, progressIndicator, onConfirmEncrypt = { - progressIndicator.value = true withApi { - try { - prefs.encryptionStartedAt.set(Clock.System.now()) - val error = m.controller.apiStorageEncryption(currentKey.value, newKey.value) - prefs.encryptionStartedAt.set(null) - val sqliteError = ((error?.chatError as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorExport)?.sqliteError - when { - sqliteError is SQLiteError.ErrorNotADatabase -> { - operationEnded(m, progressIndicator) { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.wrong_passphrase_title), - generalGetString(MR.strings.enter_correct_current_passphrase) - ) - } - } - error != null -> { - operationEnded(m, progressIndicator) { - AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_encrypting_database), - "failed to set storage encryption: ${error.responseType} ${error.details}" - ) - } - } - else -> { - prefs.initialRandomDBPassphrase.set(false) - initialRandomDBPassphrase.value = false - if (useKeychain.value) { - DatabaseUtils.ksDatabasePassword.set(newKey.value) - } - resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value) - operationEnded(m, progressIndicator) { - AlertManager.shared.showAlertMsg(generalGetString(MR.strings.database_encrypted)) - } - } - } - } catch (e: Exception) { - operationEnded(m, progressIndicator) { - AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_encrypting_database), e.stackTraceToString()) - } - } + encryptDatabase(currentKey, newKey, confirmNewKey, initialRandomDBPassphrase, useKeychain, storedKey, progressIndicator) } } ) @@ -143,17 +106,11 @@ fun DatabaseEncryptionLayout( if (checked) { setUseKeychain(true, useKeychain, prefs) } else if (storedKey.value) { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.remove_passphrase_from_keychain), - text = generalGetString(MR.strings.notifications_will_be_hidden) + "\n" + storeSecurelyDanger(), - confirmText = generalGetString(MR.strings.remove_passphrase), - onConfirm = { - DatabaseUtils.ksDatabasePassword.remove() - setUseKeychain(false, useKeychain, prefs) - storedKey.value = false - }, - destructive = true, - ) + removePassphraseAlert { + DatabaseUtils.ksDatabasePassword.remove() + setUseKeychain(false, useKeychain, prefs) + storedKey.value = false + } } else { setUseKeychain(false, useKeychain, prefs) } @@ -217,37 +174,13 @@ fun DatabaseEncryptionLayout( } Column { - if (chatDbEncrypted == false) { - SectionTextFooter(generalGetString(MR.strings.database_is_not_encrypted)) - } else if (useKeychain.value) { - if (storedKey.value) { - SectionTextFooter(generalGetString(MR.strings.keychain_is_storing_securely)) - if (initialRandomDBPassphrase.value) { - SectionTextFooter(generalGetString(MR.strings.encrypted_with_random_passphrase)) - } else { - SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase)) - } - } else { - SectionTextFooter(generalGetString(MR.strings.keychain_allows_to_receive_ntfs)) - } - } else { - SectionTextFooter(generalGetString(MR.strings.you_have_to_enter_passphrase_every_time)) - SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase)) - } + DatabaseEncryptionFooter(useKeychain, chatDbEncrypted, storedKey, initialRandomDBPassphrase) } SectionBottomSpacer() } } -fun encryptDatabaseSavedAlert(onConfirm: () -> Unit) { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.encrypt_database_question), - text = generalGetString(MR.strings.database_will_be_encrypted_and_passphrase_stored) + "\n" + storeSecurelySaved(), - confirmText = generalGetString(MR.strings.encrypt_database), - onConfirm = onConfirm, - destructive = true, - ) -} +expect fun encryptDatabaseSavedAlert(onConfirm: () -> Unit) fun encryptDatabaseAlert(onConfirm: () -> Unit) { AlertManager.shared.showAlertDialog( @@ -259,15 +192,7 @@ fun encryptDatabaseAlert(onConfirm: () -> Unit) { ) } -fun changeDatabaseKeySavedAlert(onConfirm: () -> Unit) { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.change_database_passphrase_question), - text = generalGetString(MR.strings.database_encryption_will_be_updated) + "\n" + storeSecurelySaved(), - confirmText = generalGetString(MR.strings.update_database), - onConfirm = onConfirm, - destructive = false, - ) -} +expect fun changeDatabaseKeySavedAlert(onConfirm: () -> Unit) fun changeDatabaseKeyAlert(onConfirm: () -> Unit) { AlertManager.shared.showAlertDialog( @@ -279,37 +204,25 @@ fun changeDatabaseKeyAlert(onConfirm: () -> Unit) { ) } +expect fun removePassphraseAlert(onConfirm: () -> Unit) + @Composable -fun SavePassphraseSetting( +expect fun SavePassphraseSetting( useKeychain: Boolean, initialRandomDBPassphrase: Boolean, storedKey: Boolean, progressIndicator: Boolean, minHeight: Dp = TextFieldDefaults.MinHeight, onCheckedChange: (Boolean) -> Unit, -) { - SectionItemView(minHeight = minHeight) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - if (storedKey) painterResource(MR.images.ic_vpn_key_filled) else painterResource(MR.images.ic_vpn_key_off_filled), - stringResource(MR.strings.save_passphrase_in_keychain), - tint = if (storedKey) SimplexGreen else MaterialTheme.colors.secondary - ) - Spacer(Modifier.padding(horizontal = 4.dp)) - Text( - stringResource(MR.strings.save_passphrase_in_keychain), - Modifier.padding(end = 24.dp), - color = Color.Unspecified - ) - Spacer(Modifier.fillMaxWidth().weight(1f)) - DefaultSwitch( - checked = useKeychain, - onCheckedChange = onCheckedChange, - enabled = !initialRandomDBPassphrase && !progressIndicator - ) - } - } -} +) + +@Composable +expect fun DatabaseEncryptionFooter( + useKeychain: MutableState<Boolean>, + chatDbEncrypted: Boolean?, + storedKey: MutableState<Boolean>, + initialRandomDBPassphrase: MutableState<Boolean>, +) fun resetFormAfterEncryption( m: ChatModel, @@ -443,6 +356,62 @@ fun PassphraseField( } } +suspend fun encryptDatabase( + currentKey: MutableState<String>, + newKey: MutableState<String>, + confirmNewKey: MutableState<String>, + initialRandomDBPassphrase: MutableState<Boolean>, + useKeychain: MutableState<Boolean>, + storedKey: MutableState<Boolean>, + progressIndicator: MutableState<Boolean> +): Boolean { + val m = ChatModel + val prefs = ChatController.appPrefs + progressIndicator.value = true + return try { + prefs.encryptionStartedAt.set(Clock.System.now()) + val error = m.controller.apiStorageEncryption(currentKey.value, newKey.value) + prefs.encryptionStartedAt.set(null) + val sqliteError = ((error?.chatError as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorExport)?.sqliteError + when { + sqliteError is SQLiteError.ErrorNotADatabase -> { + operationEnded(m, progressIndicator) { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.wrong_passphrase_title), + generalGetString(MR.strings.enter_correct_current_passphrase) + ) + } + false + } + error != null -> { + operationEnded(m, progressIndicator) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_encrypting_database), + "failed to set storage encryption: ${error.responseType} ${error.details}" + ) + } + false + } + else -> { + prefs.initialRandomDBPassphrase.set(false) + initialRandomDBPassphrase.value = false + if (useKeychain.value) { + DatabaseUtils.ksDatabasePassword.set(newKey.value) + } + resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value) + operationEnded(m, progressIndicator) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.database_encrypted)) + } + true + } + } + } catch (e: Exception) { + operationEnded(m, progressIndicator) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_encrypting_database), e.stackTraceToString()) + } + false + } +} + // based on https://generatepasswords.org/how-to-calculate-entropy/ private fun passphraseEntropy(s: String): Double { var hasDigits = false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt index 7101481681..bce8fdf4f1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt @@ -12,6 +12,9 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import chat.simplex.common.model.AppPreferences @@ -252,6 +255,11 @@ private fun mtrErrorDescription(err: MTRError): String = @Composable private fun DatabaseKeyField(text: MutableState<String>, enabled: Boolean, onClick: (() -> Unit)? = null) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + delay(100L) + focusRequester.requestFocus() + } PassphraseField( text, generalGetString(MR.strings.enter_passphrase), @@ -259,7 +267,15 @@ private fun DatabaseKeyField(text: MutableState<String>, enabled: Boolean, onCli keyboardActions = KeyboardActions(onDone = if (enabled) { { onClick?.invoke() } } else null - ) + ), + modifier = Modifier.focusRequester(focusRequester).onPreviewKeyEvent { + if (onClick != null && it.key == Key.Enter && it.type == KeyEventType.KeyUp) { + onClick() + true + } else { + false + } + } ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index 05f38b74de..fa0f8f54d1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -39,7 +39,6 @@ fun DatabaseView( showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit) ) { val progressIndicator = remember { mutableStateOf(false) } - val runChat = remember { m.chatRunning } val prefs = m.controller.appPrefs val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) } val chatArchiveName = remember { mutableStateOf(prefs.chatArchiveName.get()) } @@ -60,20 +59,19 @@ fun DatabaseView( importArchiveAlert(m, to, appFilesCountAndSize, progressIndicator) } } - LaunchedEffect(m.chatRunning) { - runChat.value = m.chatRunning.value ?: true - } val chatItemTTL = remember { mutableStateOf(m.chatItemTTL.value) } Box( Modifier.fillMaxSize(), ) { DatabaseLayout( progressIndicator.value, - runChat.value != false, + remember { m.chatRunning }.value != false, m.chatDbChanged.value, useKeychain.value, m.chatDbEncrypted.value, + m.controller.appPrefs.storeDBPassphrase.state.value, m.controller.appPrefs.initialRandomDBPassphrase, + m.controller.appPrefs.developerTools.state.value, importArchiveLauncher, chatArchiveName, chatArchiveTime, @@ -82,8 +80,8 @@ fun DatabaseView( chatItemTTL, m.currentUser.value, m.users, - startChat = { startChat(m, runChat, chatLastStart, m.chatDbChanged) }, - stopChatAlert = { stopChatAlert(m, runChat) }, + startChat = { startChat(m, chatLastStart, m.chatDbChanged) }, + stopChatAlert = { stopChatAlert(m) }, exportArchive = { exportArchive(m, progressIndicator, chatArchiveName, chatArchiveTime, chatArchiveFile, saveArchiveLauncher) }, deleteChatAlert = { deleteChatAlert(m, progressIndicator) }, deleteAppFilesAndMedia = { deleteFilesAndMediaAlert(appFilesCountAndSize) }, @@ -122,7 +120,9 @@ fun DatabaseLayout( chatDbChanged: Boolean, useKeyChain: Boolean, chatDbEncrypted: Boolean?, + passphraseSaved: Boolean, initialRandomDBPassphrase: SharedPreference<Boolean>, + developerTools: Boolean, importArchiveLauncher: FileChooserLauncher, chatArchiveName: MutableState<String?>, chatArchiveTime: MutableState<Instant?>, @@ -178,13 +178,21 @@ fun DatabaseLayout( SectionView(stringResource(MR.strings.chat_database_section)) { val unencrypted = chatDbEncrypted == false SettingsActionItem( - if (unencrypted) painterResource(MR.images.ic_lock_open) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled) + if (unencrypted) painterResource(MR.images.ic_lock_open_right) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled) else painterResource(MR.images.ic_lock), stringResource(MR.strings.database_passphrase), click = showSettingsModal() { DatabaseEncryptionView(it) }, - iconColor = if (unencrypted) WarningOrange else MaterialTheme.colors.secondary, + iconColor = if (unencrypted || (appPlatform.isDesktop && passphraseSaved)) WarningOrange else MaterialTheme.colors.secondary, disabled = operationsDisabled ) + if (appPlatform.isDesktop && developerTools) { + SettingsActionItem( + painterResource(MR.images.ic_folder_open), + stringResource(MR.strings.open_database_folder), + ::desktopOpenDatabaseDir, + disabled = operationsDisabled + ) + } SettingsActionItem( painterResource(MR.images.ic_ios_share), stringResource(MR.strings.export_database), @@ -327,7 +335,7 @@ fun chatArchiveTitle(chatArchiveTime: Instant, chatLastStart: Instant): String { return stringResource(if (chatArchiveTime < chatLastStart) MR.strings.old_database_archive else MR.strings.new_database_archive) } -private fun startChat(m: ChatModel, runChat: MutableState<Boolean?>, chatLastStart: MutableState<Instant?>, chatDbChanged: MutableState<Boolean>) { +private fun startChat(m: ChatModel, chatLastStart: MutableState<Instant?>, chatDbChanged: MutableState<Boolean>) { withApi { try { if (chatDbChanged.value) { @@ -344,7 +352,6 @@ private fun startChat(m: ChatModel, runChat: MutableState<Boolean?>, chatLastSta return@withApi } else { m.controller.apiStartChat() - runChat.value = true m.chatRunning.value = true } val ts = Clock.System.now() @@ -352,19 +359,19 @@ private fun startChat(m: ChatModel, runChat: MutableState<Boolean?>, chatLastSta chatLastStart.value = ts platform.androidChatStartedAfterBeingOff() } catch (e: Error) { - runChat.value = false + m.chatRunning.value = false AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_starting_chat), e.toString()) } } } -private fun stopChatAlert(m: ChatModel, runChat: MutableState<Boolean?>) { +private fun stopChatAlert(m: ChatModel) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.stop_chat_question), text = generalGetString(MR.strings.stop_chat_to_export_import_or_delete_chat_database), confirmText = generalGetString(MR.strings.stop_chat_confirmation), - onConfirm = { authStopChat(m, runChat) }, - onDismiss = { runChat.value = true } + onConfirm = { authStopChat(m) }, + onDismiss = { m.chatRunning.value = true } ) } @@ -375,7 +382,7 @@ private fun exportProhibitedAlert() { ) } -private fun authStopChat(m: ChatModel, runChat: MutableState<Boolean?>) { +private fun authStopChat(m: ChatModel) { if (m.controller.appPrefs.performLA.get()) { authenticate( generalGetString(MR.strings.auth_stop_chat), @@ -383,30 +390,29 @@ private fun authStopChat(m: ChatModel, runChat: MutableState<Boolean?>) { completed = { laResult -> when (laResult) { LAResult.Success, is LAResult.Unavailable -> { - stopChat(m, runChat) + stopChat(m) } is LAResult.Error -> { - runChat.value = true + m.chatRunning.value = true } is LAResult.Failed -> { - runChat.value = true + m.chatRunning.value = true } } } ) } else { - stopChat(m, runChat) + stopChat(m) } } -private fun stopChat(m: ChatModel, runChat: MutableState<Boolean?>) { +private fun stopChat(m: ChatModel) { withApi { try { - runChat.value = false stopChatAsync(m) platform.androidChatStopped() } catch (e: Error) { - runChat.value = true + m.chatRunning.value = true AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_stopping_chat), e.toString()) } } @@ -657,7 +663,9 @@ fun PreviewDatabaseLayout() { chatDbChanged = false, useKeyChain = false, chatDbEncrypted = false, + passphraseSaved = false, initialRandomDBPassphrase = SharedPreference({ true }, {}), + developerTools = true, importArchiveLauncher = rememberFileChooserLauncher(true) {}, chatArchiveName = remember { mutableStateOf("dummy_archive") }, chatArchiveTime = remember { mutableStateOf(Clock.System.now()) }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index d96b9d8a1e..d8466e9d96 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -101,6 +101,10 @@ class AlertManager { Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING_HALF), horizontalArrangement = Arrangement.SpaceBetween ) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } TextButton(onClick = { onDismiss?.invoke() hideAlert() @@ -108,7 +112,7 @@ class AlertManager { TextButton(onClick = { onConfirm?.invoke() hideAlert() - }) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) } + }, Modifier.focusRequester(focusRequester)) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) } } }, shape = RoundedCornerShape(corner = CornerSize(25.dp)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt index 10641b6d81..e7da47f8f0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt @@ -54,6 +54,12 @@ object DatabaseUtils { } else { dbKey = ksDatabasePassword.get() ?: "" } + } else if (appPlatform.isDesktop && !hasDatabase(dataDir.absolutePath)) { + // In case of database was deleted by hand + dbKey = randomDatabasePassword() + ksDatabasePassword.set(dbKey) + appPreferences.initialRandomDBPassphrase.set(true) + appPreferences.storeDBPassphrase.set(true) } return dbKey } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt index 65eb11321e..71801e7a5f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt @@ -32,7 +32,7 @@ import kotlinx.coroutines.launch @Composable fun DefaultBasicTextField( modifier: Modifier, - initialValue: String, + state: MutableState<TextFieldValue>, placeholder: (@Composable () -> Unit)? = null, leadingIcon: (@Composable () -> Unit)? = null, focus: Boolean = false, @@ -41,11 +41,8 @@ fun DefaultBasicTextField( selectTextOnFocus: Boolean = false, keyboardOptions: KeyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions: KeyboardActions = KeyboardActions(), - onValueChange: (String) -> Unit, + onValueChange: (TextFieldValue) -> Unit, ) { - val state = remember { - mutableStateOf(TextFieldValue(initialValue)) - } val focusRequester = remember { FocusRequester() } val keyboard = LocalSoftwareKeyboardController.current @@ -83,8 +80,7 @@ fun DefaultBasicTextField( minHeight = TextFieldDefaults.MinHeight ), onValueChange = { - state.value = it - onValueChange(it.text) + onValueChange(it) }, cursorBrush = SolidColor(colors.cursorColor(false).value), visualTransformation = VisualTransformation.None, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt index 5ab0e68c6b..7db001a4bd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp @@ -66,11 +67,13 @@ fun SimpleButton( fun SimpleButtonIconEnded( text: String, icon: Painter, + style: TextStyle = MaterialTheme.typography.caption, color: Color = MaterialTheme.colors.primary, + disabled: Boolean = false, click: () -> Unit ) { - SimpleButtonFrame(click) { - Text(text, style = MaterialTheme.typography.caption, color = color) + SimpleButtonFrame(click, disabled = disabled) { + Text(text, style = style, color = color) Icon( icon, text, tint = color, modifier = Modifier.padding(start = 8.dp) 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..6aaf7a9fdf 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 @@ -67,7 +67,7 @@ const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824 // 1GB expect fun getAppFileUri(fileName: String): URI // https://developer.android.com/training/data-storage/shared/documents-files#bitmap -expect fun getLoadedImage(file: CIFile?): ImageBitmap? +expect fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>? expect fun getFileName(uri: URI): String? @@ -77,6 +77,8 @@ expect fun getFileSize(uri: URI): Long? expect fun getBitmapFromUri(uri: URI, withAlertOnException: Boolean = true): ImageBitmap? +expect fun getBitmapFromByteArray(data: ByteArray, withAlertOnException: Boolean): ImageBitmap? + expect fun getDrawableFromUri(uri: URI, withAlertOnException: Boolean = true): Any? fun getThemeFromUri(uri: URI, withAlertOnException: Boolean = true): ThemeOverrides? { @@ -95,29 +97,34 @@ fun getThemeFromUri(uri: URI, withAlertOnException: Boolean = true): ThemeOverri return null } -fun saveImage(uri: URI): String? { +fun saveImage(uri: URI, encrypted: Boolean): CryptoFile? { val bitmap = getBitmapFromUri(uri) ?: return null - return saveImage(bitmap) + return saveImage(bitmap, encrypted) } -fun saveImage(image: ImageBitmap): String? { +fun saveImage(image: ImageBitmap, encrypted: Boolean): CryptoFile? { return try { val ext = if (image.hasAlpha()) "png" else "jpg" val dataResized = resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE) - val fileToSave = generateNewFileName("IMG", ext) - val file = File(getAppFilePath(fileToSave)) - val output = FileOutputStream(file) - dataResized.writeTo(output) - output.flush() - output.close() - fileToSave + val destFileName = generateNewFileName("IMG", ext) + val destFile = File(getAppFilePath(destFileName)) + if (encrypted) { + val args = writeCryptoFile(destFile.absolutePath, dataResized.toByteArray()) + CryptoFile(destFileName, args) + } else { + val output = FileOutputStream(destFile) + dataResized.writeTo(output) + output.flush() + output.close() + CryptoFile.plain(destFileName) + } } catch (e: Exception) { Log.e(TAG, "Util.kt saveImage error: ${e.stackTraceToString()}") null } } -fun saveAnimImage(uri: URI): String? { +fun saveAnimImage(uri: URI, encrypted: Boolean): CryptoFile? { return try { val filename = getFileName(uri)?.lowercase() var ext = when { @@ -127,15 +134,15 @@ fun saveAnimImage(uri: URI): String? { } // Just in case the image has a strange extension if (ext.length < 3 || ext.length > 4) ext = "gif" - val fileToSave = generateNewFileName("IMG", ext) - val file = File(getAppFilePath(fileToSave)) - val output = FileOutputStream(file) - uri.inputStream().use { input -> - output.use { output -> - input?.copyTo(output) - } + val destFileName = generateNewFileName("IMG", ext) + val destFile = File(getAppFilePath(destFileName)) + if (encrypted) { + val args = writeCryptoFile(destFile.absolutePath, uri.inputStream()?.readAllBytes() ?: return null) + CryptoFile(destFileName, args) + } else { + Files.copy(uri.inputStream(), destFile.toPath()) + CryptoFile.plain(destFileName) } - fileToSave } catch (e: Exception) { Log.e(TAG, "Util.kt saveAnimImage error: ${e.message}") null @@ -144,25 +151,44 @@ 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) - if (inputStream != null && fileToSave != null) { + return if (inputStream != null && fileToSave != null) { val destFileName = uniqueCombine(fileToSave) val destFile = File(getAppFilePath(destFileName)) - Files.copy(inputStream, destFile.toPath()) - destFileName + if (encrypted) { + createTmpFileAndDelete { tmpFile -> + Files.copy(inputStream, tmpFile.toPath()) + val args = encryptCryptoFile(tmpFile.absolutePath, destFile.absolutePath) + CryptoFile(destFileName, args) + } + } else { + Files.copy(inputStream, destFile.toPath()) + CryptoFile.plain(destFileName) + } } else { Log.e(TAG, "Util.kt saveFileFromUri null inputStream") null } } catch (e: Exception) { - Log.e(TAG, "Util.kt saveFileFromUri error: ${e.message}") + Log.e(TAG, "Util.kt saveFileFromUri error: ${e.stackTraceToString()}") null } } +fun <T> createTmpFileAndDelete(onCreated: (File) -> T): T { + val tmpFile = File(tmpDir, UUID.randomUUID().toString()) + tmpFile.deleteOnExit() + ChatModel.filesToDelete.add(tmpFile) + try { + return onCreated(tmpFile) + } finally { + tmpFile.delete() + } +} + fun generateNewFileName(prefix: String, ext: String): String { val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) sdf.timeZone = TimeZone.getTimeZone("GMT") @@ -263,6 +289,17 @@ fun blendARGB( return Color(r, g, b, a) } +fun InputStream.toByteArray(): ByteArray = + ByteArrayOutputStream().use { output -> + val b = ByteArray(4096) + var n = read(b) + while (n != -1) { + output.write(b, 0, n); + n = read(b) + } + return output.toByteArray() + } + expect fun ByteArray.toBase64StringForPassphrase(): String // Android's default implementation that was used before multiplatform, adds non-needed characters at the end of string diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt index 756e605dc3..8b5c2a8336 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt @@ -66,7 +66,6 @@ private fun deleteStorageAndRestart(m: ChatModel, password: String, completed: ( val createdUser = m.controller.apiCreateActiveUser(profile, pastTimestamp = true) m.currentUser.value = createdUser m.controller.appPrefs.onboardingStage.set(OnboardingStage.OnboardingComplete) - m.onboardingStage.value = OnboardingStage.OnboardingComplete if (createdUser != null) { m.controller.startChat(createdUser) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt index fe62a7d9da..934c050d8a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt @@ -126,7 +126,7 @@ private fun ContactConnectionInfoLayout( ) if (contactConnection.groupLinkId == null) { - LocalAliasEditor(contactConnection.localAlias, center = false, leadingIcon = true, focus = focusAlias, updateValue = onLocalAliasChanged) + LocalAliasEditor(contactConnection.id, contactConnection.localAlias, center = false, leadingIcon = true, focus = focusAlias, updateValue = onLocalAliasChanged) } SectionView { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt index a848d3777b..6632925964 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.unit.dp import dev.icerock.moko.resources.compose.stringResource import boofcv.alg.drawing.FiducialImageEngine import boofcv.alg.fiducial.qrcode.* +import chat.simplex.common.model.CryptoFile import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.SimpleXTheme import chat.simplex.common.views.helpers.* @@ -45,7 +46,7 @@ fun QRCode( .let { if (withLogo) it.addLogo() else it } val file = saveTempImageUncompressed(image, false) if (file != null) { - shareFile("", file.absolutePath) + shareFile("", CryptoFile.plain(file.absolutePath)) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt index 84d1ae639d..72cbc3a628 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt @@ -14,8 +14,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import chat.simplex.common.model.ChatModel -import chat.simplex.common.model.UserContactLinkRec +import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* @@ -29,6 +28,10 @@ fun CreateSimpleXAddress(m: ChatModel) { val clipboard = LocalClipboardManager.current val uriHandler = LocalUriHandler.current + LaunchedEffect(Unit) { + prepareChatBeforeAddressCreation() + } + CreateSimpleXAddressLayout( userAddress.value, share = { address: String -> clipboard.shareText(address) }, @@ -63,7 +66,6 @@ fun CreateSimpleXAddress(m: ChatModel) { OnboardingStage.OnboardingComplete } m.controller.appPrefs.onboardingStage.set(next) - m.onboardingStage.value = next }, ) @@ -172,3 +174,19 @@ private fun ProgressIndicator() { ) } } + +private fun prepareChatBeforeAddressCreation() { + if (chatModel.users.isNotEmpty()) return + withApi { + val user = chatModel.controller.apiGetActiveUser() ?: return@withApi + chatModel.currentUser.value = user + if (chatModel.users.isEmpty()) { + chatModel.controller.startChat(user) + } else { + val users = chatModel.controller.listUsers() + chatModel.users.clear() + chatModel.users.addAll(users) + chatModel.controller.getUserChatData() + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt index 3b2e0b408e..e3dfb2b736 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt @@ -13,8 +13,7 @@ import androidx.compose.ui.text.* import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.common.model.ChatController -import chat.simplex.common.model.User +import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.MarkdownText import chat.simplex.common.views.helpers.* @@ -22,7 +21,7 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource @Composable -fun HowItWorks(user: User?, onboardingStage: MutableState<OnboardingStage?>? = null) { +fun HowItWorks(user: User?, onboardingStage: SharedPreference<OnboardingStage>? = null) { Column(Modifier .fillMaxWidth() .padding(horizontal = DEFAULT_PADDING), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt index e3190f8756..119ed8cd48 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.launch enum class OnboardingStage { Step1_SimpleXInfo, Step2_CreateProfile, + Step2_5_SetupDatabasePassphrase, Step3_CreateSimpleXAddress, Step4_SetNotificationsMode, OnboardingComplete diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt index af640d5b48..aa413016d1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt @@ -41,7 +41,7 @@ fun SetNotificationsMode(m: ChatModel) { } Spacer(Modifier.fillMaxHeight().weight(1f)) Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), contentAlignment = Alignment.Center) { - OnboardingActionButton(MR.strings.use_chat, OnboardingStage.OnboardingComplete, m.onboardingStage, false) { + OnboardingActionButton(MR.strings.use_chat, OnboardingStage.OnboardingComplete, false) { changeNotificationsMode(currentMode.value, m) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt new file mode 100644 index 0000000000..9bc5ae846e --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt @@ -0,0 +1,233 @@ +package chat.simplex.common.views.onboarding + +import SectionBottomSpacer +import SectionItemView +import SectionItemViewSpaceBetween +import SectionTextFooter +import SectionView +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.* +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.* +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.database.* +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import kotlinx.coroutines.delay + +@Composable +fun SetupDatabasePassphrase(m: ChatModel) { + val progressIndicator = remember { mutableStateOf(false) } + val prefs = m.controller.appPrefs + val saveInPreferences = remember { mutableStateOf(prefs.storeDBPassphrase.get()) } + val initialRandomDBPassphrase = remember { mutableStateOf(prefs.initialRandomDBPassphrase.get()) } + // Do not do rememberSaveable on current key to prevent saving it on disk in clear text + val currentKey = remember { mutableStateOf(if (initialRandomDBPassphrase.value) DatabaseUtils.ksDatabasePassword.get() ?: "" else "") } + val newKey = rememberSaveable { mutableStateOf("") } + val confirmNewKey = rememberSaveable { mutableStateOf("") } + fun nextStep() { + m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress) + } + SetupDatabasePassphraseLayout( + currentKey, + newKey, + confirmNewKey, + progressIndicator, + onConfirmEncrypt = { + withApi { + if (m.chatRunning.value == true) { + // Stop chat if it's started before doing anything + stopChatAsync(m) + } + prefs.storeDBPassphrase.set(false) + + val newKeyValue = newKey.value + val success = encryptDatabase(currentKey, newKey, confirmNewKey, mutableStateOf(true), saveInPreferences, mutableStateOf(true), progressIndicator) + if (success) { + startChat(newKeyValue) + nextStep() + } else { + // Rollback in case of it is finished with error in order to allow to repeat the process again + prefs.storeDBPassphrase.set(true) + } + } + }, + nextStep = ::nextStep, + ) + + if (progressIndicator.value) { + ProgressIndicator() + } + + DisposableEffect(Unit) { + onDispose { + if (m.chatRunning.value != true) { + withBGApi { + val user = chatController.apiGetActiveUser() + if (user != null) { + m.controller.startChat(user) + } + } + } + } + } +} + +@Composable +private fun SetupDatabasePassphraseLayout( + currentKey: MutableState<String>, + newKey: MutableState<String>, + confirmNewKey: MutableState<String>, + progressIndicator: MutableState<Boolean>, + onConfirmEncrypt: () -> Unit, + nextStep: () -> Unit, +) { + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(top = DEFAULT_PADDING), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarTitle(stringResource(MR.strings.setup_database_passphrase)) + + Spacer(Modifier.weight(1f)) + + Column(Modifier.width(600.dp)) { + val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + LaunchedEffect(Unit) { + delay(100L) + focusRequester.requestFocus() + } + PassphraseField( + newKey, + generalGetString(MR.strings.new_passphrase), + modifier = Modifier + .padding(horizontal = DEFAULT_PADDING) + .focusRequester(focusRequester) + .onPreviewKeyEvent { + if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) { + focusManager.moveFocus(FocusDirection.Down) + true + } else { + false + } + }, + showStrength = true, + isValid = ::validKey, + keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }), + ) + val onClickUpdate = { + // Don't do things concurrently. Shouldn't be here concurrently, just in case + if (!progressIndicator.value) { + encryptDatabaseAlert(onConfirmEncrypt) + } + } + val disabled = currentKey.value == newKey.value || + newKey.value != confirmNewKey.value || + newKey.value.isEmpty() || + !validKey(currentKey.value) || + !validKey(newKey.value) || + progressIndicator.value + + PassphraseField( + confirmNewKey, + generalGetString(MR.strings.confirm_new_passphrase), + modifier = Modifier + .padding(horizontal = DEFAULT_PADDING) + .onPreviewKeyEvent { + if (!disabled && it.key == Key.Enter && it.type == KeyEventType.KeyUp) { + onClickUpdate() + true + } else { + false + } + }, + isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value }, + keyboardActions = KeyboardActions(onDone = { + if (!disabled) onClickUpdate() + defaultKeyboardAction(ImeAction.Done) + }), + ) + + Box(Modifier.align(Alignment.CenterHorizontally).padding(vertical = DEFAULT_PADDING)) { + SetPassphraseButton(disabled, onClickUpdate) + } + + Column { + SectionTextFooter(generalGetString(MR.strings.you_have_to_enter_passphrase_every_time)) + SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase)) + } + } + + Spacer(Modifier.weight(1f)) + SkipButton(progressIndicator.value, nextStep) + + SectionBottomSpacer() + } +} + +@Composable +private fun SetPassphraseButton(disabled: Boolean, onClick: () -> Unit) { + SimpleButtonIconEnded( + stringResource(MR.strings.set_database_passphrase), + painterResource(MR.images.ic_check), + style = MaterialTheme.typography.h2, + color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary, + disabled = disabled, + click = onClick + ) +} + +@Composable +private fun SkipButton(disabled: Boolean, onClick: () -> Unit) { + SimpleButtonIconEnded(stringResource(MR.strings.use_random_passphrase), painterResource(MR.images.ic_chevron_right), color = + if (disabled) MaterialTheme.colors.secondary else WarningOrange, disabled = disabled, click = onClick) + Text( + stringResource(MR.strings.you_can_change_it_later), + Modifier + .fillMaxWidth() + .padding(horizontal = DEFAULT_PADDING * 3), + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.secondary, + textAlign = TextAlign.Center, + ) +} + +@Composable +private fun ProgressIndicator() { + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + Modifier + .padding(horizontal = 2.dp) + .size(30.dp), + color = MaterialTheme.colors.secondary, + strokeWidth = 3.dp + ) + } +} + +private suspend fun startChat(key: String?) { + val m = ChatModel + initChatController(key) + m.chatDbChanged.value = false + m.chatRunning.value = true +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt index 8248194ebc..f20c4508b4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter @@ -25,7 +24,7 @@ import dev.icerock.moko.resources.StringResource fun SimpleXInfo(chatModel: ChatModel, onboarding: Boolean = true) { SimpleXInfoLayout( user = chatModel.currentUser.value, - onboardingStage = if (onboarding) chatModel.onboardingStage else null, + onboardingStage = if (onboarding) chatModel.controller.appPrefs.onboardingStage else null, showModal = { modalView -> { if (onboarding) ModalManager.fullscreen.showModal { modalView(chatModel) } else ModalManager.start.showModal { modalView(chatModel) } } }, ) } @@ -33,7 +32,7 @@ fun SimpleXInfo(chatModel: ChatModel, onboarding: Boolean = true) { @Composable fun SimpleXInfoLayout( user: User?, - onboardingStage: MutableState<OnboardingStage?>?, + onboardingStage: SharedPreference<OnboardingStage>?, showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), ) { Column( @@ -100,11 +99,11 @@ private fun InfoRow(icon: Painter, titleId: StringResource, textId: StringResour } @Composable -fun OnboardingActionButton(user: User?, onboardingStage: MutableState<OnboardingStage?>, onclick: (() -> Unit)? = null) { +fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference<OnboardingStage>, onclick: (() -> Unit)? = null) { if (user == null) { - OnboardingActionButton(MR.strings.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, onboardingStage, true, onclick) + OnboardingActionButton(MR.strings.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, true, onclick) } else { - OnboardingActionButton(MR.strings.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, onboardingStage, true, onclick) + OnboardingActionButton(MR.strings.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, true, onclick) } } @@ -112,7 +111,6 @@ fun OnboardingActionButton(user: User?, onboardingStage: MutableState<Onboarding fun OnboardingActionButton( labelId: StringResource, onboarding: OnboardingStage?, - onboardingStage: MutableState<OnboardingStage?>, border: Boolean, onclick: (() -> Unit)? ) { @@ -129,7 +127,6 @@ fun OnboardingActionButton( SimpleButtonFrame(click = { onclick?.invoke() - onboardingStage.value = onboarding if (onboarding != null) { ChatController.appPrefs.onboardingStage.set(onboarding) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt index d79b7b782e..75e7d72016 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt @@ -201,12 +201,15 @@ object AppearanceScope { val supportedLanguages = mapOf( "system" to generalGetString(MR.strings.language_system), "en" to "English", + "ar" to "العربية", "bg" to "Български", "cs" to "Čeština", "de" to "Deutsch", "es" to "Español", + "fi" to "Suomi", "fr" to "Français", "it" to "Italiano", + "iw" to "עִברִית", "ja" to "日本語", "nl" to "Nederlands", "pl" to "Polski", diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt index 447b65eff6..f2fee926a7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt @@ -180,7 +180,13 @@ fun NetworkAndServersView( SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) }) } if (networkUseSocksProxy.value) { - SectionCustomFooter { Text(annotatedStringResource(MR.strings.disable_onion_hosts_when_not_supported)) } + SectionCustomFooter { + Column { + Text(annotatedStringResource(MR.strings.disable_onion_hosts_when_not_supported)) + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + Text(annotatedStringResource(MR.strings.socks_proxy_setting_limitations)) + } + } Divider(Modifier.padding(start = DEFAULT_PADDING_HALF, top = 32.dp, end = DEFAULT_PADDING_HALF, bottom = 30.dp)) } else { Divider(Modifier.padding(start = DEFAULT_PADDING_HALF, top = 24.dp, end = DEFAULT_PADDING_HALF, bottom = 30.dp)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 81d56a3816..ef0940b2a0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -64,6 +64,7 @@ fun PrivacySettingsView( SectionDividerSpaced() SectionView(stringResource(MR.strings.settings_section_title_chats)) { + SettingsPreferenceItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.encrypt_local_files), chatModel.controller.appPrefs.privacyEncryptLocalFiles) SettingsPreferenceItem(painterResource(MR.images.ic_image), stringResource(MR.strings.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages) SettingsPreferenceItem(painterResource(MR.images.ic_travel_explore), stringResource(MR.strings.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews) SettingsPreferenceItem( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index baffc02f61..8969e48b2c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -43,6 +43,7 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, drawerSt profile = user.profile, stopped, chatModel.chatDbEncrypted.value == true, + remember { chatModel.controller.appPrefs.storeDBPassphrase.state }.value, remember { chatModel.controller.appPrefs.notificationsMode.state }, user.displayName, setPerformLA = setPerformLA, @@ -115,6 +116,7 @@ fun SettingsLayout( profile: LocalProfile, stopped: Boolean, encrypted: Boolean, + passphraseSaved: Boolean, notificationsMode: State<NotificationsMode>, userDisplayName: String, setPerformLA: (Boolean) -> Unit, @@ -162,7 +164,7 @@ fun SettingsLayout( SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped, extraPadding = true) SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped, extraPadding = true) SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it, showSettingsModal) }, extraPadding = true) - DatabaseItem(encrypted, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped) + DatabaseItem(encrypted, passphraseSaved, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped) } SectionDividerSpaced() @@ -207,7 +209,7 @@ expect fun SettingsSectionApp( withAuth: (title: String, desc: String, block: () -> Unit) -> Unit ) -@Composable private fun DatabaseItem(encrypted: Boolean, openDatabaseView: () -> Unit, stopped: Boolean) { +@Composable private fun DatabaseItem(encrypted: Boolean, saved: Boolean, openDatabaseView: () -> Unit, stopped: Boolean) { SectionItemViewWithIcon(openDatabaseView) { Row( Modifier.fillMaxWidth(), @@ -217,7 +219,7 @@ expect fun SettingsSectionApp( Icon( painterResource(MR.images.ic_database), contentDescription = stringResource(MR.strings.database_passphrase_and_export), - tint = if (encrypted) MaterialTheme.colors.secondary else WarningOrange, + tint = if (encrypted && (appPlatform.isAndroid || !saved)) MaterialTheme.colors.secondary else WarningOrange, ) TextIconSpaced(true) Text(stringResource(MR.strings.database_passphrase_and_export)) @@ -393,9 +395,13 @@ fun SettingsActionItemWithContent(icon: Painter?, text: String? = null, click: ( val padding = with(LocalDensity.current) { 6.sp.toDp() } Text(text, Modifier.weight(1f).padding(vertical = padding), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.onBackground) Spacer(Modifier.width(DEFAULT_PADDING)) - } - Row(Modifier.widthIn(max = (windowWidth() - DEFAULT_PADDING * 2) / 2)) { - content() + Row(Modifier.widthIn(max = (windowWidth() - DEFAULT_PADDING * 2) / 2)) { + content() + } + } else { + Row { + content() + } } } } @@ -469,6 +475,7 @@ fun PreviewSettingsLayout() { profile = LocalProfile.sampleData, stopped = false, encrypted = false, + passphraseSaved = false, notificationsMode = remember { mutableStateOf(NotificationsMode.OFF) }, userDisplayName = "Alice", setPerformLA = { _ -> }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt index ac3a68fc49..7929413c93 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt @@ -164,10 +164,9 @@ private fun UserProfilesLayout( ) { if (profileHidden.value) { SectionView { - SettingsActionItem(painterResource(MR.images.ic_lock_open), stringResource(MR.strings.enter_password_to_show), click = { + SettingsActionItem(painterResource(MR.images.ic_lock_open_right), stringResource(MR.strings.enter_password_to_show), click = { profileHidden.value = false - } - ) + }) } SectionSpacer() } @@ -223,7 +222,7 @@ private fun UserView( Box(Modifier.padding(horizontal = DEFAULT_PADDING)) { DefaultDropdownMenu(showMenu) { if (user.hidden) { - ItemAction(stringResource(MR.strings.user_unhide), painterResource(MR.images.ic_lock_open), onClick = { + ItemAction(stringResource(MR.strings.user_unhide), painterResource(MR.images.ic_lock_open_right), onClick = { showMenu.value = false unhideUser(user) }) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 03adcb4ec4..3c39d8f803 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -24,7 +24,7 @@ <string name="member_role_will_be_changed_with_notification">سيتم تغيير الدور إلى \"%s\". سيتم إبلاغ كل فرد في المجموعة.</string> <string name="member_role_will_be_changed_with_invitation">سيتم تغيير الدور إلى \"%s\". سيتلقى العضو دعوة جديدة.</string> <string name="smp_servers_per_user">خوادم الاتصالات الجديدة لملف تعريف الدردشة الحالي الخاص بك</string> - <string name="switch_receiving_address_desc">هذه الميزة تجريبية! ستعمل فقط إذا كان لدى العميل الآخر الإصدار 4.2 مثبتًا. يجب أن ترى الرسالة في المحادثة بمجرد اكتمال تغيير العنوان - يرجى التحقق من أنه لا يزال بإمكانك تلقي الرسائل من جهة الاتصال هذه (أو عضو المجموعة).</string> + <string name="switch_receiving_address_desc">سيتم تغيير عنوان الاستلام إلى خادم مختلف. سيتم إكمال تغيير العنوان بعد اتصال المرسل بالإنترنت.</string> <string name="this_link_is_not_a_valid_connection_link">هذا الارتباط ليس ارتباط اتصال صالح!</string> <string name="allow_verb">يسمح</string> <string name="smp_servers_preset_add">أضف خوادم محددة مسبقًا</string> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index ea6d13a355..8e035420d5 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -615,7 +615,7 @@ <string name="network_use_onion_hosts_required">Required</string> <string name="network_use_onion_hosts_prefer_desc">Onion hosts will be used when available.</string> <string name="network_use_onion_hosts_no_desc">Onion hosts will not be used.</string> - <string name="network_use_onion_hosts_required_desc">Onion hosts will be required for connection.</string> + <string name="network_use_onion_hosts_required_desc">Onion hosts will be required for connection.\nPlease note: you will not be able to connect to the servers without .onion address.</string> <string name="network_use_onion_hosts_prefer_desc_in_alert">Onion hosts will be used when available.</string> <string name="network_use_onion_hosts_no_desc_in_alert">Onion hosts will not be used.</string> <string name="network_use_onion_hosts_required_desc_in_alert">Onion hosts will be required for connection.</string> @@ -626,6 +626,7 @@ <string name="network_session_mode_entity_description"><![CDATA[A separate TCP connection (and SOCKS credential) will be used <b>for each contact and group member</b>.\n<b>Please note</b>: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail.]]></string> <string name="update_network_session_mode_question">Update transport isolation mode?</string> <string name="disable_onion_hosts_when_not_supported"><![CDATA[Set <i>Use .onion hosts</i> to No if SOCKS proxy does not support them.]]></string> + <string name="socks_proxy_setting_limitations"><![CDATA[<b>Please note</b>: message and file relays are connected via SOCKS proxy. Calls and sending link previews use direct connection.]]></string> <string name="appearance_settings">Appearance</string> <string name="customize_theme_title">Customize theme</string> <string name="theme_colors_section_title">THEME COLORS</string> @@ -769,6 +770,11 @@ <string name="onboarding_notifications_mode_periodic_desc"><![CDATA[<b>Good for battery</b>. Background service checks messages every 10 minutes. You may miss calls or urgent messages.]]></string> <string name="onboarding_notifications_mode_service_desc"><![CDATA[<b>Uses more battery</b>! Background service always runs – notifications are shown as soon as messages are available.]]></string> + <!-- SetupDatabasePassphrase.kt --> + <string name="setup_database_passphrase">Setup database passphrase</string> + <string name="you_can_change_it_later">Random passphrase is stored in settings as plaintext.\nYou can change it later.</string> + <string name="use_random_passphrase">Use random passphrase</string> + <!-- MakeConnection --> <string name="paste_the_link_you_received">Paste received link</string> @@ -850,6 +856,7 @@ <string name="privacy_and_security">Privacy & security</string> <string name="your_privacy">Your privacy</string> <string name="protect_app_screen">Protect app screen</string> + <string name="encrypt_local_files">Encrypt local files</string> <string name="auto_accept_images">Auto-accept images</string> <string name="send_link_previews">Send link previews</string> <string name="privacy_show_last_messages">Show last messages</string> @@ -939,6 +946,7 @@ <string name="import_database">Import database</string> <string name="new_database_archive">New database archive</string> <string name="old_database_archive">Old database archive</string> + <string name="open_database_folder">Open database folder</string> <string name="delete_database">Delete database</string> <string name="error_starting_chat">Error starting chat</string> <string name="stop_chat_question">Stop chat?</string> @@ -984,9 +992,11 @@ <!-- DatabaseEncryptionView.kt --> <string name="save_passphrase_in_keychain">Save passphrase in Keystore</string> + <string name="save_passphrase_in_settings">Save passphrase in settings</string> <string name="database_encrypted">Database encrypted!</string> <string name="error_encrypting_database">Error encrypting database</string> <string name="remove_passphrase_from_keychain">Remove passphrase from Keystore?</string> + <string name="remove_passphrase_from_settings">Remove passphrase from settings?</string> <string name="notifications_will_be_hidden">Notifications will be delivered only until the app stops!</string> <string name="remove_passphrase">Remove</string> <string name="encrypt_database">Encrypt</string> @@ -995,18 +1005,23 @@ <string name="new_passphrase">New passphrase…</string> <string name="confirm_new_passphrase">Confirm new passphrase…</string> <string name="update_database_passphrase">Update database passphrase</string> + <string name="set_database_passphrase">Set database passphrase</string> <string name="enter_correct_current_passphrase">Please enter correct current passphrase.</string> <string name="database_is_not_encrypted">Your chat database is not encrypted - set passphrase to protect it.</string> <string name="keychain_is_storing_securely">Android Keystore is used to securely store passphrase - it allows notification service to work.</string> + <string name="settings_is_storing_in_clear_text">The passphrase is stored in settings as plaintext.</string> <string name="encrypted_with_random_passphrase">Database is encrypted using a random passphrase, you can change it.</string> <string name="impossible_to_recover_passphrase"><![CDATA[<b>Please note</b>: you will NOT be able to recover or change passphrase if you lose it.]]></string> <string name="keychain_allows_to_receive_ntfs">Android Keystore will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving notifications.</string> + <string name="passphrase_will_be_saved_in_settings">The passphrase will be stored in settings as plaintext after you change it or restart the app.</string> <string name="you_have_to_enter_passphrase_every_time">You have to enter passphrase every time the app starts - it is not stored on the device.</string> <string name="encrypt_database_question">Encrypt database?</string> <string name="change_database_passphrase_question">Change database passphrase?</string> <string name="database_will_be_encrypted">Database will be encrypted.</string> <string name="database_will_be_encrypted_and_passphrase_stored">Database will be encrypted and the passphrase stored in the Keystore.</string> + <string name="database_will_be_encrypted_and_passphrase_stored_in_settings">Database will be encrypted and the passphrase stored in settings.</string> <string name="database_encryption_will_be_updated">Database encryption passphrase will be updated and stored in the Keystore.</string> + <string name="database_encryption_will_be_updated_in_settings">Database encryption passphrase will be updated and stored in settings.</string> <string name="database_passphrase_will_be_updated">Database encryption passphrase will be updated.</string> <string name="store_passphrase_securely">Please store passphrase securely, you will NOT be able to change it if you lose it.</string> <string name="store_passphrase_securely_without_recover">Please store passphrase securely, you will NOT be able to access chat if you lose it.</string> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index badd8cbc38..4b4bac96de 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -1337,7 +1337,7 @@ <string name="v5_2_message_delivery_receipts_descr">Druhé zaškrtnutí jsme přehlédli! ✅</string> <string name="switch_receiving_address_desc">Přijímací adresa bude změněna na jiný server. Změna adresy bude dokončena po připojení odesílatele.</string> <string name="choose_file_title">Vybrat soubor</string> - <string name="connect_via_link_incognito">Připojit se inkognito</string> + <string name="connect_via_link_incognito">Spojit se inkognito</string> <string name="turn_off_battery_optimization_button">Povolit</string> <string name="disable_notifications_button">Vypnout upozornění</string> <string name="turn_off_system_restriction_button">Otevřít nastavení aplikace</string> @@ -1355,4 +1355,25 @@ <string name="send_receipts_disabled">vypnut</string> <string name="send_receipts_disabled_alert_title">Receipts jsou zakázány</string> <string name="in_developing_title">Již brzy!</string> + <string name="connect_use_current_profile">Použít aktuální profil</string> + <string name="connect_use_new_incognito_profile">Použít nový incognito profil</string> + <string name="system_restricted_background_desc">SimpleX nemůže běžet na pozadí. Pouze při spuštěné aplikaci obdržíte upozornění.</string> + <string name="system_restricted_background_warn"><![CDATA[Chcete-li povolit oznámení, vyberte prosím <b>Baterii</b> / <b>bez omezení</b> v nastavení aplikace.]]></string> + <string name="system_restricted_background_in_call_desc">Aplikace může být uzavřena po 1 minutě na pozadí.</string> + <string name="system_restricted_background_in_call_warn"><![CDATA[Chcete-li volat na pozadí, vyberte prosím <b>Baterii</b> / <b>bez omezení</b> v nastavení aplikace.]]></string> + <string name="connect__your_profile_will_be_shared">Váš profil %1$s bude sdílen.</string> + <string name="connect_via_member_address_alert_desc">Požadavek na připojení bude zaslán tomuto členu skupiny.</string> + <string name="delivery">Doručenka</string> + <string name="receipts_groups_title_disable">Zakázat doručenky pro skupiny\?</string> + <string name="receipts_groups_title_enable">Povolit doručenky pro skupiny\?</string> + <string name="receipts_groups_override_enabled">Odeslání doručenek je povoleno pro %d skupiny</string> + <string name="receipts_section_groups">Malé skupiny (max. 20)</string> + <string name="recipient_colon_delivery_status">%s: %s</string> + <string name="rcv_group_event_2_members_connected">%s a %s připojen</string> + <string name="rcv_group_event_3_members_connected">%s, %s a %s připojeni</string> + <string name="rcv_group_event_n_members_connected">%s, %s a %d dalších členů připojeno</string> + <string name="privacy_message_draft">Rozepsáno</string> + <string name="privacy_show_last_messages">Zobrazit poslední zprávy</string> + <string name="send_receipts_disabled_alert_msg">Tato skupina má více než %1$d členů, doručenky nejsou odeslány.</string> + <string name="in_developing_desc">Tato funkce zatím není podporována. Vyzkoušejte další vydání.</string> </resources> \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index b3630c2367..dffad174fc 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -146,7 +146,7 @@ <string name="smp_server_test_disconnect">Desconectar</string> <string name="notification_preview_mode_contact">Contacto</string> <string name="copy_verb">Copiar</string> - <string name="create_your_profile">Crear tu perfil</string> + <string name="create_your_profile">Crea tu perfil</string> <string name="always_use_relay">Usar siempre retransmisor</string> <string name="set_password_to_export_desc">La base de datos está cifrada con una contraseña aleatoria. Cámbiala antes de exportar.</string> <string name="total_files_count_and_size">%d archivo(s) con tamaño total de %s</string> @@ -192,7 +192,7 @@ <string name="delete_address__question">¿Eliminar la dirección\?</string> <string name="display_name__field">Nombre mostrado:</string> <string name="callstate_connecting">conectando…</string> - <string name="decentralized">Descentralizado</string> + <string name="decentralized">Descentralizada</string> <string name="database_will_be_encrypted">La base de datos será cifrada.</string> <string name="delete_chat_archive_question">¿Eliminar archivo del chat\?</string> <string name="create_group_link">Crear enlace de grupo</string> @@ -485,7 +485,7 @@ <string name="mark_unread">Marcar como no leído</string> <string name="invalid_QR_code">Código QR inválido</string> <string name="incorrect_code">¡Código de seguridad incorrecto!</string> - <string name="markdown_in_messages">Sintaxis markdown en los mensajes</string> + <string name="markdown_in_messages">Sintaxis Markdown</string> <string name="network_use_onion_hosts_no">No</string> <string name="callstatus_missed">llamada perdida</string> <string name="import_database_confirmation">Importar</string> @@ -717,7 +717,7 @@ <string name="la_notice_title_simplex_lock">Bloqueo SimpleX</string> <string name="auth_unlock">Desbloquear</string> <string name="this_text_is_available_in_settings">Este texto está disponible en Configuración</string> - <string name="switch_receiving_address_desc">La dirección de recepción se cambiará. El cambio se completará cuando el remitente esté en línea.</string> + <string name="switch_receiving_address_desc">La dirección de recepción pasará a otro servidor. El cambio se completará cuando el remitente esté en línea.</string> <string name="chat_lock">Bloqueo SimpleX</string> <string name="using_simplex_chat_servers">Usando servidores SimpleX Chat.</string> <string name="network_session_mode_transport_isolation">Aislamiento de transporte</string> @@ -749,11 +749,11 @@ <string name="share_invitation_link">Compartir enlace de un uso</string> <string name="update_network_session_mode_question">¿Actualizar el modo de aislamiento de transporte\?</string> <string name="icon_descr_speaker_on">Altavoz activado</string> - <string name="stop_chat_to_enable_database_actions">Para habilitar las acciones sobre la base de datos, previamente debes detener Chat</string> + <string name="stop_chat_to_enable_database_actions">Detén SimpleX para habilitar las acciones sobre la base de datos.</string> <string name="connection_you_accepted_will_be_cancelled">¡La conexión que has aceptado se cancelará!</string> <string name="database_initialization_error_desc">La base de datos no funciona correctamente. Pulsa para saber más</string> <string name="moderate_message_will_be_marked_warning">El mensaje será marcado como moderado para todos los miembros.</string> - <string name="next_generation_of_private_messaging">La próxima generación de mensajería privada</string> + <string name="next_generation_of_private_messaging">La nueva generación de mensajería privada</string> <string name="delete_files_and_media_desc">Esta acción no se puede deshacer. Se eliminarán todos los archivos y multimedia recibidos y enviados. Las imágenes de baja resolución permanecerán.</string> <string name="enable_automatic_deletion_message">Esta acción no se puede deshacer. Se eliminarán los mensajes enviados y recibidos anteriores a la selección. Puede tardar varios minutos.</string> <string name="messages_section_description">Esta configuración se aplica a los mensajes del perfil actual</string> @@ -914,7 +914,7 @@ <string name="your_settings">Configuración</string> <string name="your_SMP_servers">Servidores SMP</string> <string name="you_control_your_chat">¡Tú controlas tu chat!</string> - <string name="your_profile_is_stored_on_your_device">Tu perfil, contactos y mensajes entregados se almacenan en tu dispositivo.</string> + <string name="your_profile_is_stored_on_your_device">Tu perfil, contactos y mensajes se almacenan en tu dispositivo.</string> <string name="callstate_waiting_for_answer">esperando respuesta…</string> <string name="callstate_waiting_for_confirmation">esperando confirmación…</string> <string name="onboarding_notifications_mode_off">Cuando la aplicación se está ejecutando</string> @@ -923,7 +923,7 @@ <string name="your_ice_servers">Servidores ICE</string> <string name="your_privacy">Privacidad</string> <string name="settings_section_title_you">MIS DATOS</string> - <string name="your_chat_database">Base de datos Chat</string> + <string name="your_chat_database">Base de datos</string> <string name="you_can_start_chat_via_setting_or_by_restarting_the_app">Puedes iniciar el chat en Configuración / Base de datos o reiniciando la aplicación.</string> <string name="you_sent_group_invitation">Has enviado una invitación de grupo</string> <string name="num_contacts_selected">%d contacto(s) seleccionado(s)</string> @@ -1126,7 +1126,7 @@ <string name="learn_more">Más información</string> <string name="if_you_cant_meet_in_person">Si no puedes reunirte en persona, **muestra el código QR por videollamada**, o comparte el enlace.</string> <string name="scan_qr_to_connect_to_contact">Para conectarse, tu contacto puede escanear el código QR o usar el enlace en la aplicación.</string> - <string name="create_simplex_address">Crear dirección SimpleX</string> + <string name="create_simplex_address">Crear tu dirección SimpleX</string> <string name="auto_accept_contact">Auto aceptar</string> <string name="group_welcome_preview">Vista previa</string> <string name="opening_database">Abriendo base de datos…</string> @@ -1144,15 +1144,15 @@ <string name="export_theme">Exportar tema</string> <string name="color_surface">Menús y alertas</string> <string name="add_address_to_your_profile">Añade la dirección a tu perfil para que tus contactos puedan compartirla con otros. La actualización del perfil se enviará a tus contactos.</string> - <string name="learn_more_about_address">Acerca de dirección SimpleX</string> + <string name="learn_more_about_address">Acerca de la dirección SimpleX</string> <string name="address_section_title">Dirección</string> <string name="all_your_contacts_will_remain_connected_update_sent">Todos tus contactos permanecerán conectados. La actualización del perfil se enviará a tus contactos.</string> <string name="continue_to_next_step">Continuar</string> <string name="dark_theme">Tema oscuro</string> <string name="customize_theme_title">Personalizar tema</string> <string name="enter_welcome_message_optional">Introduce mensaje de bienvenida… (opcional)</string> - <string name="create_address_and_let_people_connect">Crear una dirección para que otras personas se puedan conectar contigo.</string> - <string name="dont_create_address">No crear dirección</string> + <string name="create_address_and_let_people_connect">Crea una dirección para que otras personas puedan conectar contigo.</string> + <string name="dont_create_address">No crear dirección SimpleX</string> <string name="email_invite_body">¡Hola! \nConecta conmigo a través de SimpleX Chat: %s</string> <string name="import_theme">Importar tema</string> @@ -1170,7 +1170,7 @@ <string name="stop_sharing">Dejar de compartir</string> <string name="stop_sharing_address">¿Dejar de compartir la dirección\?</string> <string name="theme_colors_section_title">COLORES DEL TEMA</string> - <string name="you_can_create_it_later">Puedes crearlo más tarde</string> + <string name="you_can_create_it_later">Puedes crearla más tarde</string> <string name="share_address_with_contacts_question">¿Compartir la dirección con los contactos\?</string> <string name="share_with_contacts">Compartir con contactos</string> <string name="color_title">Título</string> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml index ef116dce8d..cee4837a75 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml @@ -82,7 +82,7 @@ <string name="info_row_connection">Yhteys</string> <string name="ttl_d">%dd</string> <string name="ttl_days">%d päivää</string> - <string name="disappearing_prohibited_in_this_chat">Tuhoutuvat viestit ovat kiellettyjä tässä keskustelussa.</string> + <string name="disappearing_prohibited_in_this_chat">Katoavat viestit ovat kiellettyjä tässä keskustelussa.</string> <string name="network_session_mode_user_description"><![CDATA[Erillistä TCP-yhteyttä (ja SOCKS-tunnistetietoja) käytetään <b>jokaisessa käyttämässäsi sovelluksen chat-profiilissa</b>.]]></string> <string name="app_version_code">Sovellusversio: %s</string> <string name="delete_address">Poista osoite</string> @@ -140,7 +140,7 @@ <string name="delete_verb">Poista</string> <string name="delete_message__question">Poista viesti\?</string> <string name="group_connection_pending">yhdistää…</string> - <string name="disappearing_message">Tuhoutuva viesti</string> + <string name="disappearing_message">Katoava viesti</string> <string name="send_disappearing_message_custom_time">Mukautettu aika</string> <string name="delete_contact_menu_action">Poista</string> <string name="delete_group_menu_action">Poista</string> @@ -158,7 +158,7 @@ <string name="settings_developer_tools">Kehittäjän työkalut</string> <string name="cannot_access_keychain">Ei pääsyä Keystoreen tietokannan salasanan tallentamiseksi</string> <string name="share_text_database_id">Tietokannan tunnus: %d</string> - <string name="info_row_disappears_at">Tuhoutuu klo</string> + <string name="info_row_disappears_at">Katoaa klo</string> <string name="chat_database_deleted">Keskustelujen tietokanta poistettu</string> <string name="delete_chat_profile_question">Poista keskusteluprofiili\?</string> <string name="delete_messages_after">Poista viestit tämän jälkeen</string> @@ -187,7 +187,7 @@ <string name="audio_call_no_encryption">äänipuhelu (ei e2e-salattu)</string> <string name="always_use_relay">Käytä aina relettä</string> <string name="allow_your_contacts_to_send_disappearing_messages">Salli kontaktiesi lähettää katoavia viestejä.</string> - <string name="timed_messages">Tuhoutuvat viestit</string> + <string name="timed_messages">Katoavat viestit</string> <string name="icon_descr_context">Kontekstikuvake</string> <string name="scan_QR_code_to_connect_to_contact_who_shows_QR_code"><![CDATA[<b>Skannaa QR-koodi</b>: muodostaaksesi yhteyden kontaktiisi, joka näyttää QR-koodin sinulle.]]></string> <string name="icon_descr_cancel_live_message">Peruuta live-viesti</string> @@ -199,7 +199,7 @@ <string name="settings_audio_video_calls">Ääni- ja videopuhelut</string> <string name="call_on_lock_screen">Puhelut lukitusnäytöllä:</string> <string name="conn_level_desc_direct">suora</string> - <string name="disappearing_messages_are_prohibited">Tuhoutuvat viestit ovat kiellettyjä tässä ryhmässä.</string> + <string name="disappearing_messages_are_prohibited">Katoavat viestit ovat kiellettyjä tässä ryhmässä.</string> <string name="server_connected">yhdistetty</string> <string name="display_name_connecting">yhdistää…</string> <string name="display_name_connection_established">yhteys luotu</string> @@ -240,7 +240,7 @@ <string name="both_you_and_your_contact_can_send_disappearing">Sekä sinä että kontaktisi voitte lähettää katoavia viestejä.</string> <string name="chat_preferences_contact_allows">Kontakti sallii</string> <string name="chat_preferences_default">oletus (%s)</string> - <string name="v4_4_disappearing_messages">Tuhoutuvat viestit</string> + <string name="v4_4_disappearing_messages">Katoavat viestit</string> <string name="copied">Kopioitu leikepöydälle</string> <string name="share_one_time_link">Luo kertaluonteinen kutsulinkki</string> <string name="mtr_error_no_down_migration">tietokantaversio on uudempi kuin sovellus, mutta ei alaspäin siirtymistä: %s</string> @@ -336,7 +336,7 @@ <string name="change_verb">Muuta</string> <string name="item_info_current">(nykyinen)</string> <string name="share_text_deleted_at">Poistettu: %s</string> - <string name="share_text_disappears_at">Tuhoutuu klo: %s</string> + <string name="share_text_disappears_at">Katoaa klo: %s</string> <string name="create_secret_group_title">Luo salainen ryhmä</string> <string name="chat_preferences_always">aina</string> <string name="cant_delete_user_profile">Käyttäjäprofiilia ei voi poistaa!</string> @@ -406,7 +406,7 @@ <string name="join_group_question">Liity ryhmään\?</string> <string name="network_option_enable_tcp_keep_alive">Ota TCP-säilytys käyttöön</string> <string name="incognito">Incognito</string> - <string name="incognito_info_protects">Incognito-tila suojaa pääprofiilisi nimen ja kuvan yksityisyyttä – jokaiselle uudelle yhteyshenkilölle luodaan uusi satunnainen profiili.</string> + <string name="incognito_info_protects">Incognito-tila suojaa yksityisyyttäsi käyttämällä uutta satunnaista profiilia jokaiselle kontaktille.</string> <string name="ttl_mth">%dmth</string> <string name="ttl_m">%dm</string> <string name="v4_6_group_welcome_message">Ryhmän tervetuloviesti</string> @@ -451,7 +451,7 @@ <string name="import_database_question">Tuo keskustelujen-tietokanta\?</string> <string name="file_with_path">Tiedosto: %s</string> <string name="error_removing_member">Virhe poistettaessa jäsentä</string> - <string name="message_deletion_prohibited">Viestien peruuttamaton poistaminen on kielletty tässä keskustelussa.</string> + <string name="message_deletion_prohibited">Viestien peruuttamaton poisto on kielletty tässä keskustelussa.</string> <string name="join_group_button">Liity</string> <string name="join_group_incognito_button">Liity incognito-tilassa</string> <string name="joining_group">Liittyy ryhmään</string> @@ -474,10 +474,10 @@ <string name="image_will_be_received_when_contact_is_online">Kuva vastaanotetaan, kun kontaktisi on verkossa, odota tai tarkista myöhemmin!</string> <string name="if_you_cant_meet_in_person">Jos et voi tavata henkilökohtaisesti, näytä QR-koodi videopuhelussa tai jaa linkki.</string> <string name="onboarding_notifications_mode_subtitle">Voit muuttaa sitä myöhemmin asetuksista.</string> - <string name="encrypt_database_question">Salataanko tietokanta\?</string> + <string name="encrypt_database_question">Salaa tietokanta\?</string> <string name="button_edit_group_profile">Muokkaa ryhmäprofiilia</string> <string name="delete_group_for_all_members_cannot_undo_warning">Ryhmä poistetaan kaikilta jäseniltä - tätä ei voi kumota!</string> - <string name="delete_group_for_self_cannot_undo_warning">Ryhmä poistetaan sinulta - tätä ei voi peruuttaa!</string> + <string name="delete_group_for_self_cannot_undo_warning">Ryhmä poistetaan sinulta - tätä ei voi perua!</string> <string name="error_creating_link_for_group">Virhe ryhmälinkin luomisessa</string> <string name="info_row_group">Ryhmä</string> <string name="incognito_info_allows">Se mahdollistaa useiden nimettömien yhteyksien muodostamisen yhdessä keskusteluprofiilissa ilman, että niiden välillä on jaettuja tietoja.</string> @@ -514,7 +514,7 @@ <string name="onboarding_notifications_mode_service">Välitön</string> <string name="encrypted_audio_call">e2e-salattu äänipuhelu</string> <string name="allow_accepting_calls_from_lock_screen">Ota puhelut käyttöön lukitusnäytöltä asetuksista.</string> - <string name="status_e2e_encrypted">e2e salattu</string> + <string name="status_e2e_encrypted">e2e-salattu</string> <string name="settings_section_title_incognito">Incognito-tila</string> <string name="error_with_info">Virhe: %s</string> <string name="alert_message_group_invitation_expired">Ryhmäkutsu ei ole enää voimassa, lähettäjä poisti sen.</string> @@ -543,15 +543,15 @@ <string name="downgrade_and_open_chat">Alenna ja avaa chat</string> <string name="icon_descr_group_inactive">Ei-aktiivinen ryhmä</string> <string name="v4_3_improved_privacy_and_security_desc">Piilota sovellusnäyttö viimeisimmissä sovelluksissa.</string> - <string name="v4_5_italian_interface">Italian käyttöliittymä</string> - <string name="v5_0_large_files_support_descr">Nopea ja ei odota, kunnes lähettäjä on online-tilassa!</string> + <string name="v4_5_italian_interface">Italialainen käyttöliittymä</string> + <string name="v5_0_large_files_support_descr">Nopea ja ei odotusta, kunnes lähettäjä on online-tilassa!</string> <string name="v4_6_reduced_battery_usage">Entisestä vähentynyt akun käyttö</string> <string name="v5_1_japanese_portuguese_interface">Japanin ja portugalin käyttöliittymä</string> <string name="error_saving_file">Virhe tiedoston tallentamisessa</string> <string name="icon_descr_help">apua</string> <string name="incorrect_code">Väärä turvakoodi!</string> <string name="error_sending_message">Virhe viestin lähettämisessä</string> - <string name="turn_off_battery_optimization"><![CDATA[Jotta voit käyttää sitä, <b>poista akun optimointi käytöstä</b> kohteelle SimpleX seuraavassa valintaikkunassa. Muussa tapauksessa ilmoitukset poistetaan käytöstä.]]></string> + <string name="turn_off_battery_optimization"><![CDATA[Käyttääksesi sitä, <b> salli SimpleX:n toimia taustalla </b> seuraavassa ikkunassa. Muutoin ilmoitukset poistetaan käytöstä.]]></string> <string name="notification_preview_mode_hidden">Piilotettu</string> <string name="la_immediately">Heti</string> <string name="la_enter_app_passcode">Syötä pääsykoodi</string> @@ -570,7 +570,7 @@ <string name="enable_lock">Ota lukitus käyttöön</string> <string name="group_invitation_item_description">kutsu ryhmään %1$s</string> <string name="icon_descr_add_members">Kutsu jäseniä</string> - <string name="group_invitation_expired">Ryhmäkutsu on vanhentunut</string> + <string name="group_invitation_expired">Vanhentunut ryhmäkutsu</string> <string name="rcv_group_event_member_added">kutsuttu %1$s</string> <string name="group_full_name_field">Ryhmän koko nimi:</string> <string name="full_name_optional__prompt">Koko nimi (valinnainen)</string> @@ -592,7 +592,7 @@ <string name="section_title_for_console">KONSOLIIN</string> <string name="group_member_status_group_deleted">poistettu ryhmä</string> <string name="snd_group_event_group_profile_updated">ryhmäprofiili päivitetty</string> - <string name="alert_title_group_invitation_expired">Kutsu on vanhentunut!</string> + <string name="alert_title_group_invitation_expired">Vanhentunut kutsu!</string> <string name="group_member_status_invited">kutsuttu</string> <string name="conn_level_desc_indirect">epäsuora (%1$s)</string> <string name="group_display_name_field">Ryhmän näyttönimi:</string> @@ -602,10 +602,10 @@ <string name="group_members_can_delete">Ryhmän jäsenet voivat poistaa lähetetyt viestit peruuttamattomasti.</string> <string name="group_members_can_send_dms">Ryhmän jäsenet voivat lähettää suoraviestejä.</string> <string name="group_members_can_send_voice">Ryhmän jäsenet voivat lähettää ääniviestejä.</string> - <string name="message_deletion_prohibited_in_chat">Viestien peruuttamaton poistaminen on kielletty tässä ryhmässä.</string> + <string name="message_deletion_prohibited_in_chat">Viestien peruuttamaton poisto on kielletty tässä ryhmässä.</string> <string name="ttl_months">%d kuukautta</string> <string name="ttl_sec">%d sek</string> - <string name="v5_1_message_reactions_descr">Vihdoinkin meillä on ne! 🚀</string> + <string name="v5_1_message_reactions_descr">Vihdoinkin meillä! 🚀</string> <string name="custom_time_unit_hours">tuntia</string> <string name="please_check_correct_link_and_maybe_ask_for_a_new_one">Tarkista, että käytit oikeaa linkkiä tai pyydä kontaktiasi lähettämään sinulle uusi linkki.</string> <string name="sender_may_have_deleted_the_connection_request">Lähettäjä on saattanut poistaa yhteyspyynnön.</string> @@ -619,7 +619,7 @@ <string name="notification_preview_mode_message">Viestin teksti</string> <string name="notification_preview_mode_contact_desc">Näytä vain kontakti</string> <string name="notification_preview_new_message">uusi viesti</string> - <string name="la_notice_title_simplex_lock">Simplex Lock</string> + <string name="la_notice_title_simplex_lock">SimpleX Lock</string> <string name="auth_simplex_lock_turned_on">SimpleX Lock päällä</string> <string name="auth_open_chat_console">Avaa keskustelukonsoli</string> <string name="message_delivery_error_title">Viestin toimitusvirhe</string> @@ -848,7 +848,7 @@ <string name="read_more_in_github_with_link"><![CDATA[Lue lisää <font color="#0088ff">GitHub-arkistostamme</font>.]]></string> <string name="onboarding_notifications_mode_periodic">Säännölliset</string> <string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages"><![CDATA[Vain asiakaslaitteet tallentavat käyttäjäprofiileja, yhteystietoja, ryhmiä ja viestejä, jotka on lähetetty <b>2-kerroksisella päästä päähän -salauksella</b>.]]></string> - <string name="read_more_in_github">Lue lisää GitHub-arkistostamme.</string> + <string name="read_more_in_github">Lue lisää GitHub-tietovarastostamme.</string> <string name="paste_the_link_you_received">Liitä vastaanotettu linkki</string> <string name="relay_server_protects_ip">Välityspalvelin suojaa IP-osoitteesi, mutta se voi tarkkailla puhelun kestoa.</string> <string name="open_simplex_chat_to_accept_call">Avaa SimpleX Chat hyväksyäksesi puhelun</string> @@ -1007,7 +1007,7 @@ <string name="prohibit_message_reactions">Estä viestireaktiot.</string> <string name="prohibit_sending_disappearing_messages">Estä katoavien viestien lähettäminen.</string> <string name="accept_feature_set_1_day">Aseta 1 päivä</string> - <string name="only_your_contact_can_make_calls">Vain yhteyshenkilösi voi soittaa puheluita.</string> + <string name="only_your_contact_can_make_calls">Vain kontaktisi voi soittaa puheluita.</string> <string name="prohibit_calls">Estä ääni- ja videopuhelut.</string> <string name="prohibit_direct_messages">Estä suorien viestien lähettäminen jäsenille.</string> <string name="message_reactions_prohibited_in_this_chat">Viestireaktiot ovat kiellettyjä tässä keskustelussa.</string> @@ -1043,7 +1043,7 @@ <string name="xftp_servers">XFTP-palvelimet</string> <string name="smp_servers_your_server">Palvelimesi</string> <string name="smp_servers_your_server_address">Palvelimesi osoite</string> - <string name="enable_automatic_deletion_message">Tätä toimintoa ei voi kumota - valittua aikaisemmin lähetetyt ja vastaanotetut viestit poistetaan. Se voi kestää useita minuutteja.</string> + <string name="enable_automatic_deletion_message">Tätä toimintoa ei voi kumota - valittua aikaisemmin lähetetyt ja vastaanotetut viestit poistetaan. Tämä voi kestää useita minuutteja.</string> <string name="whats_new">Uusimmat</string> <string name="you_will_still_receive_calls_and_ntfs">Saat edelleen puheluita ja ilmoituksia mykistetyiltä profiileilta, kun ne ovat aktiivisia.</string> <string name="network_disable_socks">Käytä suoraa Internet-yhteyttä\?</string> @@ -1199,7 +1199,7 @@ <string name="videos_limit_title">Liikaa videoita!</string> <string name="voice_message">Ääniviesti</string> <string name="waiting_for_video">Odottaa videota</string> - <string name="switch_receiving_address_desc">Tämä ominaisuus on kokeellinen! Se toimii vain, jos toisella on asennettuna versio 4.2. Sinun pitäisi nähdä viesti keskustelussa, kun osoitteenmuutos on valmis - tarkista, että voit edelleen vastaanottaa viestejä kyseiseltä kontaktilta (tai ryhmän jäseneltä).</string> + <string name="switch_receiving_address_desc">Vastaanotto-osoite vaihdetaan toiseen palvelimeen. Osoitteenmuutos tehdään sen jälkeen, kun lähettäjä tulee verkkoon.</string> <string name="you_need_to_allow_to_send_voice">Sinun on sallittava kontaktiesi lähettää ääniviestejä, jotta voit lähettää niitä.</string> <string name="you_can_connect_to_simplex_chat_founder"><![CDATA[Voit <font color="#0088ff">olla yhteydessä SimpleX Chatin -kehittäjiin kysyäksesi kysymyksiä ja saadaksesi päivityksiä</font>.]]></string> <string name="this_QR_code_is_not_a_link">Tämä QR-koodi ei ole linkki!</string> @@ -1260,4 +1260,118 @@ <string name="settings_restart_app">Käynnistä uudelleen</string> <string name="shutdown_alert_question">Sulje\?</string> <string name="la_mode_off">Pois</string> + <string name="v5_2_message_delivery_receipts">Viestien toimituskuittaukset!</string> + <string name="v5_2_more_things_descr">- vakaampi viestien toimitus. +\n- hieman paremmat ryhmät. +\n- ja paljon muuta!</string> + <string name="delivery_receipts_are_disabled">Toimituskuittaukset poissa käytöstä!</string> + <string name="you_can_enable_delivery_receipts_later">Voit ottaa käyttöön myöhemmin asetusten kautta</string> + <string name="you_can_enable_delivery_receipts_later_alert">Voit ottaa ne käyttöön myöhemmin sovelluksen Yksityisyys & Turvallisuus -asetuksista.</string> + <string name="error_aborting_address_change">Virhe osoitteenmuutoksen keskeytyksessä</string> + <string name="abort_switch_receiving_address">Keskeytä osoitteenvaihto</string> + <string name="network_option_protocol_timeout_per_kb">Protokollan aikakatkaisu per KB</string> + <string name="files_are_prohibited_in_group">Tiedostot ja media ovat tässä ryhmässä kiellettyjä.</string> + <string name="group_members_can_send_files">Ryhmän jäsenet voivat lähettää tiedostoja ja mediaa.</string> + <string name="connect_via_link_incognito">Yhdistä Incognito</string> + <string name="connect_use_current_profile">Käytä nykyistä profiilia</string> + <string name="connect_use_new_incognito_profile">Käytä uutta incognito-profiilia</string> + <string name="turn_off_battery_optimization_button">Salli</string> + <string name="disable_notifications_button">Poista ilmoitukset käytöstä</string> + <string name="turn_off_system_restriction_button">Avaa asetukset</string> + <string name="system_restricted_background_desc">SimpleX ei toimi tausta-ajossa. Saat ilmoitukset ainostaan, kun sovellus on käynnissä.</string> + <string name="system_restricted_background_warn"><![CDATA[Ilmoitusten sallimiseksi valitse <b> Sovelluksen akun käyttö </b> / <b> rajoittamaton </b> sovellusasetuksista.]]></string> + <string name="system_restricted_background_in_call_title">Ei taustapuheluita</string> + <string name="system_restricted_background_in_call_desc">Sovellus voi sulkeutua 1 minuutin jälkeen tausta-ajossa.</string> + <string name="system_restricted_background_in_call_warn"><![CDATA[Puheluiden soittamiseksi taustalla, valitse <b>Sovelluksen akun käyttö </b> / <b> rajoittamaton </b> sovellusasetuksista.]]></string> + <string name="in_reply_to">Vastauksena</string> + <string name="abort_switch_receiving_address_question">Keskeytä osoitteenvaihto\?</string> + <string name="favorite_chat">Suosikki</string> + <string name="unfavorite_chat">Epäsuosikki</string> + <string name="connect__a_new_random_profile_will_be_shared">Uusi satunnainen profiili jaetaan.</string> + <string name="paste_the_link_you_received_to_connect_with_your_contact">Liitä linkki, jonka sait yhteydenottoon kontaktisi kanssa…</string> + <string name="connect__your_profile_will_be_shared">Profiilisi %1$s jaetaan.</string> + <string name="receipts_groups_title_disable">Kuittaukset pois käytöstä ryhmiltä\?</string> + <string name="in_developing_title">Tulossa pian!</string> + <string name="receipts_groups_disable_for_all">Poista käytöstä kaikilta ryhmiltä</string> + <string name="files_and_media">Tiedostot ja media</string> + <string name="receipts_groups_enable_keep_overrides">Salli (pidä ryhmäohitukset)</string> + <string name="sending_delivery_receipts_will_be_enabled_all_profiles">"Toimituskuittauksien lähettäminen otetaan käyttöön kaikille kontakteille näkyvissä keskusteluprofiileissa."</string> + <string name="receipts_groups_override_enabled">Kuittauksien lähettäminen on käytössä %d ryhmille</string> + <string name="abort_switch_receiving_address_confirm">Keskeytä</string> + <string name="sync_connection_force_confirm">Uudelleenneuvottele</string> + <string name="sync_connection_force_question">Uudelleenneuvottele salaus\?</string> + <string name="sync_connection_force_desc">Salaus toimii ja uutta salaussopimusta ei tarvita. Tämä voi johtaa yhteysvirheisiin!</string> + <string name="receipts_section_description">Nämä asetukset koskevat nykyistä profiiliasi</string> + <string name="receipts_section_description_1">Ne voidaan ohittaa kontakti- ja ryhmäasetuksissa.</string> + <string name="conn_event_ratchet_sync_ok">salaus ok</string> + <string name="conn_event_ratchet_sync_allowed">salauksen uudelleenneuvottelu sallittu</string> + <string name="fix_connection_confirm">Korjaa</string> + <string name="fix_connection_not_supported_by_contact">Kontakti ei tue korjausta</string> + <string name="fix_connection_not_supported_by_group_member">Ryhmän jäsen ei tue korjausta</string> + <string name="renegotiate_encryption">Uudelleenneuvottele salaus</string> + <string name="in_developing_desc">Tätä ominaisuutta ei vielä tueta. Kokeile seuraavaa versiota.</string> + <string name="delivery">Toimitus</string> + <string name="no_info_on_delivery">Ei toimitustietoja</string> + <string name="no_filtered_chats">Ei suodatettuja keskusteluja</string> + <string name="no_selected_chat">Ei valittua keskustelua</string> + <string name="only_owners_can_enable_files_and_media">Vain ryhmän omistajat voivat sallia tiedostoja ja mediaa.</string> + <string name="files_and_media_prohibited">Tiedostot ja media kielletty!</string> + <string name="abort_switch_receiving_address_desc">Osoitteenmuutos keskeytetään. Käytetään vanhaa vastaanotto-osoitetta.</string> + <string name="choose_file_title">Valitse tiedosto</string> + <string name="receipts_section_groups">Pienryhmät (max 20)</string> + <string name="send_receipts_disabled_alert_title">Kuittaukset pois käytöstä</string> + <string name="send_receipts_disabled_alert_msg">Ryhmällä on yli %1$d jäsentä, toimituskuittauksia ei lähetetä.</string> + <string name="recipient_colon_delivery_status">%s: %s</string> + <string name="v5_2_more_things">Muutama asia lisää</string> + <string name="v5_2_fix_encryption">Pidä kontaktisi</string> + <string name="receipts_section_contacts">Kontaktit</string> + <string name="receipts_contacts_disable_for_all">Poista käytöstä kaikilta</string> + <string name="receipts_contacts_enable_for_all">Salli kaikille</string> + <string name="receipts_contacts_enable_keep_overrides">Salli (pidä ohitukset)</string> + <string name="receipts_contacts_disable_keep_overrides">Poista käytöstä (pidä ohitukset)</string> + <string name="receipts_contacts_title_disable">Kuittaukset pois käytöstä\?</string> + <string name="receipts_contacts_title_enable">Salli kuittaukset\?</string> + <string name="receipts_contacts_override_disabled">Kuittauksien lähettäminen on pois käytöstä %d kontakteilta</string> + <string name="receipts_contacts_override_enabled">Kuittauksien lähettäminen on käytössä %d kontakteille</string> + <string name="settings_section_title_delivery_receipts">LÄHETÄ TOIMITUSKUITTAUKSET VASTAANOTTAJALLE</string> + <string name="rcv_conn_event_verification_code_reset">turvakoodi on muuttunut</string> + <string name="conn_event_ratchet_sync_started">hyväksyy salausta…</string> + <string name="snd_conn_event_ratchet_sync_allowed">salauksen uudelleenneuvottelu sallittu %s:lle</string> + <string name="conn_event_ratchet_sync_required">tarvitaan salauksen uudelleenneuvottelua</string> + <string name="snd_conn_event_ratchet_sync_started">hyväksyy salausta %s:lle…</string> + <string name="conn_event_ratchet_sync_agreed">salaus sovittu</string> + <string name="snd_conn_event_ratchet_sync_agreed">salaus sovittu %s:lle</string> + <string name="snd_conn_event_ratchet_sync_ok">salaus ok %s:lle</string> + <string name="snd_conn_event_ratchet_sync_required">tarvitaan salauksen uudelleenneuvottelua %s:lle</string> + <string name="sender_at_ts">"%s klo %s"</string> + <string name="send_receipts">Lähetä kuittaukset</string> + <string name="fix_connection">Korjaa yhteys</string> + <string name="fix_connection_question">Korjaa yhteys\?</string> + <string name="allow_to_send_files">Salli tiedostojen ja median lähettäminen.</string> + <string name="prohibit_sending_files">Estä tiedostojen ja median lähettäminen.</string> + <string name="v5_2_disappear_one_message">Hävitä yksi viesti</string> + <string name="v5_2_fix_encryption_descr">Korjaa salaus varmuuskopioiden palauttamisen jälkeen.</string> + <string name="v5_2_disappear_one_message_descr">Jopa kun ei käytössä keskustelussa.</string> + <string name="receipts_groups_disable_keep_overrides">Poista käytöstä (pidä ryhmäohitukset)</string> + <string name="receipts_groups_enable_for_all">Salli kaikille ryhmille</string> + <string name="receipts_groups_title_enable">Salli kuittaukset ryhmille\?</string> + <string name="privacy_message_draft">Viestiluonnos</string> + <string name="receipts_groups_override_disabled">Kuittauksien lähettäminen on pois käytöstä %d ryhmiltä</string> + <string name="privacy_show_last_messages">Näytä viimeiset viestit</string> + <string name="send_receipts_disabled">ei käytössä</string> + <string name="rcv_group_event_2_members_connected">%s ja %s yhdistetty</string> + <string name="rcv_group_event_n_members_connected">%s, %s ja %d muut jäsenet yhdistetty</string> + <string name="rcv_group_event_3_members_connected">%s, %s ja %s yhdistetty</string> + <string name="dont_enable_receipts">Älä salli</string> + <string name="error_enabling_delivery_receipts">Virhe toimituskuittauksien sallimisessa!</string> + <string name="connect_via_member_address_alert_title">Yhdistä suoraan\?</string> + <string name="connect_via_member_address_alert_desc">Yhteyspyyntö lähetetään tälle ryhmän jäsenelle.</string> + <string name="delivery_receipts_title">Toimituskuittaukset!</string> + <string name="enable_receipts_all">Salli</string> + <string name="v5_2_favourites_filter_descr">Suodata lukemattomia- ja suosikkikeskusteluja.</string> + <string name="v5_2_favourites_filter">Löydä keskustelut nopeammin</string> + <string name="sending_delivery_receipts_will_be_enabled">Toimituskuittauksien lähettäminen otetaan käyttöön kaikille kontakteille.</string> + <string name="v5_2_message_delivery_receipts_descr">Toinen kuittaus, joka uupui! ✅</string> + <string name="error_synchronizing_connection">Virhe yhteyden synkronoinnissa</string> + <string name="no_history">Ei historiaa</string> </resources> \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index 562d51acf3..1611b413f0 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -1316,7 +1316,7 @@ <string name="receipts_contacts_override_enabled">L\'envoi d\'accusés de réception est activé pour les contacts de %d</string> <string name="conn_event_ratchet_sync_started">accord sur le chiffrement…</string> <string name="conn_event_ratchet_sync_agreed">chiffrement accepté</string> - <string name="conn_event_ratchet_sync_ok">chiffrement ok</string> + <string name="conn_event_ratchet_sync_ok">chiffrement OK</string> <string name="conn_event_ratchet_sync_allowed">renégociation de chiffrement autorisée</string> <string name="conn_event_ratchet_sync_required">renégociation de chiffrement requise</string> <string name="snd_conn_event_ratchet_sync_ok">chiffrement ok pour %s</string> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open.svg deleted file mode 100644 index bf6b7b47b4..0000000000 --- a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 96 960 960" width="24"><path d="M222 971q-23.719 0-40.609-16.891Q164.5 937.219 164.5 913.5v-431q0-23.719 16.891-40.609Q198.281 425 222 425h387v-95.385q0-53.782-37.373-91.198Q534.254 201 479.863 201q-46.363 0-81.363 28T354 300.5q-3 13-11.75 21.25T321.983 330q-12.311 0-20.397-8.5-8.086-8.5-6.086-20 10-68 61.902-113t122.629-45q77.383 0 131.926 54.551Q666.5 252.603 666.5 330v95H738q23.719 0 40.609 16.891Q795.5 458.781 795.5 482.5v431q0 23.719-16.891 40.609Q761.719 971 738 971H222Zm0-57.5h516v-431H222v431Zm258.084-140q31.179 0 53.297-21.566 22.119-21.566 22.119-51.85 0-29.347-22.203-53.465-22.203-24.119-53.381-24.119-31.179 0-53.297 24.035-22.119 24.034-22.119 53.881t22.203 51.465q22.203 21.619 53.381 21.619ZM222 482.5v431-431Z"/></svg> \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open_right.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open_right.svg new file mode 100644 index 0000000000..3188cf798e --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open_right.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M222-142.5h516v-431H222v431Zm258.084-140q31.179 0 53.297-21.566 22.119-21.566 22.119-51.85 0-29.347-22.203-53.465-22.203-24.119-53.381-24.119-31.179 0-53.297 24.035-22.119 24.034-22.119 53.881t22.203 51.465q22.203 21.619 53.381 21.619ZM222-142.5v-431 431Zm0 57.5q-23.719 0-40.609-16.891Q164.5-118.781 164.5-142.5v-431q0-23.719 16.891-40.609Q198.281-631 222-631h329.5v-95.018q0-77.832 54.349-132.157Q660.198-912.5 738-912.5q70 0 121.25 44T922-759q2 11.5-6.638 22.25T895.75-726q-12.66 0-20.705-6-8.045-6-9.545-18.5-9-44.5-44.55-74.5T738-855q-54.333 0-91.667 37.333Q609-780.333 609-726.231V-631h129q23.719 0 40.609 16.891Q795.5-597.219 795.5-573.5v431q0 23.719-16.891 40.609Q761.719-85 738-85H222Z"/></svg> \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml index 2367ae81a9..8467199bf4 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -689,7 +689,7 @@ <string name="old_database_archive">ארכיון מסד נתונים ישן</string> <string name="chat_item_ttl_none">לעולם לא</string> <string name="no_received_app_files">לא התקבלו או נשלחו קבצים</string> - <string name="new_member_role">תפקיד חבר קבוצה</string> + <string name="new_member_role">תפקיד חבר קבוצה חדש</string> <string name="no_contacts_selected">לא נבחרו אנשי קשר</string> <string name="member_info_section_title_member">חבר קבוצה</string> <string name="member_will_be_removed_from_group_cannot_be_undone">חבר הקבוצה יוסר מהקבוצה – לא ניתן לבטל זאת!</string> @@ -1257,7 +1257,7 @@ <string name="your_preferences">ההעדפות שלך</string> <string name="you_will_still_receive_calls_and_ntfs">עדיין תקבלו שיחות והתראות מפרופילים מושתקים כאשר הם פעילים.</string> <string name="abort_switch_receiving_address_confirm">בטל</string> - <string name="abort_switch_receiving_address_question">בטל שינוי כתובת\?</string> + <string name="abort_switch_receiving_address_question">האם לבטל שינוי כתובת\?</string> <string name="abort_switch_receiving_address_desc">שינוי הכתובת יבוטל. ייעשה שימוש בכתובת הקבלה הישנה.</string> <string name="shutdown_alert_desc">ההתראות יפסיקו לפעול עד שתפעיל את האפליקציה מחדש</string> <string name="abort_switch_receiving_address">בטל שינוי כתובת</string> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml index ca09056520..b82f10b255 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -167,7 +167,6 @@ <string name="icon_descr_call_ended">通話が終了しました。</string> <string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link"><![CDATA[直接会えない時は、 <b>ビデオ通話中にQRコードを見せてもらうか</b>、招待リンクを送ってもらえば相手に繋がります。]]></string> <string name="member_will_be_removed_from_group_cannot_be_undone">メンバーをグループから除名する (※元に戻せません※)!</string> - <string name="message_delivery_error_title">メッセージ送信エラー</string> <string name="call_on_lock_screen">通話をロック画面に表示</string> <string name="icon_descr_cancel_link_preview">リンクのプレビューを中止</string> <string name="icon_descr_cancel_live_message">ライブメッセージを中止</string> @@ -485,7 +484,7 @@ <string name="smp_server_test_create_queue">サーバの待ち行列を作成する</string> <string name="smp_server_test_delete_queue">待ち行列を削除</string> <string name="smp_server_test_disconnect">切断</string> - <string name="turn_off_battery_optimization"><![CDATA[利用するには次の画面にてSimpleXに対する <b>電気省電力の設定をオフ</b> for SimpleX してください。そうしないと通知が無効になります。]]></string> + <string name="turn_off_battery_optimization"><![CDATA[利用するには次の画面にてSimpleXに対する <b>SimpleX のバックグラウンドでの実行を許可</b> してください。そうしないと通知が無効になります。]]></string> <string name="database_initialization_error_title">データベースを起動できません。</string> <string name="settings_notification_preview_title">通知のプレビュー</string> <string name="simplex_service_notification_text">メッセージ受信中…</string> @@ -588,7 +587,7 @@ <string name="error_removing_member">メンバー除名にエラー発生</string> <string name="conn_level_desc_indirect">関節 (%1$s)</string> <string name="incognito">シークレットモード</string> - <string name="incognito_info_protects">シークレットモードとは、メインのプロフィールとプロフィール画像を守るために、新しい連絡先を追加する時に、その連絡先に対してランダムなプロフィールが作成されるという対策です。</string> + <string name="incognito_info_protects">シークレット モードでは、連絡先ごとに新しいランダムなプロファイルを使用してプライバシーを保護します。</string> <string name="chat_preferences_no">いいえ</string> <string name="chat_preferences_on">オン</string> <string name="direct_messages">ダイレクトメッセージ</string> @@ -904,7 +903,7 @@ <string name="simplex_link_mode">SimpleXリンク</string> <string name="settings_section_title_support">SIMPLEX CHATを支援</string> <string name="smp_servers_test_servers">テストサーバ</string> - <string name="switch_receiving_address_desc">開発中の機能です!相手のクライアントが4.2でなければ機能しません。アドレス変更が完了すると、会話にメッセージが出ます。連絡相手 (またはグループのメンバー) からメッセージを受信できないかをご確認ください。</string> + <string name="switch_receiving_address_desc">受信アドレスは別のサーバーに変更されます。アドレス変更は送信者がオンラインになった後に完了します。</string> <string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery"><![CDATA[あなたのプライバシーを守るために、このアプリはプッシュ通知の変わりに <b>SimpleX バックグラウンド・サービス</b> を使ってます。一日の電池使用量は約3%です。]]></string> <string name="to_protect_privacy_simplex_has_ids_for_queues">あなたのプライバシーを守るために、他のアプリと違って、ユーザーIDの変わりに SimpleX メッセージ束毎にIDを配布し、各連絡先が別々と扱います。</string> <string name="group_main_profile_sent">あなたのチャットプロフィールが他のグループメンバーに送られます。</string> @@ -1258,9 +1257,9 @@ <string name="choose_file_title">ファイルを選択</string> <string name="unfavorite_chat">お気に入りを解除</string> <string name="favorite_chat">お気に入り</string> - <string name="receipts_contacts_override_enabled">連絡先 %d に対して既読の通知が有効です</string> + <string name="receipts_contacts_override_enabled">Sending receipts is enabled for %d contacts</string> <string name="receipts_contacts_enable_for_all">すべて有効</string> - <string name="receipts_contacts_override_disabled">連絡先 %d に対して既読の通知が無効です</string> + <string name="receipts_contacts_override_disabled">Sending receipts is disabled for %d contacts</string> <string name="receipts_contacts_disable_for_all">すべて無効</string> <string name="settings_shutdown">終了</string> <string name="conn_event_ratchet_sync_started">暗号化に同意しています…</string> @@ -1289,7 +1288,7 @@ <string name="error_aborting_address_change">アドレス変更中止エラー</string> <string name="no_filtered_chats">フィルタされたチャットはありません</string> <string name="files_and_media_prohibited">ファイルとメディアは禁止されています!</string> - <string name="abort_switch_receiving_address_desc">住所変更は中止されます。古い受信アドレスが使用されます。</string> + <string name="abort_switch_receiving_address_desc">アドレス変更は中止されます。古い受信アドレスが使用されます。</string> <string name="abort_switch_receiving_address_question">アドレス変更を中止しますか?</string> <string name="settings_section_title_app">アプリ</string> <string name="non_fatal_errors_occured_during_import">インポート中に致命的でないエラーが発生しました - 詳細はチャットコンソールを参照してください。</string> @@ -1304,18 +1303,17 @@ <string name="v5_2_fix_encryption_descr">バックアップの復元後に暗号化を修正します。</string> <string name="snd_conn_event_ratchet_sync_started">暗号化に同意しています: %s</string> <string name="conn_event_ratchet_sync_agreed">暗号化に同意しました</string> - <string name="receipts_contacts_title_disable">既読通知を無効にしますか?</string> + <string name="receipts_contacts_title_disable">Disable receipts\?</string> <string name="fix_connection_not_supported_by_contact">連絡先による修正はサポートされていません</string> <string name="fix_connection_not_supported_by_group_member">グループメンバーによる修正はサポートされていません</string> <string name="receipts_section_contacts">連絡先</string> - <string name="receipts_section_description_1">これらは連絡先の設定が優先します</string> + <string name="receipts_section_description_1">これらは連絡先とグループの設定が優先されます。</string> <string name="receipts_section_description">これらの設定は現在のプロファイル用です</string> - <string name="receipts_contacts_title_enable">既読通知を有効にしますか?</string> + <string name="receipts_contacts_title_enable">Enable receipts\?</string> <string name="sender_at_ts">%s : %s</string> <string name="fix_connection">接続を修正</string> <string name="files_and_media">ファイルとメディア</string> <string name="you_can_enable_delivery_receipts_later_alert">あとでアプリのプライバシーとセキュリティの設定から有効にすることができます。</string> - <string name="error_enabling_delivery_receipts">既読通知の有効化でエラーが発生しました!</string> <string name="item_info_no_text">テキストなし</string> <string name="error_synchronizing_connection">接続の同期エラー</string> <string name="no_history">履歴はありません</string> @@ -1326,4 +1324,56 @@ <string name="only_owners_can_enable_files_and_media">ファイルやメディアを有効にできるのは、グループオーナーだけです。</string> <string name="sync_connection_force_confirm">再ネゴシエート</string> <string name="settings_restart_app">再起動</string> + <string name="connect_via_link_incognito">シークレットモードで接続</string> + <string name="turn_off_battery_optimization_button">許可</string> + <string name="connect_via_member_address_alert_title">直接接続しますか\?</string> + <string name="connect__a_new_random_profile_will_be_shared">新しいランダムなプロファイルが共有されます。</string> + <string name="delivery">送信</string> + <string name="in_developing_title">近日公開!</string> + <string name="v5_2_disappear_one_message">メッセージを1つ消す</string> + <string name="connect_use_current_profile">現在のプロファイルを使用する</string> + <string name="connect_use_new_incognito_profile">新しいシークレットプロファイルを使用する</string> + <string name="turn_off_system_restriction_button">アプリの設定を開く</string> + <string name="disable_notifications_button">通知を無効にする</string> + <string name="system_restricted_background_warn"><![CDATA[通知を有効にするには、アプリの設定で<b>アプリのバッテリー使用量</b> / <b>制限なし</b> を選択してください。]]></string> + <string name="system_restricted_background_in_call_desc">アプリはバックグラウンドで1分経過すると終了します。</string> + <string name="system_restricted_background_in_call_warn"><![CDATA[バックグラウンドで通話を行うには、アプリの設定で<b>アプリのバッテリー使用量</b> / <b>制限なし</b> を選択してください。]]></string> + <string name="connect__your_profile_will_be_shared">あなたのプロフィール %1$s が共有されます。</string> + <string name="receipts_groups_title_enable">Enable receipts for groups\?</string> + <string name="receipts_groups_title_disable">Disable receipts for groups\?</string> + <string name="receipts_groups_override_enabled">Sending receipts is enabled for %d groups</string> + <string name="receipts_groups_override_disabled">Sending receipts is disabled for %d groups</string> + <string name="send_receipts_disabled">無効</string> + <string name="system_restricted_background_in_call_title">バックグラウンド通話なし</string> + <string name="paste_the_link_you_received_to_connect_with_your_contact">受信したリンクを貼り付け、連絡先に接続する。</string> + <string name="rcv_group_event_2_members_connected">%s と %s は接続中</string> + <string name="system_restricted_background_desc">SimpleXはバックグラウンドでは動作できません。アプリが起動している時のみ通知を受け取ることができます。</string> + <string name="no_info_on_delivery">送信情報なし</string> + <string name="no_selected_chat">チャットが選択されていません</string> + <string name="receipts_section_groups">小グループ(最大20名)</string> + <string name="receipts_groups_enable_for_all">すべてのグループで有効にする</string> + <string name="recipient_colon_delivery_status">%s: %s</string> + <string name="connect_via_member_address_alert_desc">接続リクエストはこのグループ メンバーに送信されます。</string> + <string name="receipts_groups_disable_for_all">すべてのグループで無効にする</string> + <string name="privacy_message_draft">メッセージの下書き</string> + <string name="privacy_show_last_messages">最新のメッセージを表示</string> + <string name="send_receipts">Send receipts</string> + <string name="rcv_group_event_n_members_connected">%s, %s および %d 人の他のメンバーが接続しています。</string> + <string name="rcv_group_event_3_members_connected">%s, %s と %s は接続中</string> + <string name="in_developing_desc">この機能はまだサポートされていません。次のリリースをお試しください。</string> + <string name="receipts_contacts_enable_keep_overrides">有効にする(設定の優先を維持)</string> + <string name="receipts_groups_disable_keep_overrides">無効にする(グループの設定の優先を維持)</string> + <string name="receipts_groups_enable_keep_overrides">有効にする(グループの設定の優先を維持)</string> + <string name="receipts_contacts_disable_keep_overrides">無効にする(設定の優先を維持)</string> + <string name="v5_2_disappear_one_message_descr">会話中に無効になっている場合でも。</string> + <string name="v5_2_message_delivery_receipts">Message delivery receipts!</string> + <string name="delivery_receipts_title">Delivery receipts!</string> + <string name="sending_delivery_receipts_will_be_enabled_all_profiles">Sending delivery receipts will be enabled for all contacts in all visible chat profiles.</string> + <string name="message_delivery_error_title">Message delivery error</string> + <string name="send_receipts_disabled_alert_title">Receipts are disabled</string> + <string name="settings_section_title_delivery_receipts">SEND DELIVERY RECEIPTS TO</string> + <string name="sending_delivery_receipts_will_be_enabled">Sending delivery receipts will be enabled for all contacts.</string> + <string name="send_receipts_disabled_alert_msg">This group has over %1$d members, delivery receipts are not sent.</string> + <string name="error_enabling_delivery_receipts">Error enabling delivery receipts!</string> + <string name="delivery_receipts_are_disabled">Delivery receipts are disabled!</string> </resources> \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index 791a2ff64c..e69ad19fb0 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -1086,13 +1086,13 @@ <string name="decryption_error">Decodering fout</string> <string name="alert_text_msg_bad_hash">De hash van het vorige bericht is anders.</string> <string name="alert_text_decryption_error_too_many_skipped">%1$d berichten overgeslagen.</string> - <string name="alert_text_fragment_encryption_out_of_sync_old_database">Het kan gebeuren wanneer u of uw verbinding de oude databaseback-up gebruikte.</string> + <string name="alert_text_fragment_encryption_out_of_sync_old_database">Het kan gebeuren wanneer u of de ander een oude databaseback-up gebruikt.</string> <string name="alert_text_fragment_please_report_to_developers">Meld het alsjeblieft aan de ontwikkelaars.</string> <string name="alert_title_msg_bad_hash">Onjuiste bericht hash</string> <string name="alert_title_msg_bad_id">Onjuiste bericht-ID</string> <string name="alert_text_msg_bad_id">De ID van het volgende bericht is onjuist (minder of gelijk aan het vorige). \nHet kan gebeuren vanwege een bug of wanneer de verbinding is aangetast.</string> - <string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d-berichten konden niet worden ontsleuteld.</string> + <string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d berichten konden niet worden ontsleuteld.</string> <string name="no_spaces">Geen spaties!</string> <string name="stop_rcv_file__message">Het ontvangen van het bestand wordt gestopt.</string> <string name="revoke_file__confirm">Intrekken</string> @@ -1369,7 +1369,7 @@ <string name="connect_via_member_address_alert_desc">Verzoek voor het verbinden wordt naar dit groepslid verzonden.</string> <string name="paste_the_link_you_received_to_connect_with_your_contact">Plak de link die je hebt ontvangen om verbinding te maken met je contact…</string> <string name="system_restricted_background_warn"><![CDATA[Als u meldingen wilt inschakelen, kiest u <b> App-batterijgebruik</b> /<b> Onbeperkt</b> in de app-instellingen.]]></string> - <string name="connect_use_new_incognito_profile">Gebruik een nieuw incognito -profiel</string> + <string name="connect_use_new_incognito_profile">Gebruik een nieuw incognito profiel</string> <string name="privacy_message_draft">Concept bericht</string> <string name="rcv_group_event_n_members_connected">%s, %s en %d andere leden verbonden</string> <string name="privacy_show_last_messages">Laat laatste berichten zien</string> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml index 2b735f5944..07836dc98a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml @@ -414,7 +414,7 @@ <string name="initial_member_role">Função inicial</string> <string name="snd_group_event_group_profile_updated">perfil do grupo atualizado</string> <string name="group_member_status_group_deleted">Grupo excluído</string> - <string name="incognito_info_protects">O modo de navegação anônima protege a privacidade do nome e da imagem do seu perfil principal — para cada novo contato, um novo perfil aleatório é criado.</string> + <string name="incognito_info_protects">O modo Incognito protege sua privacidade usando um novo perfil aleatório para cada contato.</string> <string name="group_members_can_delete">Os membros do grupo podem excluir mensagens enviadas de forma irreversível.</string> <string name="ttl_w">%dsemana</string> <string name="v4_3_improved_server_configuration">Configuração de servidor aprimorada</string> @@ -631,7 +631,7 @@ <string name="images_limit_desc">Apenas 10 imagens podem ser enviadas ao mesmo tempo</string> <string name="notifications">Notificações</string> <string name="text_field_set_contact_placeholder">Definir nome do contato…</string> - <string name="switch_receiving_address_desc">Esse recurso é experimental! Ele só funcionará se o outro cliente tiver a versão 4.2 instalada. Você deve ver a mensagem na conversa assim que a alteração de endereço for concluída – verifique se você ainda pode receber mensagens desse contato (ou membro do grupo).</string> + <string name="switch_receiving_address_desc">O endereço de recebimento será alterado para um servidor diferente. A mudança de endereço terminará após o remetente entrar on-line.</string> <string name="send_verb">Enviar</string> <string name="reset_verb">Redefinir</string> <string name="live_message">Mensagem ao vivo!</string> @@ -1327,4 +1327,28 @@ <string name="enable_receipts_all">Ativar</string> <string name="sending_delivery_receipts_will_be_enabled">Enviar confirmações de entrega serão ativadas para todos os contatos.</string> <string name="error_enabling_delivery_receipts">Ocorreu um erro ao ativar as confirmações de entrega!</string> + <string name="choose_file_title">Escolher arquivo</string> + <string name="connect_via_link_incognito">Conectar incógnito</string> + <string name="turn_off_battery_optimization_button">Permitir</string> + <string name="disable_notifications_button">Desativar notificações</string> + <string name="system_restricted_background_in_call_title">Sem chamadas de fundo</string> + <string name="turn_off_system_restriction_button">Abrir configurações do aplicativo</string> + <string name="connect__a_new_random_profile_will_be_shared">Um novo perfil aleatório será compartilhado.</string> + <string name="paste_the_link_you_received_to_connect_with_your_contact">Cole o link que você recebeu para se conectar com seu contato..</string> + <string name="receipts_groups_title_disable">Desativar recibos para grupos\?</string> + <string name="receipts_groups_title_enable">Ativar recibos para grupos\?</string> + <string name="receipts_groups_override_disabled">Recibos de entrega estão desativados para %d grupos</string> + <string name="receipts_groups_enable_for_all">Ativar para todos os grupos</string> + <string name="receipts_groups_enable_keep_overrides">Ativar (manter sobreposições do grupo)</string> + <string name="receipts_groups_disable_keep_overrides">Desativar (manter sobreposições do grupo)</string> + <string name="receipts_groups_disable_for_all">Desativar para todos os grupos</string> + <string name="in_developing_title">Em breve!</string> + <string name="delivery">Entrega</string> + <string name="no_info_on_delivery">Nenhuma informação de entrega</string> + <string name="sending_delivery_receipts_will_be_enabled_all_profiles">Enviar recibos de entrega será ativado para todos os contatos em todos os perfis de chat visíveis.</string> + <string name="rcv_group_event_2_members_connected">%s e %s conectados</string> + <string name="connect_via_member_address_alert_title">Conectar diretamente\?</string> + <string name="no_selected_chat">Sem chat selecionado</string> + <string name="privacy_message_draft">Rascunho de mensagem</string> + <string name="send_receipts_disabled">desativado</string> </resources> \ No newline at end of file diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt index 486b147f81..94e985328e 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt @@ -104,7 +104,11 @@ object NtfManager { actions.forEach { builder.action(it.first, it.second) } - prevNtfs.add(chatId to builder.toast()) + try { + prevNtfs.add(chatId to builder.toast()) + } catch (e: Exception) { + Log.e(TAG, e.stackTraceToString()) + } } private fun prepareIconPath(icon: ImageBitmap?): String? = if (icon != null) { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt index 471389d0cd..612217925b 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt @@ -25,6 +25,8 @@ fun initApp() { initChatController() runMigrations() } + // LALAL + //testCrypto() } private fun applyAppLocale() { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt index b26023951e..9042a62830 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt @@ -2,8 +2,10 @@ package chat.simplex.common.platform import androidx.compose.runtime.* import chat.simplex.common.* +import chat.simplex.common.views.helpers.AlertManager import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.MR +import java.awt.Desktop import java.io.* import java.net.URI @@ -19,6 +21,20 @@ actual val agentDatabaseFileName: String = "simplex_v1_agent.db" actual val databaseExportDir: File = tmpDir +actual fun desktopOpenDatabaseDir() { + if (Desktop.isDesktopSupported()) { + try { + Desktop.getDesktop().open(dataDir); + } catch (e: IOException) { + Log.e(TAG, e.stackTraceToString()) + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.unknown_error), + text = e.stackTraceToString() + ) + } + } +} + @Composable actual fun rememberFileChooserLauncher(getContent: Boolean, rememberedValue: Any?, onResult: (URI?) -> Unit): FileChooserLauncher = remember(rememberedValue) { FileChooserLauncher(getContent, onResult) } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt index 2ba6f3b3f6..6e85ea91c6 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt @@ -1,7 +1,7 @@ package chat.simplex.common.platform import androidx.compose.runtime.MutableState -import chat.simplex.common.model.ChatItem +import chat.simplex.common.model.* import chat.simplex.common.views.usersettings.showInDevelopingAlert import kotlinx.coroutines.CoroutineScope @@ -18,7 +18,7 @@ actual class RecorderNative: RecorderInterface { } actual object AudioPlayer: AudioPlayerInterface { - override fun play(filePath: String?, audioPlaying: MutableState<Boolean>, progress: MutableState<Int>, duration: MutableState<Int>, resetOnEnd: Boolean) { + override fun play(fileSource: CryptoFile, audioPlaying: MutableState<Boolean>, progress: MutableState<Int>, duration: MutableState<Int>, resetOnEnd: Boolean) { showInDevelopingAlert() } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt index 84e24a1d55..1d5ab45bbb 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt @@ -3,6 +3,8 @@ package chat.simplex.common.platform import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.text.AnnotatedString +import chat.simplex.common.model.* +import chat.simplex.common.views.helpers.getAppFileUri import chat.simplex.common.views.helpers.withApi import java.io.File import java.net.URI @@ -20,12 +22,16 @@ actual fun ClipboardManager.shareText(text: String) { showToast(MR.strings.copied.localized()) } -actual fun shareFile(text: String, filePath: String) { +actual fun shareFile(text: String, fileSource: CryptoFile) { withApi { FileChooserLauncher(false) { to: URI? -> if (to != null) { - copyFileToFile(File(filePath), to) {} + if (fileSource.cryptoArgs != null) { + decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, to.path) + } else { + copyFileToFile(File(fileSource.filePath), to) {} + } } - }.launch(filePath) + }.launch(fileSource.filePath) } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt index 214946b1c9..711e09267d 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt @@ -11,7 +11,7 @@ import java.net.URI @Composable actual fun SimpleAndAnimatedImageView( - uri: URI, + data: ByteArray, imageBitmap: ImageBitmap, file: CIFile?, imageProvider: () -> ImageGalleryProvider, diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt index 9b265a5f5f..c1d9eeec52 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt @@ -31,7 +31,7 @@ actual fun ReactionIcon(text: String, fontSize: TextUnit) { @Composable actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState<Boolean>) { - ItemAction(stringResource(MR.strings.save_verb), painterResource(MR.images.ic_download), onClick = { + ItemAction(stringResource(MR.strings.save_verb), painterResource(if (cItem.file?.fileSource?.cryptoArgs == null) MR.images.ic_download else MR.images.ic_lock_open_right), onClick = { when (cItem.content.msgContent) { is MsgContent.MCImage, is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> withApi { saveFileLauncher.launch(cItem.file?.fileName ?: "") } else -> {} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt index e4e483092d..a73c2784ed 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt @@ -4,19 +4,16 @@ import androidx.compose.foundation.Image import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.* -import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.layout.ContentScale import chat.simplex.common.platform.VideoPlayer -import chat.simplex.common.views.helpers.getBitmapFromUri +import chat.simplex.common.views.helpers.getBitmapFromByteArray import chat.simplex.res.MR -import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource -import java.net.URI @Composable -actual fun FullScreenImageView(modifier: Modifier, uri: URI, imageBitmap: ImageBitmap) { +actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap) { Image( - getBitmapFromUri(uri, false) ?: MR.images.decentralized.image.toComposeImageBitmap(), + getBitmapFromByteArray(data, false) ?: MR.images.decentralized.image.toComposeImageBitmap(), contentDescription = stringResource(MR.strings.image_descr), contentScale = ContentScale.Fit, modifier = modifier, diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt new file mode 100644 index 0000000000..af2b269b58 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt @@ -0,0 +1,106 @@ +package chat.simplex.common.views.database + +import SectionItemView +import SectionTextFooter +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import chat.simplex.common.ui.theme.WarningOrange +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +actual fun SavePassphraseSetting( + useKeychain: Boolean, + initialRandomDBPassphrase: Boolean, + storedKey: Boolean, + progressIndicator: Boolean, + minHeight: Dp, + onCheckedChange: (Boolean) -> Unit, +) { + SectionItemView(minHeight = minHeight) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + if (storedKey) painterResource(MR.images.ic_vpn_key_filled) else painterResource(MR.images.ic_vpn_key_off_filled), + stringResource(MR.strings.save_passphrase_in_settings), + tint = if (storedKey) WarningOrange else MaterialTheme.colors.secondary + ) + Spacer(Modifier.padding(horizontal = 4.dp)) + Text( + stringResource(MR.strings.save_passphrase_in_settings), + Modifier.padding(end = 24.dp), + color = Color.Unspecified + ) + Spacer(Modifier.fillMaxWidth().weight(1f)) + DefaultSwitch( + checked = useKeychain, + onCheckedChange = onCheckedChange, + enabled = !initialRandomDBPassphrase && !progressIndicator + ) + } + } +} + +@Composable +actual fun DatabaseEncryptionFooter( + useKeychain: MutableState<Boolean>, + chatDbEncrypted: Boolean?, + storedKey: MutableState<Boolean>, + initialRandomDBPassphrase: MutableState<Boolean>, +) { + if (chatDbEncrypted == false) { + SectionTextFooter(generalGetString(MR.strings.database_is_not_encrypted)) + } else if (useKeychain.value) { + if (storedKey.value) { + SectionTextFooter(generalGetString(MR.strings.settings_is_storing_in_clear_text)) + if (initialRandomDBPassphrase.value) { + SectionTextFooter(generalGetString(MR.strings.encrypted_with_random_passphrase)) + } else { + SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase)) + } + } else { + SectionTextFooter(generalGetString(MR.strings.passphrase_will_be_saved_in_settings)) + } + } else { + SectionTextFooter(generalGetString(MR.strings.you_have_to_enter_passphrase_every_time)) + SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase)) + } +} + +actual fun encryptDatabaseSavedAlert(onConfirm: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.encrypt_database_question), + text = generalGetString(MR.strings.database_will_be_encrypted_and_passphrase_stored_in_settings) + "\n" + storeSecurelySaved(), + confirmText = generalGetString(MR.strings.encrypt_database), + onConfirm = onConfirm, + destructive = true, + ) +} + +actual fun changeDatabaseKeySavedAlert(onConfirm: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.change_database_passphrase_question), + text = generalGetString(MR.strings.database_encryption_will_be_updated_in_settings) + "\n" + storeSecurelySaved(), + confirmText = generalGetString(MR.strings.update_database), + onConfirm = onConfirm, + destructive = false, + ) +} + +actual fun removePassphraseAlert(onConfirm: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.remove_passphrase_from_settings), + text = storeSecurelyDanger(), + confirmText = generalGetString(MR.strings.remove_passphrase), + onConfirm = onConfirm, + destructive = true, + ) +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt index f4a9ac9b78..4fa768a5d3 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt @@ -6,8 +6,10 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Density import chat.simplex.common.model.CIFile +import chat.simplex.common.model.readCryptoFile import chat.simplex.common.platform.* import chat.simplex.common.simplexWindowState +import java.io.ByteArrayInputStream import java.io.File import java.net.URI import javax.imageio.ImageIO @@ -88,11 +90,12 @@ actual fun escapedHtmlToAnnotatedString(text: String, density: Density): Annotat actual fun getAppFileUri(fileName: String): URI = URI("file:" + appFilesDir.absolutePath + File.separator + fileName) -actual fun getLoadedImage(file: CIFile?): ImageBitmap? { +actual fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>? { val filePath = getLoadedFilePath(file) return if (filePath != null) { - val uri = getAppFileUri(filePath.substringAfterLast(File.separator)) - getBitmapFromUri(uri, false) + val data = if (file?.fileSource?.cryptoArgs != null) readCryptoFile(filePath, file.fileSource.cryptoArgs) else File(filePath).readBytes() + val bitmap = getBitmapFromByteArray(data, false) + if (bitmap != null) bitmap to data else null } else { null } @@ -107,6 +110,9 @@ actual fun getFileSize(uri: URI): Long? = uri.toPath().toFile().length() actual fun getBitmapFromUri(uri: URI, withAlertOnException: Boolean): ImageBitmap? = ImageIO.read(uri.inputStream()).toComposeImageBitmap() +actual fun getBitmapFromByteArray(data: ByteArray, withAlertOnException: Boolean): ImageBitmap? = + ImageIO.read(ByteArrayInputStream(data)).toComposeImageBitmap() + // LALAL implement to support animated drawable actual fun getDrawableFromUri(uri: URI, withAlertOnException: Boolean): Any? = null diff --git a/cabal.project b/cabal.project index 84969148a5..fa1e5eee36 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: dc65197cfd1276a28bbc542d78c761eb68742186 + tag: 887ccbcf6c5da85540f029d162cc183badc5365b source-repository-package type: git diff --git a/docs/protocol/diagrams/group.mmd b/docs/protocol/diagrams/group.mmd index c331b46100..18d392caa5 100644 --- a/docs/protocol/diagrams/group.mmd +++ b/docs/protocol/diagrams/group.mmd @@ -3,24 +3,31 @@ sequenceDiagram participant A as Alice participant B as Bob participant C as Existing<br>contact - + note over A, B: 1. send and accept group invitation A ->> B: x.grp.inv<br>invite Bob to group<br>(via contact connection) - B ->> A: x.grp.acpt<br>accept invitation<br>(via member connection) - B ->> A: establish group member connection + B ->> A: x.grp.acpt<br>accept invitation<br>(via member connection)<br>establish group member connection note over M, B: 2. introduce new member Bob to all existing members A ->> M: x.grp.mem.new<br>"announce" Bob<br>to existing members<br>(via member connections) - A ->> B: x.grp.mem.intro * N<br>"introduce" members<br>(via member connection) - B ->> A: x.grp.mem.inv * N<br>"invitations" to connect<br>for all members<br>(via member connection) - A ->> M: x.grp.mem.fwd<br>forward "invitations"<br>to all members<br>(via member connections) + loop batched + A ->> B: x.grp.mem.intro * N<br>"introduce" members and<br>their chat protocol versions<br>(via member connection) + note over B: prepare group member connections + opt chat protocol compatible version < 2 + note over B: prepare direct connections + end + B ->> A: x.grp.mem.inv * N<br>"invitations" to connect<br>for all members<br>(via member connection) + end + A ->> M: x.grp.mem.fwd<br>forward "invitations" and<br>Bob's chat protocol version<br>to all members<br>(via member connections) note over M, B: 3. establish direct and group member connections M ->> B: establish group member connection - M ->> B: establish direct connection - note over M, C: 4. deduplicate new contact - B ->> M: x.info.probe<br>"probe" is sent to all new members - B ->> C: x.info.probe.check<br>"probe" hash,<br>in case contact and<br>member profiles match - C ->> B: x.info.probe.ok<br> original "probe",<br> in case contact and member<br>are the same user - note over B: merge existing and new contacts if received and sent probe hashes match + opt chat protocol compatible version < 2 + M ->> B: establish direct connection + note over M, C: 4. deduplicate new contact + B ->> M: x.info.probe<br>"probe" is sent to all new members + B ->> C: x.info.probe.check<br>"probe" hash,<br>in case contact and<br>member profiles match + C ->> B: x.info.probe.ok<br> original "probe",<br> in case contact and member<br>are the same user + note over B: merge existing and new contacts if received and sent probe hashes match + end diff --git a/docs/protocol/diagrams/group.svg b/docs/protocol/diagrams/group.svg index d66b560b21..8c1b65dee2 100644 --- a/docs/protocol/diagrams/group.svg +++ b/docs/protocol/diagrams/group.svg @@ -1 +1 @@ -<svg aria-labelledby="chart-title-graph-div chart-desc-graph-div" role="img" viewBox="-50 -10 1125.5 1328" style="max-width: 1125.5px;" height="1328" xmlns="http://www.w3.org/2000/svg" width="100%" id="graph-div" xmlns:xlink="http://www.w3.org/1999/xlink"><style>@import url("https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.2/css/all.min.css");'</style><title id="chart-title-graph-div">N existingmembersAliceBobExistingcontact1. send and accept group invitation2. introduce new member Bob to all existing members3. establish direct and group member connections4. deduplicate new contactmerge existing and new contacts if received and sent probe hashes matchx.grp.invinvite Bob to group(via contact connection)x.grp.acptestablish group member connectionx.grp.mem.new"announce" Bobto existing members(via member connections)x.grp.mem.intro * N"introduce" members(via member connection)x.grp.mem.inv * N"invitations" to connectfor all members(via member connection)x.grp.mem.fwdforward "invitations"to all members(via member connections)establish group member connectionestablish direct connectionx.info.probe"probe" is sent to all new membersx.info.probe.check"probe" hash,in case contact andmember profiles matchx.info.probe.ok original "probe", in case contact and memberare the same userN existingmembersAliceBobExistingcontact \ No newline at end of file +ExistingcontactBobAliceN existingmembersExistingcontactBobAliceN existingmembers1. send and accept group invitation2. introduce new member Bob to all existing membersprepare group member connectionsprepare direct connectionsopt[chat protocolcompatible version< 2]loop[batched]3. establish direct and group member connections4. deduplicate new contactmerge existing and new contacts if received and sent probe hashes matchopt[chat protocol compatible version < 2]x.grp.invinvite Bob to group(via contact connection)x.grp.acptaccept invitation(via member connection)establish group member connectionx.grp.mem.new"announce" Bobto existing members(via member connections)x.grp.mem.intro * N"introduce" members andtheir chat protocol versions(via member connection)x.grp.mem.inv * N"invitations" to connectfor all members(via member connection)x.grp.mem.fwdforward "invitations" andBob's chat protocol versionto all members(via member connections)establish group member connectionestablish direct connectionx.info.probe"probe" is sent to all new membersx.info.probe.check"probe" hash,in case contact andmember profiles matchx.info.probe.ok original "probe", in case contact and memberare the same user \ No newline at end of file diff --git a/docs/rfcs/2023-04-28-files-encryption.md b/docs/rfcs/2023-04-28-files-encryption.md new file mode 100644 index 0000000000..30c6a4d2dd --- /dev/null +++ b/docs/rfcs/2023-04-28-files-encryption.md @@ -0,0 +1,64 @@ +# Encrpting local app files + +## Problem + +Currently, the files are stored in the file storage unencrypted, unlike the database. + +There are multiple operations in the app that access files: + +1. Sending files via SMP - chat core reads the files chunk by chunk and sends them. The file can be encrypted once sent and the "encrypted" flag added. + +2. Sending files via XFTP - simplexmq encrypts the file first and then sends it. Currently, we are deleting the file from chat, once its uploaded, there is no reason to keep unencrypted file (from XFTP point of view) once its encrypted. + +3. Viewing images in the mobile apps. + +4. Playing voice files in the mobile apps. + +5. Playing videos and showing video previews in mobile apps. + +6. Saving files from the app storage to the device. + +## Possible solutions + +### System encryption + +A possible approach is to use platform-specific encryption mechanism. The problem with that approach is inconsistency between platforms, and that the files in chat archive will probably be unencrypted in this case. + +### App encryption + +Files will be encrypted once received, using storage key, and the core would expose C apis to mobile apps: + +1. Read the file with decryption - this can be used for image previews, for example, as a replacement for OS file reading. + +2. Copy the file with decryption to some permanent destination - this can be used for saving files to the device. + +3. Copy the file into a temporary location with decryption - this can be used for playing voice/video files. The app would remove the files once no longer used, and this temporary location can be cleaned on each app start, to clean up the files that the app failed to remove. Alternative to that would be to have both encrypted and decrypted copies available for the file, with paths stored in the database, and clean up process removed decrypted copies once no longer used - there should be some flags to indicate when decrypted copy can be deleted. + +For specific use cases: + +1. Viewing images in the mobile apps. + - iOS: we use `UIImage(contentsOfFile path: String)`. We could use `init?(data: Data)` instead, and decrypt the file in memory before passing it to the image view. Images are small enough for this approach to be ok, and in any case the image is read to memory as a whole. + - Android: we use `BitmapFactory.decodeFileDescriptor` (?). We could use ... + +2. Playing voice files in the mobile apps. + - iOS: we use `AVAudioPlayer.init(contentsOf: URL)` to play the file. We could either decrypt the file before playing it, or, given that voice files are small (even if we increase allowed duration, they are still likely to be under 1mb), we could use `init(data: Data)` to avoid creating decrypted file. + - Android: we use `MediaPlayer.setDataSource(filePath)`. We could use ... + +3. Showing video previews. + - iOS: ... + - Android: ... + + Possibly, we will need to store preview as a separate file, to avoid decrypting the whole video just to show preview. + +4. Playing video files. + - iOS: we use `AVPlayer(url: URL)`, the file will have to be decrypted for playback. + - Android: ... + +5. Saving files from the app storage to the device. + The file will have to be decrypted, passed to the system, and then decrypted copy deleted once no longer needed. + +### Which key to use for encryption + +1. Derive file encryption key from database storage key. The downside for this approach is managing key changes - they will be slow. Also, if file encryption is made optional, and in any case, for the existing users all files are not encrypted yet, we will need somehow to track which files are encrypted. + +2. Random per-file encryption key stored in the database. Given that the database is already encrypted, it can be a better approach, and it makes it easier to manage file encryption/decryption. File keys will not be sent to the client application, but they will be accessible via the database queries of course. diff --git a/docs/rfcs/2023-08-28-groups-improvements.md b/docs/rfcs/2023-08-28-groups-improvements.md new file mode 100644 index 0000000000..7a653fcb26 --- /dev/null +++ b/docs/rfcs/2023-08-28-groups-improvements.md @@ -0,0 +1,112 @@ +# Groups improvements + +See also: +- [Group contacts management](./2022-10-19-group-contacts-management.md). +- [Create groups without establishing direct connections](./2023-08-10-groups-wt-contacts.md). + +## Problem + +Establishing connections in groups is unstable and uses a lot of traffic. There are several areas for improvement that that could help optimize it: + +- Joining group member prematurely creates direct and group connections for each member. + + Some members may never come online, and that traffic would be completely wasted. + + Instead of creating direct connections, we could allow to send direct messages inside group, and optionally have a separate protocol for automating establishing direct connection with member via them. + +- Host sends N introduction messages (XGrpMemIntro) to joining member. Instead they could be batched. + +## Possible solutions + +### Improved group handshake protocol + +Below are proposed changes to group handshake protocol to reduce traffic and improve stability. + +Each joining member creates a new temporary per group address for introduced members to connect via. Joining member sends it to host when accepting group invitation. + +``` haskell +XGrpAcptAddress :: MemberId -> ConnReqContact -> ChatMsgEvent 'Json +``` + +Host sends group introductions in batches, batching smaller messages first (introductions of members without profile picture). + +For each received batch of N introductions joining member creates N transient per member identifiers (MemberCodes) and replies to host with batched XGrpMemInv messages including these identifiers. Joining member would then use them to verify contact requests from introduced members. + +How is MemberCode different from MemberId? - MemberId is known to all group members and is constant per member per group. MemberCode would be known only to host and to introduced member (of existing members), so other members wouldn't be able to impersonate one another when requesting connection with joining member. An introduced member can still pass their identifier + joining member address to another member or outside of group, but it is no different to passing currently shared invitation links. + +```haskell +newtype MemberCode = MemberCode {unMemberCode :: ByteString} + +XGrpMemInvCode :: MemberId -> MemberCode -> ChatMsgEvent 'Json + +-- instead of / in addition to batching message could be + +type MemberCodes = Map MemberId MemberCode + +XGrpMemInvCodes :: MemberCodes -> ChatMsgEvent 'Json +``` + +Host includes joining member address and code (unique for each introduced member) into XGrpMemFwd messages instead of invitation links: + +```haskell +XGrpMemFwdCode :: MemberInfo -> ConnReqContact -> MemberCode -> ChatMsgEvent 'Json +``` + +Introduced members send contact requests with a new message XGroupMember / XIntroduced (similar to XInfo or XContact, see `processUserContactRequest`): + +```haskell +XIntroduced :: MemberInfo -> MemberCode -> ChatMsgEvent 'Json +``` + +Joinee verifies profile and code and automatically accepts contact request. They both assign resulting connection to respective group member record, without creating contact. + +After (if) all introduced members have connected, joining member deletes per group address. Possibly it can also be deleted after expiration interval. + +#### Group links + +We can reduce number of steps taken to join group via group link: +- Do not create direct connection and contact with group link host, instead use the connection resulting from contact request as a group connection, and assign it to a group member record. +- Host to not send XGrpInv message, joining member to not wait for it, instead joining member would initiate with XGrpAcptAddress after establishing connection via group link. + +In addition to their profile, host includes MemberId of joining member into confirmation when accepting group link join request, using new message: + +```haskell +XGroupLinkInfo :: Profile -> MemberId -> ChatMsgEvent 'Json +``` + +Joining member initially doesn't know group profile, they create a placeholder group with a new dummy profile (alternatively, we could include at least group display name into group link). After connection is established, host sends XGrpInfo containing group profile to joining member. This can happen in parallel with group handshake started by XGrpAcptAddress. + +Group profile could also be included into XGroupLinkInfo if not for the limitation on size if both host's profile and group profile contain pictures. + +![Adding member to the group](./diagrams/2023-08-28-groups-improvements.svg) + +#### Clients compatibility + +We have a [proposed mechanism](https://github.com/simplex-chat/simplex-chat/pull/2886) for communicating "chat protocol version" between clients. + +Sending and processing new protocol messages would only be supported by updated clients. + +Trying to support both protocols across different members in the same group would require complex logic: + +Host would have to send introduced members versions, joining member would provide both address or invitation links depending on each members' versions, host would forward accordingly. + +Instead we could assign "chat protocol version" per group and share it with members as part of group profile, and make a two-stage release when members would first be able to update and get new processing logic, but have it disabled until next release. + +After group switching to new processing logic old clients wouldn't be able to connect in groups. + +How should existing groups be switched? +- Owner user action? +- Owner client deciding automatically? +- In case group has multiple owners - which owner(s) can / should decide? +- Prohibited until all / part of existing members don't update? How to request members to update? +- Old clients will not be able to process and save group chat version from group profile update. + +### Sending direct messages inside group + +Group messages are sent by broadcasting them to all group member connections. As a replacement for creating additional direct connections in group we can allow to send message directly to members via group member connections. The UX would be to choose whether to send to group or to a specific member via compose view. + +Possible approach is to extend ExtMsgContent with `direct :: Maybe Bool` field, which would only be considered for group messages. + +Chat items should store information of receiving member database ID (for sending member) and of message being direct (for receiving member). Perhaps it could be a single field `direct_member_id`, which would be the same as `group_member_id` for received messages. + +TODO - consider whether `connection_id` or `group_id` or both should be assigned in `messages` table. diff --git a/docs/rfcs/diagrams/2023-08-28-groups-improvements.mmd b/docs/rfcs/diagrams/2023-08-28-groups-improvements.mmd new file mode 100644 index 0000000000..591c30445e --- /dev/null +++ b/docs/rfcs/diagrams/2023-08-28-groups-improvements.mmd @@ -0,0 +1,40 @@ +sequenceDiagram + participant M as N existing
members + participant A as Alice + participant B as Bob + + note over A, B: 1. send and accept group invitation /
join via group link + alt host invites contact + A ->> B: x.grp.inv
invite Bob to group
(via contact connection) + else user joins via group link + B ->> A: request to join group via link + A ->> B: auto-accept
x.group.link.info with host's profile
and joining member MemberId
establish group member connection + A ->> B: x.grp.info
group profile + end + + note right of B: when joining via group link
Bob doesn't wait for x.grp.info
and initiates group handshake
with x.grp.acpt.address
after establishing connection + + note over B: create per group address + B ->> A: x.grp.acpt.address
accept invitation
and send address to connect
(via member connection) + B ->> A: establish group member connection + + note over M, B: 2. introduce new member Bob to all existing members + A ->> M: x.grp.mem.new
"announce" Bob
to existing members
(via member connections) + + loop batched + A ->> B: x.grp.mem.intro * N
"introduce" members
(via member connection) + note over B: create N MemberCodes + B ->> A: x.grp.mem.inv.code
unique MemberCodes
for all members
(via member connection) + end + + A ->> M: x.grp.mem.fwd.code
forward address
and unique MemberCodes
to all members
(via member connections) + + note over M, B: 3. establish group member connection + M ->> B: request group member connection
x.introduced with MemberCode + B ->> M: verify MemberCode, auto-accept + + note over M, B: no contact deduplication + + opt all introduced members connected / expiration + note over B: delete per group address + end diff --git a/docs/rfcs/diagrams/2023-08-28-groups-improvements.svg b/docs/rfcs/diagrams/2023-08-28-groups-improvements.svg new file mode 100644 index 0000000000..34529d5329 --- /dev/null +++ b/docs/rfcs/diagrams/2023-08-28-groups-improvements.svg @@ -0,0 +1 @@ +BobAliceN existingmembersBobAliceN existingmembers1. send and accept group invitation /join via group linkalt[host invites contact][user joins via group link]when joining via group linkBob doesn't wait for x.grp.infoand initiates group handshakewith x.grp.acpt.addressafter establishing connectioncreate per group address2. introduce new member Bob to all existing memberscreate N MemberCodesloop[batched]3. establish group member connectionno contact deduplicationdelete per group addressopt[all introducedmembersconnected /expiration]x.grp.invinvite Bob to group(via contact connection)request to join group via linkauto-acceptx.group.link.info with host's profileand joining member MemberIdestablish group member connectionx.grp.infogroup profilex.grp.acpt.addressaccept invitationand send address to connect(via member connection)establish group member connectionx.grp.mem.new"announce" Bobto existing members(via member connections)x.grp.mem.intro * N"introduce" members(via member connection)x.grp.mem.inv.codeunique MemberCodesfor all members(via member connection)x.grp.mem.fwd.codeforward addressand unique MemberCodesto all members(via member connections)request group member connectionx.introduced with MemberCodeverify MemberCode, auto-accept \ No newline at end of file diff --git a/scripts/ios/export-localizations.sh b/scripts/ios/export-localizations.sh index ee97415bc0..df880e2694 100755 --- a/scripts/ios/export-localizations.sh +++ b/scripts/ios/export-localizations.sh @@ -2,7 +2,7 @@ set -e -langs=( en cs de es fr it ja nl pl ru zh-Hans ) +langs=( en cs de es fi fr it ja nl pl ru uk zh-Hans ) for lang in "${langs[@]}"; do echo "***" diff --git a/scripts/ios/import-localizations.sh b/scripts/ios/import-localizations.sh index 40f9d944b9..542c3a7f61 100755 --- a/scripts/ios/import-localizations.sh +++ b/scripts/ios/import-localizations.sh @@ -2,7 +2,7 @@ set -e -langs=( en cs de es fr it ja nl pl ru th zh-Hans ) +langs=( en cs de es fi fr it ja nl pl ru th uk zh-Hans ) for lang in "${langs[@]}"; do echo "***" diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 0d1bde019f..fd4f9e6f5a 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."dc65197cfd1276a28bbc542d78c761eb68742186" = "06n1hhvgwi16byw3wwglmgii5yk5wlmr5qc1is6cvnj5pgyvv7qk"; + "https://github.com/simplex-chat/simplexmq.git"."887ccbcf6c5da85540f029d162cc183badc5365b" = "01iyv5nfvdhw9id6wly83v9rg1xmfjdxd3a0mf632nv0lmgnx2mh"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 8cb3ee466c..c5bf769dad 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -108,7 +108,12 @@ library Simplex.Chat.Migrations.M20230705_delivery_receipts Simplex.Chat.Migrations.M20230721_group_snd_item_statuses Simplex.Chat.Migrations.M20230814_indexes + Simplex.Chat.Migrations.M20230827_file_encryption + Simplex.Chat.Migrations.M20230829_connections_chat_vrange + Simplex.Chat.Migrations.M20230903_connections_to_subscribe Simplex.Chat.Mobile + Simplex.Chat.Mobile.File + Simplex.Chat.Mobile.Shared Simplex.Chat.Mobile.WebRTC Simplex.Chat.Options Simplex.Chat.ProfileGenerator diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index d894210045..20a2dcf39e 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE BangPatterns #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} @@ -6,13 +5,13 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE MultiWayIf #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeApplications #-} -{-# LANGUAGE OverloadedRecordDot #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} @@ -30,7 +29,7 @@ import Crypto.Random (drgNew) import qualified Data.Aeson as J import Data.Attoparsec.ByteString.Char8 (Parser) import qualified Data.Attoparsec.ByteString.Char8 as A -import Data.Bifunctor (bimap, first, second) +import Data.Bifunctor (bimap, first) import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B @@ -46,7 +45,6 @@ import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, listToMaybe, mapMaybe, maybeToList) -import qualified Data.Set as S import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) @@ -90,14 +88,17 @@ import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations import Simplex.Messaging.Client (defaultNetworkConfig) import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) +import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (base64P) -import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), EntityId, ErrorType (..), MsgBody, MsgFlags (..), NtfServer, ProtoServerWithAuth, ProtocolTypeI, SProtocolType (..), UserProtocol, userProtocol) +import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), EntityId, ErrorType (..), MsgBody, MsgFlags (..), NtfServer, ProtoServerWithAuth, ProtocolTypeI, SProtocolType (..), SubscriptionMode (..), UserProtocol, userProtocol) import qualified Simplex.Messaging.Protocol as SMP import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (defaultSocksProxy) import Simplex.Messaging.Util +import Simplex.Messaging.Version import System.Exit (exitFailure, exitSuccess) import System.FilePath (combine, splitExtensions, takeFileName, ()) import System.IO (Handle, IOMode (..), SeekMode (..), hFlush, stdout) @@ -117,6 +118,7 @@ defaultChatConfig = { tcpPort = undefined, -- agent does not listen to TCP tbqSize = 1024 }, + chatVRange = supportedChatVRange, confirmMigrations = MCConsole, defaultServers = DefaultAgentServers @@ -196,6 +198,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen inputQ <- newTBQueueIO tbqSize outputQ <- newTBQueueIO tbqSize notifyQ <- newTBQueueIO tbqSize + subscriptionMode <- newTVarIO SMSubscribe chatLock <- newEmptyTMVarIO sndFiles <- newTVarIO M.empty rcvFiles <- newTVarIO M.empty @@ -209,7 +212,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen showLiveItems <- newTVarIO False userXFTPFileConfig <- newTVarIO $ xftpFileConfig cfg tempDirectory <- newTVarIO tempDir - pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, filesFolder, expireCIThreads, expireCIFlags, cleanupManagerAsync, timedItemThreads, showLiveItems, userXFTPFileConfig, tempDirectory, logFilePath = logFile} + pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, subscriptionMode, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, filesFolder, expireCIThreads, expireCIFlags, cleanupManagerAsync, timedItemThreads, showLiveItems, userXFTPFileConfig, tempDirectory, logFilePath = logFile} where configServers :: DefaultAgentServers configServers = @@ -248,6 +251,8 @@ cfgServers p s = case p of startChatController :: forall m. ChatMonad' m => Bool -> Bool -> Bool -> m (Async ()) startChatController subConns enableExpireCIs startXFTPWorkers = do asks smpAgent >>= resumeAgentClient + unless subConns $ + chatWriteVar subscriptionMode SMOnlyCreate users <- fromRight [] <$> runExceptT (withStoreCtx' (Just "startChatController, getUsers") getUsers) restoreCalls s <- asks agentAsync @@ -257,7 +262,7 @@ startChatController subConns enableExpireCIs startXFTPWorkers = do a1 <- async $ race_ notificationSubscriber agentSubscriber a2 <- if subConns - then Just <$> async (subscribeUsers users) + then Just <$> async (subscribeUsers False users) else pure Nothing atomically . writeTVar s $ Just (a1, a2) when startXFTPWorkers $ do @@ -285,14 +290,14 @@ startChatController subConns enableExpireCIs startXFTPWorkers = do startExpireCIThread user setExpireCIFlag user True -subscribeUsers :: forall m. ChatMonad' m => [User] -> m () -subscribeUsers users = do +subscribeUsers :: forall m. ChatMonad' m => Bool -> [User] -> m () +subscribeUsers onlyNeeded users = do let (us, us') = partition activeUser users subscribe us subscribe us' where subscribe :: [User] -> m () - subscribe = mapM_ $ runExceptT . subscribeUserConnections Agent.subscribeConnections + subscribe = mapM_ $ runExceptT . subscribeUserConnections onlyNeeded Agent.subscribeConnections startFilesToReceive :: forall m. ChatMonad' m => [User] -> m () startFilesToReceive users = do @@ -466,14 +471,16 @@ processChatCommand = \case APIActivateChat -> withUser $ \_ -> do restoreCalls withAgent foregroundAgent - withStoreCtx' (Just "APIActivateChat, getUsers") getUsers >>= void . forkIO . startFilesToReceive + users <- withStoreCtx' (Just "APIActivateChat, getUsers") getUsers + void . forkIO $ subscribeUsers True users + void . forkIO $ startFilesToReceive users setAllExpireCIFlags True ok_ APISuspendChat t -> do setAllExpireCIFlags False withAgent (`suspendAgent` t) ok_ - ResubscribeAllConnections -> withStoreCtx' (Just "ResubscribeAllConnections, getUsers") getUsers >>= subscribeUsers >> ok_ + ResubscribeAllConnections -> withStoreCtx' (Just "ResubscribeAllConnections, getUsers") getUsers >>= subscribeUsers False >> ok_ -- has to be called before StartChat SetTempFolder tf -> do createDirectoryIfMissing True tf @@ -566,21 +573,24 @@ processChatCommand = \case SendFileSMP fileInline -> smpSndFileTransfer file fileSize fileInline SendFileXFTP -> xftpSndFileTransfer user file fileSize 1 $ CGContact ct where - smpSndFileTransfer :: FilePath -> Integer -> Maybe InlineFileMode -> m (FileInvitation, CIFile 'MDSnd, FileTransferMeta) - smpSndFileTransfer file fileSize fileInline = do + smpSndFileTransfer :: CryptoFile -> Integer -> Maybe InlineFileMode -> m (FileInvitation, CIFile 'MDSnd, FileTransferMeta) + smpSndFileTransfer (CryptoFile _ (Just _)) _ _ = throwChatError $ CEFileInternal "locally encrypted files can't be sent via SMP" -- can only happen if XFTP is disabled + smpSndFileTransfer (CryptoFile file Nothing) fileSize fileInline = do + subMode <- chatReadVar subscriptionMode (agentConnId_, fileConnReq) <- if isJust fileInline then pure (Nothing, Nothing) - else bimap Just Just <$> withAgent (\a -> createConnection a (aUserId user) True SCMInvitation Nothing) + else bimap Just Just <$> withAgent (\a -> createConnection a (aUserId user) True SCMInvitation Nothing subMode) let fileName = takeFileName file fileInvitation = FileInvitation {fileName, fileSize, fileDigest = Nothing, fileConnReq, fileInline, fileDescr = Nothing} chSize <- asks $ fileChunkSize . config withStore' $ \db -> do - ft@FileTransferMeta {fileId} <- createSndDirectFileTransfer db userId ct file fileInvitation agentConnId_ chSize + ft@FileTransferMeta {fileId} <- createSndDirectFileTransfer db userId ct file fileInvitation agentConnId_ chSize subMode fileStatus <- case fileInline of Just IFMSent -> createSndDirectInlineFT db ct ft $> CIFSSndTransfer 0 1 _ -> pure CIFSSndStored - let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just file, fileStatus, fileProtocol = FPSMP} + let fileSource = Just $ CF.plain file + ciFile = CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol = FPSMP} pure (fileInvitation, ciFile, ft) prepareMsg :: Maybe FileInvitation -> Maybe CITimed -> m (MsgContainer, Maybe (CIQuote 'CTDirect)) prepareMsg fInv_ timed_ = case quotedItemId_ of @@ -629,15 +639,17 @@ processChatCommand = \case SendFileSMP fileInline -> smpSndFileTransfer file fileSize fileInline SendFileXFTP -> xftpSndFileTransfer user file fileSize n $ CGGroup g where - smpSndFileTransfer :: FilePath -> Integer -> Maybe InlineFileMode -> m (FileInvitation, CIFile 'MDSnd, FileTransferMeta) - smpSndFileTransfer file fileSize fileInline = do + smpSndFileTransfer :: CryptoFile -> Integer -> Maybe InlineFileMode -> m (FileInvitation, CIFile 'MDSnd, FileTransferMeta) + smpSndFileTransfer (CryptoFile _ (Just _)) _ _ = throwChatError $ CEFileInternal "locally encrypted files can't be sent via SMP" -- can only happen if XFTP is disabled + smpSndFileTransfer (CryptoFile file Nothing) fileSize fileInline = do let fileName = takeFileName file fileInvitation = FileInvitation {fileName, fileSize, fileDigest = Nothing, fileConnReq = Nothing, fileInline, fileDescr = Nothing} fileStatus = if fileInline == Just IFMSent then CIFSSndTransfer 0 1 else CIFSSndStored chSize <- asks $ fileChunkSize . config withStore' $ \db -> do ft@FileTransferMeta {fileId} <- createSndGroupFileTransfer db userId gInfo file fileInvitation chSize - let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just file, fileStatus, fileProtocol = FPSMP} + let fileSource = Just $ CF.plain file + ciFile = CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol = FPSMP} pure (fileInvitation, ciFile, ft) sendGroupFileInline :: [GroupMember] -> SharedMsgId -> FileTransferMeta -> m () sendGroupFileInline ms sharedMsgId ft@FileTransferMeta {fileInline} = @@ -694,17 +706,19 @@ processChatCommand = \case getFileName CIFile{fileName} = fileName qFileName = maybe qText (T.pack . getFileName) ciFile_ qTextOrFile = if T.null qText then qFileName else qText - xftpSndFileTransfer :: User -> FilePath -> Integer -> Int -> ContactOrGroup -> m (FileInvitation, CIFile 'MDSnd, FileTransferMeta) - xftpSndFileTransfer user file fileSize n contactOrGroup = do - let fileName = takeFileName file + xftpSndFileTransfer :: User -> CryptoFile -> Integer -> Int -> ContactOrGroup -> m (FileInvitation, CIFile 'MDSnd, FileTransferMeta) + xftpSndFileTransfer user file@(CryptoFile filePath cfArgs) fileSize n contactOrGroup = do + let fileName = takeFileName filePath fileDescr = FileDescr {fileDescrText = "", fileDescrPartNo = 0, fileDescrComplete = False} fInv = xftpFileInvitation fileName fileSize fileDescr - fsFilePath <- toFSFilePath file - aFileId <- withAgent $ \a -> xftpSendFile a (aUserId user) fsFilePath (roundedFDCount n) + fsFilePath <- toFSFilePath filePath + let srcFile = CryptoFile fsFilePath cfArgs + aFileId <- withAgent $ \a -> xftpSendFile a (aUserId user) srcFile (roundedFDCount n) -- TODO CRSndFileStart event for XFTP chSize <- asks $ fileChunkSize . config ft@FileTransferMeta {fileId} <- withStore' $ \db -> createSndFileTransferXFTP db user contactOrGroup file fInv (AgentSndFileId aFileId) chSize - let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just file, fileStatus = CIFSSndStored, fileProtocol = FPXFTP} + let fileSource = Just $ CryptoFile filePath cfArgs + ciFile = CIFile {fileId, fileName, fileSize, fileSource, fileStatus = CIFSSndStored, fileProtocol = FPXFTP} case contactOrGroup of CGContact Contact {activeConn} -> withStore' $ \db -> createSndFTDescrXFTP db user Nothing activeConn ft fileDescr CGGroup (Group _ ms) -> forM_ ms $ \m -> saveMemberFD m `catchChatError` (toView . CRChatError (Just user)) @@ -1273,8 +1287,9 @@ processChatCommand = \case APIAddContact userId incognito -> withUserId userId $ \user -> withChatLock "addContact" . procCmd $ do -- [incognito] generate profile for connection incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing - (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing - conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnNew incognitoProfile + subMode <- chatReadVar subscriptionMode + (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing subMode + conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnNew incognitoProfile subMode toView $ CRNewContactConnection user conn pure $ CRInvitation user cReq conn AddContact incognito -> withUser $ \User {userId} -> @@ -1295,11 +1310,13 @@ processChatCommand = \case Just conn' -> pure $ CRConnectionIncognitoUpdated user conn' Nothing -> throwChatError CEConnectionIncognitoChangeProhibited APIConnect userId incognito (Just (ACR SCMInvitation cReq)) -> withUserId userId $ \user -> withChatLock "connect" . procCmd $ do + subMode <- chatReadVar subscriptionMode -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = userProfileToSend user incognitoProfile Nothing - connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq . directMessage $ XInfo profileToSend - conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnJoined $ incognitoProfile $> profileToSend + dm <- directMessage $ XInfo profileToSend + connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm subMode + conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnJoined (incognitoProfile $> profileToSend) subMode toView $ CRNewContactConnection user conn pure $ CRSentConfirmation user APIConnect userId incognito (Just (ACR SCMContact cReq)) -> withUserId userId $ \user -> connectViaContact user incognito cReq @@ -1316,8 +1333,9 @@ processChatCommand = \case ListContacts -> withUser $ \User {userId} -> processChatCommand $ APIListContacts userId APICreateMyAddress userId -> withUserId userId $ \user -> withChatLock "createMyAddress" . procCmd $ do - (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact Nothing - withStore $ \db -> createUserContactLink db user connId cReq + subMode <- chatReadVar subscriptionMode + (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact Nothing subMode + withStore $ \db -> createUserContactLink db user connId cReq subMode pure $ CRUserContactLinkCreated user cReq CreateMyAddress -> withUser $ \User {userId} -> processChatCommand $ APICreateMyAddress userId @@ -1422,8 +1440,9 @@ processChatCommand = \case case contactMember contact members of Nothing -> do gVar <- asks idsDrg - (agentConnId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing - member <- withStore $ \db -> createNewContactMember db gVar user groupId contact memRole agentConnId cReq + subMode <- chatReadVar subscriptionMode + (agentConnId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing subMode + member <- withStore $ \db -> createNewContactMember db gVar user groupId contact memRole agentConnId cReq subMode sendInvitation member cReq pure $ CRSentGroupInvitation user gInfo contact member Just member@GroupMember {groupMemberId, memberStatus, memberRole = mRole} @@ -1436,11 +1455,17 @@ processChatCommand = \case Nothing -> throwChatError $ CEGroupCantResendInvitation gInfo cName | otherwise -> throwChatError $ CEGroupDuplicateMember cName APIJoinGroup groupId -> withUser $ \user@User {userId} -> do - ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} <- withStore $ \db -> getGroupInvitation db user groupId + (invitation, ct) <- withStore $ \db -> do + inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db user groupId + (inv,) <$> getContactViaMember db user fromMember + let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} = invitation + Contact {activeConn = Connection {peerChatVRange}} = ct withChatLock "joinGroup" . procCmd $ do - agentConnId <- withAgent $ \a -> joinConnection a (aUserId user) True connRequest . directMessage $ XGrpAcpt membership.memberId + subMode <- chatReadVar subscriptionMode + dm <- directMessage $ XGrpAcpt membership.memberId + agentConnId <- withAgent $ \a -> joinConnection a (aUserId user) True connRequest dm subMode withStore' $ \db -> do - createMemberConnection db userId fromMember agentConnId + createMemberConnection db userId fromMember agentConnId peerChatVRange subMode updateGroupMemberStatus db userId fromMember GSMemAccepted updateGroupMemberStatus db userId membership GSMemAccepted updateCIGroupInvitationStatus user @@ -1551,9 +1576,10 @@ processChatCommand = \case assertUserGroupRole gInfo GRAdmin when (mRole > GRMember) $ throwChatError $ CEGroupMemberInitialRole gInfo mRole groupLinkId <- GroupLinkId <$> drgRandomBytes 16 + subMode <- chatReadVar subscriptionMode let crClientData = encodeJSON $ CRDataGroup groupLinkId - (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact $ Just crClientData - withStore $ \db -> createGroupLink db user gInfo connId cReq groupLinkId mRole + (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact (Just crClientData) subMode + withStore $ \db -> createGroupLink db user gInfo connId cReq groupLinkId mRole subMode pure $ CRGroupLinkCreated user gInfo cReq mRole APIGroupLinkMemberRole groupId mRole' -> withUser $ \user -> withChatLock "groupLinkMemberRole " $ do gInfo <- withStore $ \db -> getGroupInfo db user groupId @@ -1621,26 +1647,40 @@ processChatCommand = \case asks showLiveItems >>= atomically . (`writeTVar` on) >> ok_ SendFile chatName f -> withUser $ \user -> do chatRef <- getChatRef user chatName - processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just f) Nothing (MCFile "") + processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just $ CF.plain f) Nothing (MCFile "") SendImage chatName f -> withUser $ \user -> do chatRef <- getChatRef user chatName filePath <- toFSFilePath f - unless (any ((`isSuffixOf` map toLower f)) imageExtensions) $ throwChatError CEFileImageType {filePath} + unless (any (`isSuffixOf` map toLower f) imageExtensions) $ throwChatError CEFileImageType {filePath} fileSize <- getFileSize filePath unless (fileSize <= maxImageSize) $ throwChatError CEFileImageSize {filePath} -- TODO include file description for preview - processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just f) Nothing (MCImage "" fixedImagePreview) + processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just $ CF.plain f) Nothing (MCImage "" fixedImagePreview) ForwardFile chatName fileId -> forwardFile chatName fileId SendFile ForwardImage chatName fileId -> forwardFile chatName fileId SendImage SendFileDescription _chatName _f -> pure $ chatCmdError Nothing "TODO" - ReceiveFile fileId rcvInline_ filePath_ -> withUser $ \_ -> + ReceiveFile fileId encrypted rcvInline_ filePath_ -> withUser $ \_ -> withChatLock "receiveFile" . procCmd $ do - (user, ft) <- withStore $ \db -> getRcvFileTransferById db fileId - receiveFile' user ft rcvInline_ filePath_ - SetFileToReceive fileId -> withUser $ \_ -> do + (user, ft) <- withStore (`getRcvFileTransferById` fileId) + ft' <- if encrypted then encryptLocalFile ft else pure ft + receiveFile' user ft' rcvInline_ filePath_ + where + encryptLocalFile ft@RcvFileTransfer {xftpRcvFile} = case xftpRcvFile of + Nothing -> throwChatError $ CEFileInternal "locally encrypted files can't be received via SMP" + Just f -> do + cfArgs <- liftIO $ CF.randomArgs + withStore' $ \db -> setFileCryptoArgs db fileId cfArgs + pure ft {xftpRcvFile = Just ((f :: XFTPRcvFile) {cryptoArgs = Just cfArgs})} + SetFileToReceive fileId encrypted -> withUser $ \_ -> do withChatLock "setFileToReceive" . procCmd $ do - withStore' (`setRcvFileToReceive` fileId) + cfArgs <- if encrypted then fileCryptoArgs else pure Nothing + withStore' $ \db -> setRcvFileToReceive db fileId cfArgs ok_ + where + fileCryptoArgs = do + (_, RcvFileTransfer {xftpRcvFile = f}) <- withStore (`getRcvFileTransferById` fileId) + unless (isJust f) $ throwChatError $ CEFileInternal "locally encrypted files can't be received via SMP" + liftIO $ Just <$> CF.randomArgs CancelFile fileId -> withUser $ \user@User {userId} -> withChatLock "cancelFile" . procCmd $ withStore (\db -> getFileTransfer db user fileId) >>= \case @@ -1744,17 +1784,17 @@ processChatCommand = \case ResetAgentStats -> withAgent resetAgentStats >> ok_ GetAgentSubs -> summary <$> withAgent getAgentSubscriptions where - summary SubscriptionsInfo {activeSubscriptions, pendingSubscriptions} = - CRAgentSubs {activeSubs, distinctActiveSubs, pendingSubs, distinctPendingSubs} + summary SubscriptionsInfo {activeSubscriptions, pendingSubscriptions, removedSubscriptions} = + CRAgentSubs + { activeSubs = foldl' countSubs M.empty activeSubscriptions, + pendingSubs = foldl' countSubs M.empty pendingSubscriptions, + removedSubs = foldl' accSubErrors M.empty removedSubscriptions + } where - (activeSubs, distinctActiveSubs) = foldSubs activeSubscriptions - (pendingSubs, distinctPendingSubs) = foldSubs pendingSubscriptions - foldSubs :: [SubInfo] -> (Map Text Int, Map Text Int) - foldSubs = second (M.map S.size) . foldl' acc (M.empty, M.empty) - acc (m, m') SubInfo {server, rcvId} = - ( M.alter (Just . maybe 1 (+ 1)) server m, - M.alter (Just . maybe (S.singleton rcvId) (S.insert rcvId)) server m' - ) + countSubs m SubInfo {server} = M.alter (Just . maybe 1 (+ 1)) server m + accSubErrors m = \case + SubInfo {server, subError = Just e} -> M.alter (Just . maybe [e] (e :)) server m + _ -> m GetAgentSubsDetails -> CRAgentSubsDetails <$> withAgent getAgentSubscriptions where withChatLock name action = asks chatLock >>= \l -> withLock l name action @@ -1825,30 +1865,33 @@ processChatCommand = \case (_, xContactId_) -> procCmd $ do let randomXContactId = XContactId <$> drgRandomBytes 16 xContactId <- maybe randomXContactId pure xContactId_ + subMode <- chatReadVar subscriptionMode -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = userProfileToSend user incognitoProfile Nothing - connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq $ directMessage (XContact profileToSend $ Just xContactId) + dm <- directMessage (XContact profileToSend $ Just xContactId) + connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm subMode let groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli - conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId + conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId subMode toView $ CRNewContactConnection user conn pure $ CRSentInvitation user incognitoProfile contactMember :: Contact -> [GroupMember] -> Maybe GroupMember contactMember Contact {contactId} = find $ \GroupMember {memberContactId = cId, memberStatus = s} -> cId == Just contactId && s /= GSMemRemoved && s /= GSMemLeft - checkSndFile :: MsgContent -> FilePath -> Integer -> m (Integer, SendFileMode) - checkSndFile mc f n = do + checkSndFile :: MsgContent -> CryptoFile -> Integer -> m (Integer, SendFileMode) + checkSndFile mc (CryptoFile f cfArgs) n = do fsFilePath <- toFSFilePath f unlessM (doesFileExist fsFilePath) . throwChatError $ CEFileNotFound f ChatConfig {fileChunkSize, inlineFiles} <- asks config xftpCfg <- readTVarIO =<< asks userXFTPFileConfig - fileSize <- getFileSize fsFilePath + fileSize <- liftIO $ CF.getFileContentsSize $ CryptoFile fsFilePath cfArgs when (fromInteger fileSize > maxFileSize) $ throwChatError $ CEFileSize f let chunks = - ((- fileSize) `div` fileChunkSize) fileInline = inlineFileMode mc inlineFiles chunks n fileMode = case xftpCfg of Just cfg + | isJust cfArgs -> SendFileXFTP | fileInline == Just IFMSent || fileSize < minFileSize cfg || n <= 0 -> SendFileSMP fileInline | otherwise -> SendFileXFTP _ -> SendFileSMP fileInline @@ -1875,17 +1918,17 @@ processChatCommand = \case summary <- foldM (processAndCount user' logLevel) (UserProfileUpdateSummary 0 0 0 []) contacts pure $ CRUserProfileUpdated user' (fromLocalProfile p) p' summary where - processAndCount user' ll (!s@UserProfileUpdateSummary {notChanged, updateSuccesses, updateFailures, changedContacts = cts}) ct = do + processAndCount user' ll s@UserProfileUpdateSummary {notChanged, updateSuccesses, updateFailures, changedContacts = cts} ct = do let mergedProfile = userProfileToSend user Nothing $ Just ct ct' = updateMergedPreferences user' ct mergedProfile' = userProfileToSend user' Nothing $ Just ct' if mergedProfile' == mergedProfile then pure s {notChanged = notChanged + 1} - else - let cts' = if mergedPreferences ct == mergedPreferences ct' then cts else ct' : cts + else + let cts' = if mergedPreferences ct == mergedPreferences ct' then cts else ct' : cts in (notifyContact mergedProfile' ct' $> s {updateSuccesses = updateSuccesses + 1, changedContacts = cts'}) `catchChatError` \e -> when (ll <= CLLInfo) (toView $ CRChatError (Just user) e) $> s {updateFailures = updateFailures + 1, changedContacts = cts'} - where + where notifyContact mergedProfile' ct' = do void $ sendDirectContactMessage ct' (XInfo mergedProfile') when (directOrUsed ct') $ createSndFeatureItems user' ct ct' @@ -2218,11 +2261,13 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI case (xftpRcvFile, fileConnReq) of -- direct file protocol (Nothing, Just connReq) -> do - connIds <- joinAgentConnectionAsync user True connReq . directMessage $ XFileAcpt fName + subMode <- chatReadVar subscriptionMode + dm <- directMessage $ XFileAcpt fName + connIds <- joinAgentConnectionAsync user True connReq dm subMode filePath <- getRcvFilePath fileId filePath_ fName True - withStoreCtx (Just "acceptFileReceive, acceptRcvFileTransfer") $ \db -> acceptRcvFileTransfer db user fileId connIds ConnJoined filePath + withStoreCtx (Just "acceptFileReceive, acceptRcvFileTransfer") $ \db -> acceptRcvFileTransfer db user fileId connIds ConnJoined filePath subMode -- XFTP - (Just _xftpRcvFile, _) -> do + (Just XFTPRcvFile {cryptoArgs}, _) -> do filePath <- getRcvFilePath fileId filePath_ fName False (ci, rfd) <- withStoreCtx (Just "acceptFileReceive, xftpAcceptRcvFT ...") $ \db -> do -- marking file as accepted and reading description in the same transaction @@ -2230,7 +2275,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI ci <- xftpAcceptRcvFT db user fileId filePath rfd <- getRcvFileDescrByFileId db fileId pure (ci, rfd) - receiveViaCompleteFD user fileId rfd + receiveViaCompleteFD user fileId rfd cryptoArgs pure ci -- group & direct file protocol _ -> do @@ -2261,8 +2306,9 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI | fileInline == Just IFMSent -> throwChatError $ CEFileAlreadyReceiving fName | otherwise -> do -- accepting via a new connection - connIds <- createAgentConnectionAsync user cmdFunction True SCMInvitation - withStoreCtx (Just "acceptFile, acceptRcvFileTransfer") $ \db -> acceptRcvFileTransfer db user fileId connIds ConnNew filePath + subMode <- chatReadVar subscriptionMode + connIds <- createAgentConnectionAsync user cmdFunction True SCMInvitation subMode + withStoreCtx (Just "acceptFile, acceptRcvFileTransfer") $ \db -> acceptRcvFileTransfer db user fileId connIds ConnNew filePath subMode receiveInline :: m Bool receiveInline = do ChatConfig {fileChunkSize, inlineFiles = InlineFilesConfig {receiveChunks, offerChunks}} <- asks config @@ -2273,11 +2319,11 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI || (rcvInline_ == Just True && fileSize <= fileChunkSize * offerChunks) ) -receiveViaCompleteFD :: ChatMonad m => User -> FileTransferId -> RcvFileDescr -> m () -receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} = +receiveViaCompleteFD :: ChatMonad m => User -> FileTransferId -> RcvFileDescr -> Maybe CryptoFileArgs -> m () +receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} cfArgs = when fileDescrComplete $ do rd <- parseFileDescription fileDescrText - aFileId <- withAgent $ \a -> xftpReceiveFile a (aUserId user) rd + aFileId <- withAgent $ \a -> xftpReceiveFile a (aUserId user) rd cfArgs startReceivingFile user fileId withStoreCtx' (Just "receiveViaCompleteFD, updateRcvFileAgentId") $ \db -> updateRcvFileAgentId db fileId (Just $ AgentRcvFileId aFileId) @@ -2333,17 +2379,20 @@ getRcvFilePath fileId fPath_ fn keepHandle = case fPath_ of in ifM (doesFileExist f) (tryCombine $ n + 1) (pure f) acceptContactRequest :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> m Contact -acceptContactRequest user UserContactRequest {agentInvitationId = AgentInvId invId, localDisplayName = cName, profileId, profile = cp, userContactLinkId, xContactId} incognitoProfile = do +acceptContactRequest user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = cp, userContactLinkId, xContactId} incognitoProfile = do + subMode <- chatReadVar subscriptionMode let profileToSend = profileToSendOnAccept user incognitoProfile - acId <- withAgent $ \a -> acceptContact a True invId . directMessage $ XInfo profileToSend - withStore' $ \db -> createAcceptedContact db user acId cName profileId cp userContactLinkId xContactId incognitoProfile + dm <- directMessage $ XInfo profileToSend + acId <- withAgent $ \a -> acceptContact a True invId dm subMode + withStore' $ \db -> createAcceptedContact db user acId cReqChatVRange cName profileId cp userContactLinkId xContactId incognitoProfile subMode acceptContactRequestAsync :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> m Contact -acceptContactRequestAsync user UserContactRequest {agentInvitationId = AgentInvId invId, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} incognitoProfile = do +acceptContactRequestAsync user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} incognitoProfile = do + subMode <- chatReadVar subscriptionMode let profileToSend = profileToSendOnAccept user incognitoProfile - (cmdId, acId) <- agentAcceptContactAsync user True invId $ XInfo profileToSend + (cmdId, acId) <- agentAcceptContactAsync user True invId (XInfo profileToSend) subMode withStore' $ \db -> do - ct@Contact {activeConn = Connection {connId}} <- createAcceptedContact db user acId cName profileId p userContactLinkId xContactId incognitoProfile + ct@Contact {activeConn = Connection {connId}} <- createAcceptedContact db user acId cReqChatVRange cName profileId p userContactLinkId xContactId incognitoProfile subMode setCommandConnId db user cmdId connId pure ct @@ -2390,18 +2439,28 @@ agentSubscriber = do type AgentBatchSubscribe m = AgentClient -> [ConnId] -> ExceptT AgentErrorType m (Map ConnId (Either AgentErrorType ())) -subscribeUserConnections :: forall m. ChatMonad m => AgentBatchSubscribe m -> User -> m () -subscribeUserConnections agentBatchSubscribe user@User {userId} = do +subscribeUserConnections :: forall m. ChatMonad m => Bool -> AgentBatchSubscribe m -> User -> m () +subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do -- get user connections ce <- asks $ subscriptionEvents . config - (ctConns, cts) <- getContactConns - (ucConns, ucs) <- getUserContactLinkConns - (gs, mConns, ms) <- getGroupMemberConns - (sftConns, sfts) <- getSndFileTransferConns - (rftConns, rfts) <- getRcvFileTransferConns - (pcConns, pcs) <- getPendingContactConns + (conns, cts, ucs, gs, ms, sfts, rfts, pcs) <- + if onlyNeeded + then do + (conns, entities) <- withStore' getConnectionsToSubscribe + let (cts, ucs, ms, sfts, rfts, pcs) = foldl' addEntity (M.empty, M.empty, M.empty, M.empty, M.empty, M.empty) entities + pure (conns, cts, ucs, [], ms, sfts, rfts, pcs) + else do + withStore' unsetConnectionToSubscribe + (ctConns, cts) <- getContactConns + (ucConns, ucs) <- getUserContactLinkConns + (gs, mConns, ms) <- getGroupMemberConns + (sftConns, sfts) <- getSndFileTransferConns + (rftConns, rfts) <- getRcvFileTransferConns + (pcConns, pcs) <- getPendingContactConns + let conns = concat [ctConns, ucConns, mConns, sftConns, rftConns, pcConns] + pure (conns, cts, ucs, gs, ms, sfts, rfts, pcs) -- subscribe using batched commands - rs <- withAgent (`agentBatchSubscribe` concat [ctConns, ucConns, mConns, sftConns, rftConns, pcConns]) + rs <- withAgent $ \a -> agentBatchSubscribe a conns -- send connection events to view contactSubsToView rs cts ce contactLinkSubsToView rs ucs @@ -2410,6 +2469,29 @@ subscribeUserConnections agentBatchSubscribe user@User {userId} = do rcvFileSubsToView rs rfts pendingConnSubsToView rs pcs where + addEntity (cts, ucs, ms, sfts, rfts, pcs) = \case + RcvDirectMsgConnection c (Just ct) -> let cts' = addConn c ct cts in (cts', ucs, ms, sfts, rfts, pcs) + RcvDirectMsgConnection c Nothing -> let pcs' = addConn c (toPCC c) pcs in (cts, ucs, ms, sfts, rfts, pcs') + RcvGroupMsgConnection c _g m -> let ms' = addConn c m ms in (cts, ucs, ms', sfts, rfts, pcs) + SndFileConnection c sft -> let sfts' = addConn c sft sfts in (cts, ucs, ms, sfts', rfts, pcs) + RcvFileConnection c rft -> let rfts' = addConn c rft rfts in (cts, ucs, ms, sfts, rfts', pcs) + UserContactConnection c uc -> let ucs' = addConn c uc ucs in (cts, ucs', ms, sfts, rfts, pcs) + addConn :: Connection -> a -> Map ConnId a -> Map ConnId a + addConn = M.insert . aConnId + toPCC Connection {connId, agentConnId, connStatus, viaUserContactLink, groupLinkId, customUserProfileId, localAlias, createdAt} = + PendingContactConnection + { pccConnId = connId, + pccAgentConnId = agentConnId, + pccConnStatus = connStatus, + viaContactUri = False, + viaUserContactLink, + groupLinkId, + customUserProfileId, + connReqInv = Nothing, + localAlias, + createdAt, + updatedAt = createdAt + } getContactConns :: m ([ConnId], Map ConnId Contact) getContactConns = do cts <- withStore_ ("subscribeUserConnections " <> show userId <> ", getUserContacts") getUserContacts @@ -2827,21 +2909,23 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> Nothing processDirectMessage :: ACommand 'Agent e -> ConnectionEntity -> Connection -> Maybe Contact -> m () - processDirectMessage agentMsg connEntity conn@Connection {connId, viaUserContactLink, groupLinkId, customUserProfileId, connectionCode} = \case + processDirectMessage agentMsg connEntity conn@Connection {connId, peerChatVRange, viaUserContactLink, groupLinkId, customUserProfileId, connectionCode} = \case Nothing -> case agentMsg of CONF confId _ connInfo -> do -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) let profileToSend = userProfileToSend user (fromLocalProfile <$> incognitoProfile) Nothing - saveConnInfo conn connInfo + conn' <- saveConnInfo conn connInfo -- [async agent commands] no continuation needed, but command should be asynchronous for stability - allowAgentConnectionAsync user conn confId $ XInfo profileToSend - INFO connInfo -> - saveConnInfo conn connInfo + allowAgentConnectionAsync user conn' confId $ XInfo profileToSend + INFO connInfo -> do + _conn' <- saveConnInfo conn connInfo + pure () MSG meta _msgFlags msgBody -> do cmdId <- createAckCmd conn - withAckMessage agentConnId cmdId meta $ - saveRcvMSG conn (ConnectionId connId) meta msgBody cmdId $> False + withAckMessage agentConnId cmdId meta $ do + (_conn', _) <- saveRcvMSG conn (ConnectionId connId) meta msgBody cmdId + pure False SENT msgId -> sentMsgDeliveryEvent conn msgId OK -> @@ -2866,54 +2950,57 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do setConnConnReqInv db user connId cReq getXGrpMemIntroContDirect db user ct forM_ contData $ \(hostConnId, xGrpMemIntroCont) -> - sendXGrpMemInv hostConnId directConnReq xGrpMemIntroCont + sendXGrpMemInv hostConnId (Just directConnReq) xGrpMemIntroCont CRContactUri _ -> throwChatError $ CECommandError "unexpected ConnectionRequestUri type" MSG msgMeta _msgFlags msgBody -> do cmdId <- createAckCmd conn withAckMessage agentConnId cmdId msgMeta $ do - msg@RcvMessage {chatMsgEvent = ACME _ event} <- saveRcvMSG conn (ConnectionId connId) msgMeta msgBody cmdId - assertDirectAllowed user MDRcv ct $ toCMEventTag event + (conn', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveRcvMSG conn (ConnectionId connId) msgMeta msgBody cmdId + let ct' = ct {activeConn = conn'} :: Contact + assertDirectAllowed user MDRcv ct' $ toCMEventTag event updateChatLock "directMessage" event case event of - XMsgNew mc -> newContentMessage ct mc msg msgMeta - XMsgFileDescr sharedMsgId fileDescr -> messageFileDescription ct sharedMsgId fileDescr msgMeta - XMsgFileCancel sharedMsgId -> cancelMessageFile ct sharedMsgId msgMeta - XMsgUpdate sharedMsgId mContent ttl live -> messageUpdate ct sharedMsgId mContent msg msgMeta ttl live - XMsgDel sharedMsgId _ -> messageDelete ct sharedMsgId msg msgMeta - XMsgReact sharedMsgId _ reaction add -> directMsgReaction ct sharedMsgId reaction add msg msgMeta + XMsgNew mc -> newContentMessage ct' mc msg msgMeta + XMsgFileDescr sharedMsgId fileDescr -> messageFileDescription ct' sharedMsgId fileDescr msgMeta + XMsgFileCancel sharedMsgId -> cancelMessageFile ct' sharedMsgId msgMeta + XMsgUpdate sharedMsgId mContent ttl live -> messageUpdate ct' sharedMsgId mContent msg msgMeta ttl live + XMsgDel sharedMsgId _ -> messageDelete ct' sharedMsgId msg msgMeta + XMsgReact sharedMsgId _ reaction add -> directMsgReaction ct' sharedMsgId reaction add msg msgMeta -- TODO discontinue XFile - XFile fInv -> processFileInvitation' ct fInv msg msgMeta - XFileCancel sharedMsgId -> xFileCancel ct sharedMsgId msgMeta - XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInv ct sharedMsgId fileConnReq_ fName msgMeta - XInfo p -> xInfo ct p - XGrpInv gInv -> processGroupInvitation ct gInv msg msgMeta - XInfoProbe probe -> xInfoProbe ct probe - XInfoProbeCheck probeHash -> xInfoProbeCheck ct probeHash - XInfoProbeOk probe -> xInfoProbeOk ct probe - XCallInv callId invitation -> xCallInv ct callId invitation msg msgMeta - XCallOffer callId offer -> xCallOffer ct callId offer msg msgMeta - XCallAnswer callId answer -> xCallAnswer ct callId answer msg msgMeta - XCallExtra callId extraInfo -> xCallExtra ct callId extraInfo msg msgMeta - XCallEnd callId -> xCallEnd ct callId msg msgMeta - BFileChunk sharedMsgId chunk -> bFileChunk ct sharedMsgId chunk msgMeta + XFile fInv -> processFileInvitation' ct' fInv msg msgMeta + XFileCancel sharedMsgId -> xFileCancel ct' sharedMsgId msgMeta + XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInv ct' sharedMsgId fileConnReq_ fName msgMeta + XInfo p -> xInfo ct' p + XGrpInv gInv -> processGroupInvitation ct' gInv msg msgMeta + XInfoProbe probe -> xInfoProbe ct' probe + XInfoProbeCheck probeHash -> xInfoProbeCheck ct' probeHash + XInfoProbeOk probe -> xInfoProbeOk ct' probe + XCallInv callId invitation -> xCallInv ct' callId invitation msg msgMeta + XCallOffer callId offer -> xCallOffer ct' callId offer msg msgMeta + XCallAnswer callId answer -> xCallAnswer ct' callId answer msg msgMeta + XCallExtra callId extraInfo -> xCallExtra ct' callId extraInfo msg msgMeta + XCallEnd callId -> xCallEnd ct' callId msg msgMeta + BFileChunk sharedMsgId chunk -> bFileChunk ct' sharedMsgId chunk msgMeta _ -> messageError $ "unsupported message: " <> T.pack (show event) - let Contact {chatSettings = ChatSettings {sendRcpts}} = ct + let Contact {chatSettings = ChatSettings {sendRcpts}} = ct' pure $ fromMaybe (sendRcptsContacts user) sendRcpts && hasDeliveryReceipt (toCMEventTag event) RCVD msgMeta msgRcpt -> withAckMessage' agentConnId conn msgMeta $ directMsgReceived ct conn msgMeta msgRcpt CONF confId _ connInfo -> do -- confirming direct connection with a member - ChatMessage {chatMsgEvent} <- parseChatMessage conn connInfo + ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo + conn' <- updatePeerChatVRange conn chatVRange case chatMsgEvent of XGrpMemInfo _memId _memProfile -> do -- TODO check member ID -- TODO update member profile -- [async agent commands] no continuation needed, but command should be asynchronous for stability - allowAgentConnectionAsync user conn confId XOk + allowAgentConnectionAsync user conn' confId XOk _ -> messageError "CONF from member must have x.grp.mem.info" INFO connInfo -> do - ChatMessage {chatMsgEvent} <- parseChatMessage conn connInfo + ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo + _conn' <- updatePeerChatVRange conn chatVRange case chatMsgEvent of XGrpMemInfo _memId _memProfile -> do -- TODO check member ID @@ -2943,9 +3030,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) forM_ groupId_ $ \groupId -> do + subMode <- chatReadVar subscriptionMode gVar <- asks idsDrg - groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation - withStore $ \db -> createNewContactMemberAsync db gVar user groupId ct gLinkMemRole groupConnIds + groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation subMode + withStore $ \db -> createNewContactMemberAsync db gVar user groupId ct gLinkMemRole groupConnIds peerChatVRange subMode _ -> pure () Just (gInfo@GroupInfo {membership}, m@GroupMember {activeConn}) -> when (maybe False ((== ConnReady) . connStatus) activeConn) $ do @@ -3012,22 +3100,30 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do case cReq of groupConnReq@(CRInvitationUri _ _) -> case cmdFunction of -- [async agent commands] XGrpMemIntro continuation on receiving INV - CFCreateConnGrpMemInv -> do - contData <- withStore' $ \db -> do - setConnConnReqInv db user connId cReq - getXGrpMemIntroContGroup db user m - forM_ contData $ \(hostConnId, directConnReq) -> do - let GroupMember {groupMemberId, memberId} = m - sendXGrpMemInv hostConnId directConnReq XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} + CFCreateConnGrpMemInv + | isCompatibleRange (peerChatVRange conn) groupNoDirectVRange -> sendWithDirectCReq -- sendWithoutDirectCReq + | otherwise -> sendWithDirectCReq + where + sendWithoutDirectCReq = do + let GroupMember {groupMemberId, memberId} = m + hostConnId <- withStore $ \db -> do + liftIO $ setConnConnReqInv db user connId cReq + getHostConnId db user groupId + sendXGrpMemInv hostConnId Nothing XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} + sendWithDirectCReq = do + let GroupMember {groupMemberId, memberId} = m + contData <- withStore' $ \db -> do + setConnConnReqInv db user connId cReq + getXGrpMemIntroContGroup db user m + forM_ contData $ \(hostConnId, directConnReq) -> + sendXGrpMemInv hostConnId (Just directConnReq) XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} -- [async agent commands] group link auto-accept continuation on receiving INV - CFCreateConnGrpInv -> - withStore' (\db -> getContactViaMember db user m) >>= \case - Nothing -> messageError "implementation error: invitee does not have contact" - Just ct -> do - withStore' $ \db -> setNewContactMemberConnRequest db user m cReq - groupLinkId <- withStore' $ \db -> getGroupLinkId db user gInfo - sendGrpInvitation ct m groupLinkId - toView $ CRSentGroupInvitation user gInfo ct m + CFCreateConnGrpInv -> do + ct <- withStore $ \db -> getContactViaMember db user m + withStore' $ \db -> setNewContactMemberConnRequest db user m cReq + groupLinkId <- withStore' $ \db -> getGroupLinkId db user gInfo + sendGrpInvitation ct m groupLinkId + toView $ CRSentGroupInvitation user gInfo ct m where sendGrpInvitation :: Contact -> GroupMember -> Maybe GroupLinkId -> m () sendGrpInvitation ct GroupMember {memberId, memberRole = memRole} groupLinkId = do @@ -3039,7 +3135,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> throwChatError $ CECommandError "unexpected cmdFunction" CRContactUri _ -> throwChatError $ CECommandError "unexpected ConnectionRequestUri type" CONF confId _ connInfo -> do - ChatMessage {chatMsgEvent} <- parseChatMessage conn connInfo + ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo + conn' <- updatePeerChatVRange conn chatVRange case memberCategory m of GCInviteeMember -> case chatMsgEvent of @@ -3047,7 +3144,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do | sameMemberId memId m -> do withStore $ \db -> liftIO $ updateGroupMemberStatus db userId m GSMemAccepted -- [async agent commands] no continuation needed, but command should be asynchronous for stability - allowAgentConnectionAsync user conn confId XOk + allowAgentConnectionAsync user conn' confId XOk | otherwise -> messageError "x.grp.acpt: memberId is different from expected" _ -> messageError "CONF from invited member must have x.grp.acpt" _ -> @@ -3056,11 +3153,12 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do | sameMemberId memId m -> do -- TODO update member profile -- [async agent commands] no continuation needed, but command should be asynchronous for stability - allowAgentConnectionAsync user conn confId $ XGrpMemInfo (membership.memberId) (fromLocalProfile $ memberProfile membership) + allowAgentConnectionAsync user conn' confId $ XGrpMemInfo membership.memberId (fromLocalProfile $ memberProfile membership) | otherwise -> messageError "x.grp.mem.info: memberId is different from expected" _ -> messageError "CONF from member must have x.grp.mem.info" INFO connInfo -> do - ChatMessage {chatMsgEvent} <- parseChatMessage conn connInfo + ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo + _conn' <- updatePeerChatVRange conn chatVRange case chatMsgEvent of XGrpMemInfo memId _memProfile | sameMemberId memId m -> do @@ -3101,7 +3199,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do processIntro intro `catchChatError` (toView . CRChatError (Just user)) where processIntro intro@GroupMemberIntro {introId} = do - void $ sendDirectMessage conn (XGrpMemIntro . memberInfo $ reMember intro) (GroupId groupId) + void $ sendDirectMessage conn (XGrpMemIntro $ memberInfo (reMember intro)) (GroupId groupId) withStore' $ \db -> updateIntroStatus db introId GMIntroSent _ -> do -- TODO send probe and decide whether to use existing contact connection or the new contact connection @@ -3118,28 +3216,29 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do MSG msgMeta _msgFlags msgBody -> do cmdId <- createAckCmd conn withAckMessage agentConnId cmdId msgMeta $ do - msg@RcvMessage {chatMsgEvent = ACME _ event} <- saveRcvMSG conn (GroupId groupId) msgMeta msgBody cmdId + (conn', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveRcvMSG conn (GroupId groupId) msgMeta msgBody cmdId + let m' = m {activeConn = Just conn'} :: GroupMember updateChatLock "groupMessage" event case event of - XMsgNew mc -> canSend $ newGroupContentMessage gInfo m mc msg msgMeta - XMsgFileDescr sharedMsgId fileDescr -> canSend $ groupMessageFileDescription gInfo m sharedMsgId fileDescr msgMeta - XMsgFileCancel sharedMsgId -> cancelGroupMessageFile gInfo m sharedMsgId msgMeta - XMsgUpdate sharedMsgId mContent ttl live -> canSend $ groupMessageUpdate gInfo m sharedMsgId mContent msg msgMeta ttl live - XMsgDel sharedMsgId memberId -> groupMessageDelete gInfo m sharedMsgId memberId msg msgMeta - XMsgReact sharedMsgId (Just memberId) reaction add -> groupMsgReaction gInfo m sharedMsgId memberId reaction add msg msgMeta + XMsgNew mc -> canSend m' $ newGroupContentMessage gInfo m' mc msg msgMeta + XMsgFileDescr sharedMsgId fileDescr -> canSend m' $ groupMessageFileDescription gInfo m' sharedMsgId fileDescr msgMeta + XMsgFileCancel sharedMsgId -> cancelGroupMessageFile gInfo m' sharedMsgId msgMeta + XMsgUpdate sharedMsgId mContent ttl live -> canSend m' $ groupMessageUpdate gInfo m' sharedMsgId mContent msg msgMeta ttl live + XMsgDel sharedMsgId memberId -> groupMessageDelete gInfo m' sharedMsgId memberId msg msgMeta + XMsgReact sharedMsgId (Just memberId) reaction add -> groupMsgReaction gInfo m' sharedMsgId memberId reaction add msg msgMeta -- TODO discontinue XFile - XFile fInv -> processGroupFileInvitation' gInfo m fInv msg msgMeta - XFileCancel sharedMsgId -> xFileCancelGroup gInfo m sharedMsgId msgMeta - XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInvGroup gInfo m sharedMsgId fileConnReq_ fName msgMeta - XGrpMemNew memInfo -> xGrpMemNew gInfo m memInfo msg msgMeta - XGrpMemIntro memInfo -> xGrpMemIntro gInfo m memInfo - XGrpMemInv memId introInv -> xGrpMemInv gInfo m memId introInv - XGrpMemFwd memInfo introInv -> xGrpMemFwd gInfo m memInfo introInv - XGrpMemRole memId memRole -> xGrpMemRole gInfo m memId memRole msg msgMeta - XGrpMemDel memId -> xGrpMemDel gInfo m memId msg msgMeta - XGrpLeave -> xGrpLeave gInfo m msg msgMeta - XGrpDel -> xGrpDel gInfo m msg msgMeta - XGrpInfo p' -> xGrpInfo gInfo m p' msg msgMeta + XFile fInv -> processGroupFileInvitation' gInfo m' fInv msg msgMeta + XFileCancel sharedMsgId -> xFileCancelGroup gInfo m' sharedMsgId msgMeta + XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInvGroup gInfo m' sharedMsgId fileConnReq_ fName msgMeta + XGrpMemNew memInfo -> xGrpMemNew gInfo m' memInfo msg msgMeta + XGrpMemIntro memInfo -> xGrpMemIntro gInfo m' memInfo + XGrpMemInv memId introInv -> xGrpMemInv gInfo m' memId introInv + XGrpMemFwd memInfo introInv -> xGrpMemFwd gInfo m' memInfo introInv + XGrpMemRole memId memRole -> xGrpMemRole gInfo m' memId memRole msg msgMeta + XGrpMemDel memId -> xGrpMemDel gInfo m' memId msg msgMeta + XGrpLeave -> xGrpLeave gInfo m' msg msgMeta + XGrpDel -> xGrpDel gInfo m' msg msgMeta + XGrpInfo p' -> xGrpInfo gInfo m' p' msg msgMeta BFileChunk sharedMsgId chunk -> bFileChunkGroup gInfo sharedMsgId chunk msgMeta _ -> messageError $ "unsupported message: " <> T.pack (show event) currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo @@ -3149,8 +3248,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do && hasDeliveryReceipt (toCMEventTag event) && currentMemCount <= smallGroupsRcptsMemLimit where - canSend a - | m.memberRole <= GRObserver = messageError "member is not allowed to send messages" + canSend :: GroupMember -> m () -> m () + canSend mem a + | mem.memberRole <= GRObserver = messageError "member is not allowed to send messages" | otherwise = a RCVD msgMeta msgRcpt -> withAckMessage' agentConnId conn msgMeta $ @@ -3235,14 +3335,15 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- SMP CONF for SndFileConnection happens for direct file protocol -- when recipient of the file "joins" connection created by the sender CONF confId _ connInfo -> do - ChatMessage {chatMsgEvent} <- parseChatMessage conn connInfo + ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo + conn' <- updatePeerChatVRange conn chatVRange case chatMsgEvent of -- TODO save XFileAcpt message XFileAcpt name | name == fileName -> do withStore' $ \db -> updateSndFileStatus db ft FSAccepted -- [async agent commands] no continuation needed, but command should be asynchronous for stability - allowAgentConnectionAsync user conn confId XOk + allowAgentConnectionAsync user conn' confId XOk | otherwise -> messageError "x.file.acpt: fileName is different from expected" _ -> messageError "CONF from file connection must have x.file.acpt" CON -> do @@ -3303,9 +3404,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- when sender of the file "joins" connection created by the recipient -- (sender doesn't create connections for all group members) CONF confId _ connInfo -> do - ChatMessage {chatMsgEvent} <- parseChatMessage conn connInfo + ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo + conn' <- updatePeerChatVRange conn chatVRange case chatMsgEvent of - XOk -> allowAgentConnectionAsync user conn confId XOk -- [async agent commands] no continuation needed, but command should be asynchronous for stability + XOk -> allowAgentConnectionAsync user conn' confId XOk -- [async agent commands] no continuation needed, but command should be asynchronous for stability _ -> pure () CON -> startReceivingFile user fileId MSG meta _ msgBody -> do @@ -3364,10 +3466,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do processUserContactRequest :: ACommand 'Agent e -> ConnectionEntity -> Connection -> UserContact -> m () processUserContactRequest agentMsg connEntity conn UserContact {userContactLinkId} = case agentMsg of REQ invId _ connInfo -> do - ChatMessage {chatMsgEvent} <- parseChatMessage conn connInfo + ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo case chatMsgEvent of - XContact p xContactId_ -> profileContactRequest invId p xContactId_ - XInfo p -> profileContactRequest invId p Nothing + XContact p xContactId_ -> profileContactRequest invId chatVRange p xContactId_ + XInfo p -> profileContactRequest invId chatVRange p Nothing -- TODO show/log error, other events in contact request _ -> pure () MERR _ err -> do @@ -3379,9 +3481,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- TODO add debugging output _ -> pure () where - profileContactRequest :: InvitationId -> Profile -> Maybe XContactId -> m () - profileContactRequest invId p xContactId_ = do - withStore (\db -> createOrUpdateContactRequest db user userContactLinkId invId p xContactId_) >>= \case + profileContactRequest :: InvitationId -> VersionRange -> Profile -> Maybe XContactId -> m () + profileContactRequest invId chatVRange p xContactId_ = do + withStore (\db -> createOrUpdateContactRequest db user userContactLinkId invId chatVRange p xContactId_) >>= \case CORContact contact -> toView $ CRContactRequestAlreadyAccepted user contact CORRequest cReq@UserContactRequest {localDisplayName} -> do withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case @@ -3575,14 +3677,14 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do processFDMessage fileId fileDescr = do ft <- withStore $ \db -> getRcvFileTransfer db user fileId unless (rcvFileCompleteOrCancelled ft) $ do - (rfd, RcvFileTransfer {fileStatus}) <- withStore $ \db -> do + (rfd, RcvFileTransfer {fileStatus, xftpRcvFile}) <- withStore $ \db -> do rfd <- appendRcvFD db userId fileId fileDescr -- reading second time in the same transaction as appending description -- to prevent race condition with accept ft' <- getRcvFileTransfer db user fileId pure (rfd, ft') - case fileStatus of - RFSAccepted _ -> receiveViaCompleteFD user fileId rfd + case (fileStatus, xftpRcvFile) of + (RFSAccepted _, Just XFTPRcvFile {cryptoArgs}) -> receiveViaCompleteFD user fileId rfd cryptoArgs _ -> pure () cancelMessageFile :: Contact -> SharedMsgId -> MsgMeta -> m () @@ -3608,7 +3710,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do withStore' $ \db -> startRcvInlineFT db user ft fPath inline pure (Just fPath, CIFSRcvAccepted) _ -> pure (Nothing, CIFSRcvInvitation) - pure (ft, CIFile {fileId, fileName, fileSize, filePath, fileStatus, fileProtocol}) + let fileSource = CF.plain <$> filePath + pure (ft, CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol}) messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> Maybe Int -> Maybe Bool -> m () messageUpdate ct@Contact {contactId, localDisplayName = c} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl live_ = do @@ -3825,7 +3928,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do inline <- receiveInlineMode fInv Nothing fileChunkSize RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFileTransfer db userId ct fInv inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP - ciFile = Just $ CIFile {fileId, fileName, fileSize, filePath = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} + ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ msgMeta (CIRcvMsgContent $ MCFile "") ciFile Nothing False toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) whenContactNtfs user ct $ do @@ -3839,7 +3942,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do inline <- receiveInlineMode fInv Nothing fileChunkSize RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvGroupFileTransfer db userId m fInv inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP - ciFile = Just $ CIFile {fileId, fileName, fileSize, filePath = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} + ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta (CIRcvMsgContent $ MCFile "") ciFile Nothing False groupMsgToView gInfo m ci msgMeta let g = groupName' gInfo @@ -3878,8 +3981,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do then unless cancelled $ case fileConnReq_ of -- receiving via a separate connection Just fileConnReq -> do - connIds <- joinAgentConnectionAsync user True fileConnReq $ directMessage XOk - withStore' $ \db -> createSndDirectFTConnection db user fileId connIds + subMode <- chatReadVar subscriptionMode + dm <- directMessage XOk + connIds <- joinAgentConnectionAsync user True fileConnReq dm subMode + withStore' $ \db -> createSndDirectFTConnection db user fileId connIds subMode -- receiving inline _ -> do event <- withStore $ \db -> do @@ -3973,10 +4078,12 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do if fName == fileName then unless cancelled $ case (fileConnReq_, activeConn) of (Just fileConnReq, _) -> do + subMode <- chatReadVar subscriptionMode -- receiving via a separate connection -- [async agent commands] no continuation needed, but command should be asynchronous for stability - connIds <- joinAgentConnectionAsync user True fileConnReq $ directMessage XOk - withStore' $ \db -> createSndGroupFileTransferConnection db user fileId connIds m + dm <- directMessage XOk + connIds <- joinAgentConnectionAsync user True fileConnReq dm subMode + withStore' $ \db -> createSndGroupFileTransferConnection db user fileId connIds m subMode (_, Just conn) -> do -- receiving inline event <- withStore $ \db -> do @@ -3998,7 +4105,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do processGroupInvitation :: Contact -> GroupInvitation -> RcvMessage -> MsgMeta -> m () processGroupInvitation ct inv msg msgMeta = do - let Contact {localDisplayName = c, activeConn = Connection {customUserProfileId, groupLinkId = groupLinkId'}} = ct + let Contact {localDisplayName = c, activeConn = Connection {peerChatVRange, customUserProfileId, groupLinkId = groupLinkId'}} = ct GroupInvitation {fromMember = (MemberIdRole fromMemId fromRole), invitedMember = (MemberIdRole memId memRole), connRequest, groupLinkId} = inv checkIntegrityCreateItem (CDDirectRcv ct) msgMeta when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c) @@ -4007,9 +4114,11 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do (gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership = membership@GroupMember {groupMemberId, memberId}}, hostId) <- withStore $ \db -> createGroupInvitation db user ct inv customUserProfileId if sameGroupLinkId groupLinkId groupLinkId' then do - connIds <- joinAgentConnectionAsync user True connRequest . directMessage $ XGrpAcpt memberId + subMode <- chatReadVar subscriptionMode + dm <- directMessage $ XGrpAcpt memberId + connIds <- joinAgentConnectionAsync user True connRequest dm subMode withStore' $ \db -> do - createMemberConnectionAsync db user hostId connIds + createMemberConnectionAsync db user hostId connIds peerChatVRange subMode updateGroupMemberStatusById db userId hostId GSMemAccepted updateGroupMemberStatus db userId membership GSMemAccepted toView $ CRUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct) @@ -4209,18 +4318,20 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do withStore' $ \db -> mergeContactRecords db userId c1 c2 toView $ CRContactsMerged user c1 c2 - saveConnInfo :: Connection -> ConnInfo -> m () + saveConnInfo :: Connection -> ConnInfo -> m Connection saveConnInfo activeConn connInfo = do - ChatMessage {chatMsgEvent} <- parseChatMessage activeConn connInfo + ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage activeConn connInfo + conn' <- updatePeerChatVRange activeConn chatVRange case chatMsgEvent of XInfo p -> do - ct <- withStore $ \db -> createDirectContact db user activeConn p + ct <- withStore $ \db -> createDirectContact db user conn' p toView $ CRContactConnecting user ct + pure conn' -- TODO show/log error, other events in SMP confirmation - _ -> pure () + _ -> pure conn' xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> RcvMessage -> MsgMeta -> m () - xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole memberProfile) msg msgMeta = do + xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ memberProfile) msg msgMeta = do checkHostRole m memRole members <- withStore' $ \db -> getGroupMembers db user gInfo unless (sameMemberId memId $ membership gInfo) $ @@ -4233,7 +4344,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do toView $ CRJoinedGroupMemberConnecting user gInfo m newMember xGrpMemIntro :: GroupInfo -> GroupMember -> MemberInfo -> m () - xGrpMemIntro gInfo@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ _) = do + xGrpMemIntro gInfo@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ memberChatVRange _) = do case memberCategory m of GCHostMember -> do members <- withStore' $ \db -> getGroupMembers db user gInfo @@ -4241,15 +4352,21 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do then messageWarning "x.grp.mem.intro ignored: member already exists" else do when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole c) + subMode <- chatReadVar subscriptionMode -- [async agent commands] commands should be asynchronous, continuation is to send XGrpMemInv - have to remember one has completed and process on second - groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpMemInv enableNtfs SCMInvitation - directConnIds <- createAgentConnectionAsync user CFCreateConnGrpMemInv enableNtfs SCMInvitation - -- [incognito] direct connection with member has to be established using the same incognito profile [that was known to host and used for group membership] + groupConnIds <- createConn subMode + directConnIds <- case memberChatVRange of + Nothing -> Just <$> createConn subMode + Just mcvr + | isCompatibleRange (fromChatVRange mcvr) groupNoDirectVRange -> Just <$> createConn subMode -- pure Nothing + | otherwise -> Just <$> createConn subMode let customUserProfileId = if memberIncognito membership then Just (localProfileId $ memberProfile membership) else Nothing - void $ withStore $ \db -> createIntroReMember db user gInfo m memInfo groupConnIds directConnIds customUserProfileId + void $ withStore $ \db -> createIntroReMember db user gInfo m memInfo groupConnIds directConnIds customUserProfileId subMode _ -> messageError "x.grp.mem.intro can be only sent by host member" + where + createConn subMode = createAgentConnectionAsync user CFCreateConnGrpMemInv enableNtfs SCMInvitation subMode - sendXGrpMemInv :: Int64 -> ConnReqInvitation -> XGrpMemIntroCont -> m () + sendXGrpMemInv :: Int64 -> Maybe ConnReqInvitation -> XGrpMemIntroCont -> m () sendXGrpMemInv hostConnId directConnReq XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} = do hostConn <- withStore $ \db -> getConnectionById db user hostConnId let msg = XGrpMemInv memberId IntroInvitation {groupConnReq, directConnReq} @@ -4270,7 +4387,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> messageError "x.grp.mem.inv can be only sent by invitee member" xGrpMemFwd :: GroupInfo -> GroupMember -> MemberInfo -> IntroInvitation -> m () - xGrpMemFwd gInfo@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}} m memInfo@(MemberInfo memId memRole _) introInv@IntroInvitation {groupConnReq, directConnReq} = do + xGrpMemFwd gInfo@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}} m memInfo@(MemberInfo memId memRole memberChatVRange _) introInv@IntroInvitation {groupConnReq, directConnReq} = do checkHostRole m memRole members <- withStore' $ \db -> getGroupMembers db user gInfo toMember <- case find (sameMemberId memId) members of @@ -4281,13 +4398,15 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do Nothing -> withStore $ \db -> createNewGroupMember db user gInfo memInfo GCPostMember GSMemAnnounced Just m' -> pure m' withStore' $ \db -> saveMemberInvitation db toMember introInv + subMode <- chatReadVar subscriptionMode -- [incognito] send membership incognito profile, create direct connection as incognito - let msg = XGrpMemInfo membership.memberId (fromLocalProfile $ memberProfile membership) + dm <- directMessage $ XGrpMemInfo membership.memberId (fromLocalProfile $ memberProfile membership) -- [async agent commands] no continuation needed, but commands should be asynchronous for stability - groupConnIds <- joinAgentConnectionAsync user enableNtfs groupConnReq $ directMessage msg - directConnIds <- joinAgentConnectionAsync user enableNtfs directConnReq $ directMessage msg + groupConnIds <- joinAgentConnectionAsync user enableNtfs groupConnReq dm subMode + directConnIds <- forM directConnReq $ \dcr -> joinAgentConnectionAsync user enableNtfs dcr dm subMode let customUserProfileId = if memberIncognito membership then Just (localProfileId $ memberProfile membership) else Nothing - withStore' $ \db -> createIntroToMemberContact db user m toMember groupConnIds directConnIds customUserProfileId + mcvr = maybe chatInitialVRange fromChatVRange memberChatVRange + withStore' $ \db -> createIntroToMemberContact db user m toMember mcvr groupConnIds directConnIds customUserProfileId subMode xGrpMemRole :: GroupInfo -> GroupMember -> MemberId -> GroupMemberRole -> RcvMessage -> MsgMeta -> m () xGrpMemRole gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId memRole msg msgMeta @@ -4427,6 +4546,13 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do toView $ CRChatItemStatusUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) chatItem) _ -> pure () +updatePeerChatVRange :: ChatMonad m => Connection -> VersionRange -> m Connection +updatePeerChatVRange conn@Connection {connId, peerChatVRange} msgChatVRange + | msgChatVRange /= peerChatVRange = do + withStore' $ \db -> setPeerChatVRange db connId msgChatVRange + pure conn {peerChatVRange = msgChatVRange} + | otherwise = pure conn + parseFileDescription :: (ChatMonad m, FilePartyI p) => Text -> m (ValidFileDescription p) parseFileDescription = liftEither . first (ChatError . CEInvalidFileDescription) . (strDecode . encodeUtf8) @@ -4625,12 +4751,15 @@ sendDirectMessage conn chatMsgEvent connOrGroupId = do createSndMessage :: (MsgEncodingI e, ChatMonad m) => ChatMsgEvent e -> ConnOrGroupId -> m SndMessage createSndMessage chatMsgEvent connOrGroupId = do gVar <- asks idsDrg + ChatConfig {chatVRange} <- asks config withStore $ \db -> createNewSndMessage db gVar connOrGroupId $ \sharedMsgId -> - let msgBody = strEncode ChatMessage {msgId = Just sharedMsgId, chatMsgEvent} + let msgBody = strEncode ChatMessage {chatVRange, msgId = Just sharedMsgId, chatMsgEvent} in NewMessage {chatMsgEvent, msgBody} -directMessage :: MsgEncodingI e => ChatMsgEvent e -> ByteString -directMessage chatMsgEvent = strEncode ChatMessage {msgId = Nothing, chatMsgEvent} +directMessage :: (MsgEncodingI e, ChatMonad m) => ChatMsgEvent e -> m ByteString +directMessage chatMsgEvent = do + ChatConfig {chatVRange} <- asks config + pure $ strEncode ChatMessage {chatVRange, msgId = Nothing, chatMsgEvent} deliverMessage :: ChatMonad m => Connection -> CMEventTag e -> MsgBody -> MessageId -> m Int64 deliverMessage conn@Connection {connId} cmEventTag msgBody msgId = do @@ -4685,15 +4814,17 @@ sendPendingGroupMessages user GroupMember {groupMemberId, localDisplayName} conn _ -> throwChatError $ CEGroupMemberIntroNotFound localDisplayName _ -> pure () -saveRcvMSG :: ChatMonad m => Connection -> ConnOrGroupId -> MsgMeta -> MsgBody -> CommandId -> m RcvMessage +saveRcvMSG :: ChatMonad m => Connection -> ConnOrGroupId -> MsgMeta -> MsgBody -> CommandId -> m (Connection, RcvMessage) saveRcvMSG conn@Connection {connId} connOrGroupId agentMsgMeta msgBody agentAckCmdId = do - ACMsg _ ChatMessage {msgId = sharedMsgId_, chatMsgEvent} <- parseAChatMessage conn agentMsgMeta msgBody + ACMsg _ ChatMessage {chatVRange, msgId = sharedMsgId_, chatMsgEvent} <- parseAChatMessage conn agentMsgMeta msgBody + conn' <- updatePeerChatVRange conn chatVRange let agentMsgId = fst $ recipient agentMsgMeta newMsg = NewMessage {chatMsgEvent, msgBody} rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta, agentAckCmdId} - withStoreCtx' + msg <- withStoreCtx' (Just $ "createNewMessageAndRcvMsgDelivery, rcvMsgDelivery: " <> show rcvMsgDelivery <> ", sharedMsgId_: " <> show sharedMsgId_ <> ", msgDeliveryStatus: MDSRcvAgent") $ \db -> createNewMessageAndRcvMsgDelivery db connOrGroupId newMsg sharedMsgId_ rcvMsgDelivery + pure (conn', msg) saveSndChatItem :: ChatMonad m => User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> m (ChatItem c 'MDSnd) saveSndChatItem user cd msg content = saveSndChatItem' user cd msg content Nothing Nothing Nothing False @@ -4745,10 +4876,9 @@ deleteGroupCI user gInfo ci@(CChatItem msgDir deletedItem@ChatItem {file}) byUse pure $ CRChatItemDeleted user (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) toCi byUser timed deleteCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m () -deleteCIFile user file = - forM_ file $ \CIFile {fileId, filePath, fileStatus} -> do - let fileInfo = CIFileInfo {fileId, fileStatus = Just $ AFS msgDirection fileStatus, filePath} - fileAgentConnIds <- deleteFile' user fileInfo True +deleteCIFile user file_ = + forM_ file_ $ \file -> do + fileAgentConnIds <- deleteFile' user (mkCIFileInfo file) True deleteAgentConnectionsAsync user fileAgentConnIds markDirectCIDeleted :: ChatMonad m => User -> Contact -> CChatItem 'CTDirect -> MessageId -> Bool -> UTCTime -> m ChatResponse @@ -4772,34 +4902,35 @@ markGroupCIDeleted user gInfo@GroupInfo {groupId} ci@(CChatItem _ ChatItem {file gItem (CChatItem msgDir ci') = AChatItem SCTGroup msgDir (GroupChat gInfo) ci' cancelCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m () -cancelCIFile user file = - forM_ file $ \CIFile {fileId, filePath, fileStatus} -> do - let fileInfo = CIFileInfo {fileId, fileStatus = Just $ AFS msgDirection fileStatus, filePath} - fileAgentConnIds <- cancelFile' user fileInfo True +cancelCIFile user file_ = + forM_ file_ $ \file -> do + fileAgentConnIds <- cancelFile' user (mkCIFileInfo file) True deleteAgentConnectionsAsync user fileAgentConnIds -createAgentConnectionAsync :: forall m c. (ChatMonad m, ConnectionModeI c) => User -> CommandFunction -> Bool -> SConnectionMode c -> m (CommandId, ConnId) -createAgentConnectionAsync user cmdFunction enableNtfs cMode = do +createAgentConnectionAsync :: forall m c. (ChatMonad m, ConnectionModeI c) => User -> CommandFunction -> Bool -> SConnectionMode c -> SubscriptionMode -> m (CommandId, ConnId) +createAgentConnectionAsync user cmdFunction enableNtfs cMode subMode = do cmdId <- withStore' $ \db -> createCommand db user Nothing cmdFunction - connId <- withAgent $ \a -> createConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cMode + connId <- withAgent $ \a -> createConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cMode subMode pure (cmdId, connId) -joinAgentConnectionAsync :: ChatMonad m => User -> Bool -> ConnectionRequestUri c -> ConnInfo -> m (CommandId, ConnId) -joinAgentConnectionAsync user enableNtfs cReqUri cInfo = do +joinAgentConnectionAsync :: ChatMonad m => User -> Bool -> ConnectionRequestUri c -> ConnInfo -> SubscriptionMode -> m (CommandId, ConnId) +joinAgentConnectionAsync user enableNtfs cReqUri cInfo subMode = do cmdId <- withStore' $ \db -> createCommand db user Nothing CFJoinConn - connId <- withAgent $ \a -> joinConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cReqUri cInfo + connId <- withAgent $ \a -> joinConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cReqUri cInfo subMode pure (cmdId, connId) allowAgentConnectionAsync :: (MsgEncodingI e, ChatMonad m) => User -> Connection -> ConfirmationId -> ChatMsgEvent e -> m () allowAgentConnectionAsync user conn@Connection {connId} confId msg = do cmdId <- withStore' $ \db -> createCommand db user (Just connId) CFAllowConn - withAgent $ \a -> allowConnectionAsync a (aCorrId cmdId) (aConnId conn) confId $ directMessage msg + dm <- directMessage msg + withAgent $ \a -> allowConnectionAsync a (aCorrId cmdId) (aConnId conn) confId dm withStore' $ \db -> updateConnectionStatus db conn ConnAccepted -agentAcceptContactAsync :: (MsgEncodingI e, ChatMonad m) => User -> Bool -> InvitationId -> ChatMsgEvent e -> m (CommandId, ConnId) -agentAcceptContactAsync user enableNtfs invId msg = do +agentAcceptContactAsync :: (MsgEncodingI e, ChatMonad m) => User -> Bool -> InvitationId -> ChatMsgEvent e -> SubscriptionMode -> m (CommandId, ConnId) +agentAcceptContactAsync user enableNtfs invId msg subMode = do cmdId <- withStore' $ \db -> createCommand db user Nothing CFAcceptContact - connId <- withAgent $ \a -> acceptContactAsync a (aCorrId cmdId) enableNtfs invId $ directMessage msg + dm <- directMessage msg + connId <- withAgent $ \a -> acceptContactAsync a (aCorrId cmdId) enableNtfs invId dm subMode pure (cmdId, connId) deleteAgentConnectionAsync :: ChatMonad m => User -> ConnId -> m () @@ -5008,7 +5139,7 @@ withAgent :: ChatMonad m => (AgentClient -> ExceptT AgentErrorType m a) -> m a withAgent action = asks smpAgent >>= runExceptT . action - >>= liftEither . first (\e -> ChatErrorAgent e Nothing) + >>= liftEither . first (`ChatErrorAgent` Nothing) withStore' :: ChatMonad m => (DB.Connection -> IO a) -> m a withStore' action = withStore $ liftIO . action @@ -5243,8 +5374,8 @@ chatCommandP = ("/fforward " <|> "/ff ") *> (ForwardFile <$> chatNameP' <* A.space <*> A.decimal), ("/image_forward " <|> "/imgf ") *> (ForwardImage <$> chatNameP' <* A.space <*> A.decimal), ("/fdescription " <|> "/fd") *> (SendFileDescription <$> chatNameP' <* A.space <*> filePath), - ("/freceive " <|> "/fr ") *> (ReceiveFile <$> A.decimal <*> optional (" inline=" *> onOffP) <*> optional (A.space *> filePath)), - "/_set_file_to_receive " *> (SetFileToReceive <$> A.decimal), + ("/freceive " <|> "/fr ") *> (ReceiveFile <$> A.decimal <*> (" encrypt=" *> onOffP <|> pure False) <*> optional (" inline=" *> onOffP) <*> optional (A.space *> filePath)), + "/_set_file_to_receive " *> (SetFileToReceive <$> A.decimal <*> (" encrypt=" *> onOffP <|> pure False)), ("/fcancel " <|> "/fc ") *> (CancelFile <$> A.decimal), ("/fstatus " <|> "/fs ") *> (FileStatus <$> A.decimal), "/simplex" *> (ConnectSimplex <$> incognitoP), diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs index 486792b4c9..ecd1659bca 100644 --- a/src/Simplex/Chat/Bot.hs +++ b/src/Simplex/Chat/Bot.hs @@ -66,7 +66,7 @@ sendComposedMessage cc = sendComposedMessage' cc . contactId' sendComposedMessage' :: ChatController -> ContactId -> Maybe ChatItemId -> MsgContent -> IO () sendComposedMessage' cc ctId quotedItemId msgContent = do - let cm = ComposedMessage {filePath = Nothing, quotedItemId, msgContent} + let cm = ComposedMessage {fileSource = Nothing, quotedItemId, msgContent} sendChatCmd cc (APISendMessage (ChatRef CTDirect ctId) False Nothing cm) >>= \case CRNewChatItem {} -> printLog cc CLLInfo $ "sent message to contact ID " <> show ctId r -> putStrLn $ "unexpected send message response: " <> show r diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 615e472f25..af9aa964cf 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -22,8 +22,9 @@ import Control.Monad.Except import Control.Monad.IO.Unlift import Control.Monad.Reader import Crypto.Random (ChaChaDRG) -import Data.Aeson (FromJSON (..), ToJSON (..)) +import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.:?)) import qualified Data.Aeson as J +import qualified Data.Aeson.Types as JT import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B @@ -54,16 +55,19 @@ import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig) import Simplex.Messaging.Agent.Lock import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation, SQLiteStore, UpMigration) +import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.File (CryptoFile (..)) +import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) import Simplex.Messaging.Parsers (dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) -import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType, CorrId, MsgFlags, NtfServer, ProtoServerWithAuth, ProtocolTypeI, QueueId, SProtocolType, UserProtocol, XFTPServerWithAuth) +import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType, CorrId, MsgFlags, NtfServer, ProtoServerWithAuth, ProtocolTypeI, QueueId, SProtocolType, SubscriptionMode (..), UserProtocol, XFTPServerWithAuth) import Simplex.Messaging.TMap (TMap) -import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) import Simplex.Messaging.Transport (simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) -import Simplex.Messaging.Util (allFinally, catchAllErrors, tryAllErrors) +import Simplex.Messaging.Util (allFinally, catchAllErrors, tryAllErrors, (<$$>)) +import Simplex.Messaging.Version import System.IO (Handle) import System.Mem.Weak (Weak) import UnliftIO.STM @@ -72,7 +76,7 @@ versionNumber :: String versionNumber = showVersion SC.version versionString :: String -> String -versionString version = "SimpleX Chat v" <> version +versionString ver = "SimpleX Chat v" <> ver updateStr :: String updateStr = "To update run: curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/master/install.sh | bash" @@ -101,6 +105,7 @@ coreVersionInfo simplexmqCommit = data ChatConfig = ChatConfig { agentConfig :: AgentConfig, + chatVRange :: VersionRange, confirmMigrations :: MigrationConfirmation, defaultServers :: DefaultAgentServers, tbqSize :: Natural, @@ -171,6 +176,7 @@ data ChatController = ChatController outputQ :: TBQueue (Maybe CorrId, ChatResponse), notifyQ :: TBQueue Notification, sendNotification :: Notification -> IO (), + subscriptionMode :: TVar SubscriptionMode, chatLock :: Lock, sndFiles :: TVar (Map Int64 Handle), rcvFiles :: TVar (Map Int64 Handle), @@ -387,8 +393,8 @@ data ChatCommand | ForwardFile ChatName FileTransferId | ForwardImage ChatName FileTransferId | SendFileDescription ChatName FilePath - | ReceiveFile {fileId :: FileTransferId, fileInline :: Maybe Bool, filePath :: Maybe FilePath} - | SetFileToReceive FileTransferId + | ReceiveFile {fileId :: FileTransferId, storeEncrypted :: Bool, fileInline :: Maybe Bool, filePath :: Maybe FilePath} + | SetFileToReceive {fileId :: FileTransferId, storeEncrypted :: Bool} | CancelFile FileTransferId | FileStatus FileTransferId | ShowProfile -- UserId (not used in UI) @@ -570,7 +576,7 @@ data ChatResponse | CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]} | CRDebugLocks {chatLockName :: Maybe String, agentLocks :: AgentLocks} | CRAgentStats {agentStats :: [[String]]} - | CRAgentSubs {activeSubs :: Map Text Int, distinctActiveSubs :: Map Text Int, pendingSubs :: Map Text Int, distinctPendingSubs :: Map Text Int} + | CRAgentSubs {activeSubs :: Map Text Int, pendingSubs :: Map Text Int, removedSubs :: Map Text [String]} | CRAgentSubsDetails {agentSubs :: SubscriptionsInfo} | CRConnectionDisabled {connectionEntity :: ConnectionEntity} | CRAgentRcvQueueDeleted {agentConnId :: AgentConnId, server :: SMPServer, agentQueueId :: AgentQueueId, agentError_ :: Maybe AgentErrorType} @@ -723,11 +729,24 @@ data UserProfileUpdateSummary = UserProfileUpdateSummary instance ToJSON UserProfileUpdateSummary where toEncoding = J.genericToEncoding J.defaultOptions data ComposedMessage = ComposedMessage - { filePath :: Maybe FilePath, + { fileSource :: Maybe CryptoFile, quotedItemId :: Maybe ChatItemId, msgContent :: MsgContent } - deriving (Show, Generic, FromJSON) + deriving (Show, Generic) + +-- This instance is needed for backward compatibility, can be removed in v6.0 +instance FromJSON ComposedMessage where + parseJSON (J.Object v) = do + fileSource <- + (v .:? "fileSource") >>= \case + Nothing -> CF.plain <$$> (v .:? "filePath") + f -> pure f + quotedItemId <- v .:? "quotedItemId" + msgContent <- v .: "msgContent" + pure ComposedMessage {fileSource, quotedItemId, msgContent} + parseJSON invalid = + JT.prependFailure "bad ComposedMessage, " (JT.typeMismatch "Object" invalid) instance ToJSON ComposedMessage where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} @@ -942,6 +961,14 @@ type ChatMonad' m = (MonadUnliftIO m, MonadReader ChatController m) type ChatMonad m = (ChatMonad' m, MonadError ChatError m) +chatReadVar :: ChatMonad' m => (ChatController -> TVar a) -> m a +chatReadVar f = asks f >>= readTVarIO +{-# INLINE chatReadVar #-} + +chatWriteVar :: ChatMonad' m => (ChatController -> TVar a) -> a -> m () +chatWriteVar f value = asks f >>= atomically . (`writeTVar` value) +{-# INLINE chatWriteVar #-} + tryChatError :: ChatMonad m => m a -> m (Either ChatError a) tryChatError = tryAllErrors mkChatError {-# INLINE tryChatError #-} diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 67154f50af..79463d2107 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -40,6 +40,8 @@ import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol (AgentMsgId, MsgMeta (..), MsgReceiptStatus (..)) +import Simplex.Messaging.Crypto.File (CryptoFile (..)) +import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, parseAll, sumTypeJSON) import Simplex.Messaging.Protocol (MsgBody) @@ -462,7 +464,7 @@ data CIFile (d :: MsgDirection) = CIFile { fileId :: Int64, fileName :: String, fileSize :: Integer, - filePath :: Maybe FilePath, -- local file path + fileSource :: Maybe CryptoFile, -- local file path with optional key and nonce fileStatus :: CIFileStatus d, fileProtocol :: FileProtocol } @@ -634,6 +636,14 @@ data CIFileInfo = CIFileInfo } deriving (Show) +mkCIFileInfo :: MsgDirectionI d => CIFile d -> CIFileInfo +mkCIFileInfo CIFile {fileId, fileStatus, fileSource} = + CIFileInfo + { fileId, + fileStatus = Just $ AFS msgDirection fileStatus, + filePath = CF.filePath <$> fileSource + } + data CIStatus (d :: MsgDirection) where CISSndNew :: CIStatus 'MDSnd CISSndSent :: SndCIStatusProgress -> CIStatus 'MDSnd diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 725cf74cf9..95c490a901 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -50,7 +50,7 @@ instance FromField AMsgDirection where fromField = fromIntField_ $ fmap fromMsgD instance ToField MsgDirection where toField = toField . msgDirectionInt -fromIntField_ :: (Typeable a) => (Int64 -> Maybe a) -> Field -> Ok a +fromIntField_ :: Typeable a => (Int64 -> Maybe a) -> Field -> Ok a fromIntField_ fromInt = \case f@(Field (SQLInteger i) _) -> case fromInt i of diff --git a/src/Simplex/Chat/Migrations/M20230827_file_encryption.hs b/src/Simplex/Chat/Migrations/M20230827_file_encryption.hs new file mode 100644 index 0000000000..2e659cac84 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20230827_file_encryption.hs @@ -0,0 +1,20 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20230827_file_encryption where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20230827_file_encryption :: Query +m20230827_file_encryption = + [sql| +ALTER TABLE files ADD COLUMN file_crypto_key BLOB; +ALTER TABLE files ADD COLUMN file_crypto_nonce BLOB; +|] + +down_m20230827_file_encryption :: Query +down_m20230827_file_encryption = + [sql| +ALTER TABLE files DROP COLUMN file_crypto_key; +ALTER TABLE files DROP COLUMN file_crypto_nonce; +|] diff --git a/src/Simplex/Chat/Migrations/M20230829_connections_chat_vrange.hs b/src/Simplex/Chat/Migrations/M20230829_connections_chat_vrange.hs new file mode 100644 index 0000000000..2588553a92 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20230829_connections_chat_vrange.hs @@ -0,0 +1,26 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20230829_connections_chat_vrange where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20230829_connections_chat_vrange :: Query +m20230829_connections_chat_vrange = + [sql| +ALTER TABLE connections ADD COLUMN peer_chat_min_version INTEGER NOT NULL DEFAULT 1; +ALTER TABLE connections ADD COLUMN peer_chat_max_version INTEGER NOT NULL DEFAULT 1; + +ALTER TABLE contact_requests ADD COLUMN peer_chat_min_version INTEGER NOT NULL DEFAULT 1; +ALTER TABLE contact_requests ADD COLUMN peer_chat_max_version INTEGER NOT NULL DEFAULT 1; +|] + +down_m20230829_connections_chat_vrange :: Query +down_m20230829_connections_chat_vrange = + [sql| +ALTER TABLE contact_requests DROP COLUMN peer_chat_max_version; +ALTER TABLE contact_requests DROP COLUMN peer_chat_min_version; + +ALTER TABLE connections DROP COLUMN peer_chat_max_version; +ALTER TABLE connections DROP COLUMN peer_chat_min_version; +|] diff --git a/src/Simplex/Chat/Migrations/M20230903_connections_to_subscribe.hs b/src/Simplex/Chat/Migrations/M20230903_connections_to_subscribe.hs new file mode 100644 index 0000000000..48ad8dbf86 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20230903_connections_to_subscribe.hs @@ -0,0 +1,20 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20230903_connections_to_subscribe where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20230903_connections_to_subscribe :: Query +m20230903_connections_to_subscribe = + [sql| +ALTER TABLE connections ADD COLUMN to_subscribe INTEGER DEFAULT 0 NOT NULL; +CREATE INDEX idx_connections_to_subscribe ON connections(to_subscribe); +|] + +down_m20230903_connections_to_subscribe :: Query +down_m20230903_connections_to_subscribe = + [sql| +DROP INDEX idx_connections_to_subscribe; +ALTER TABLE connections DROP COLUMN to_subscribe; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 76b7ba4a12..c71cc9aa90 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -204,7 +204,9 @@ CREATE TABLE files( agent_snd_file_id BLOB NULL, private_snd_file_descr TEXT NULL, agent_snd_file_deleted INTEGER DEFAULT 0 CHECK(agent_snd_file_deleted NOT NULL), - protocol TEXT NOT NULL DEFAULT 'smp' + protocol TEXT NOT NULL DEFAULT 'smp', + file_crypto_key BLOB, + file_crypto_nonce BLOB ); CREATE TABLE snd_files( file_id INTEGER NOT NULL REFERENCES files ON DELETE CASCADE, @@ -283,6 +285,9 @@ CREATE TABLE connections( security_code TEXT NULL, security_code_verified_at TEXT NULL, auth_err_counter INTEGER DEFAULT 0 CHECK(auth_err_counter NOT NULL), + peer_chat_min_version INTEGER NOT NULL DEFAULT 1, + peer_chat_max_version INTEGER NOT NULL DEFAULT 1, + to_subscribe INTEGER DEFAULT 0 NOT NULL, FOREIGN KEY(snd_file_id, connection_id) REFERENCES snd_files(file_id, connection_id) ON DELETE CASCADE @@ -316,6 +321,8 @@ CREATE TABLE contact_requests( user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, updated_at TEXT CHECK(updated_at NOT NULL), xcontact_id BLOB, + peer_chat_min_version INTEGER NOT NULL DEFAULT 1, + peer_chat_max_version INTEGER NOT NULL DEFAULT 1, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON UPDATE CASCADE @@ -705,3 +712,4 @@ CREATE INDEX idx_chat_items_user_id_item_status ON chat_items( user_id, item_status ); +CREATE INDEX idx_connections_to_subscribe ON connections(to_subscribe); diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 6e62fbce06..57113dea61 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -35,6 +35,8 @@ import GHC.Generics (Generic) import Simplex.Chat import Simplex.Chat.Controller import Simplex.Chat.Markdown (ParsedMarkdown (..), parseMaybeMarkdownList) +import Simplex.Chat.Mobile.File +import Simplex.Chat.Mobile.Shared import Simplex.Chat.Mobile.WebRTC import Simplex.Chat.Options import Simplex.Chat.Store @@ -69,6 +71,14 @@ foreign export ccall "chat_encrypt_media" cChatEncryptMedia :: CString -> Ptr Wo foreign export ccall "chat_decrypt_media" cChatDecryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString +foreign export ccall "chat_write_file" cChatWriteFile :: CString -> Ptr Word8 -> CInt -> IO CJSONString + +foreign export ccall "chat_read_file" cChatReadFile :: CString -> CString -> CString -> IO (Ptr Word8) + +foreign export ccall "chat_encrypt_file" cChatEncryptFile :: CString -> CString -> IO CJSONString + +foreign export ccall "chat_decrypt_file" cChatDecryptFile :: CString -> CString -> CString -> CString -> IO CString + -- | check / migrate database and initialize chat controller on success cChatMigrateInit :: CString -> CString -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString cChatMigrateInit fp key conf ctrl = do @@ -151,8 +161,6 @@ defaultMobileConfig = logLevel = CLLError } -type CJSONString = CString - getActiveUser_ :: SQLiteStore -> IO (Maybe User) getActiveUser_ st = find activeUser <$> withTransaction st getUsers diff --git a/src/Simplex/Chat/Mobile/File.hs b/src/Simplex/Chat/Mobile/File.hs new file mode 100644 index 0000000000..9dc4b5c982 --- /dev/null +++ b/src/Simplex/Chat/Mobile/File.hs @@ -0,0 +1,155 @@ +{-# LANGUAGE BangPatterns #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TupleSections #-} + +module Simplex.Chat.Mobile.File + ( cChatWriteFile, + cChatReadFile, + cChatEncryptFile, + cChatDecryptFile, + WriteFileResult (..), + ReadFileResult (..), + chatWriteFile, + chatReadFile, + ) +where + +import Control.Monad +import Control.Monad.Except +import Control.Monad.IO.Class +import Data.Aeson (ToJSON) +import qualified Data.Aeson as J +import Data.ByteString (ByteString) +import qualified Data.ByteString as B +import qualified Data.ByteString.Lazy as LB +import qualified Data.ByteString.Lazy.Char8 as LB' +import Data.Char (chr) +import Data.Either (fromLeft) +import Data.Word (Word8, Word32) +import Foreign.C +import Foreign.Marshal.Alloc (mallocBytes) +import Foreign.Ptr +import Foreign.Storable (poke) +import GHC.Generics (Generic) +import Simplex.Chat.Mobile.Shared +import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..), CryptoFileHandle, FTCryptoError (..)) +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 + = WFResult {cryptoArgs :: CryptoFileArgs} + | WFError {writeError :: String} + deriving (Generic) + +instance ToJSON WriteFileResult where toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "WF" + +cChatWriteFile :: CString -> Ptr Word8 -> CInt -> IO CJSONString +cChatWriteFile cPath ptr len = do + path <- peekCString cPath + s <- getByteString ptr len + r <- chatWriteFile path s + newCAString $ LB'.unpack $ J.encode r + +chatWriteFile :: FilePath -> ByteString -> IO WriteFileResult +chatWriteFile path s = do + cfArgs <- CF.randomArgs + let file = CryptoFile path $ Just cfArgs + either WFError (\_ -> WFResult cfArgs) + <$> runCatchExceptT (withExceptT show $ CF.writeFile file $ LB.fromStrict s) + +data ReadFileResult + = RFResult {fileSize :: Int} + | RFError {readError :: String} + deriving (Generic) + +instance ToJSON ReadFileResult where toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RF" + +cChatReadFile :: CString -> CString -> CString -> IO (Ptr Word8) +cChatReadFile cPath cKey cNonce = do + path <- peekCString cPath + key <- B.packCString cKey + nonce <- B.packCString cNonce + chatReadFile path key nonce >>= \case + Left e -> castPtr <$> newCString (chr 1 : e) + Right s -> do + let s' = LB.toStrict s + len = B.length s' + ptr <- mallocBytes $ len + 5 + poke ptr 0 + poke (ptr `plusPtr` 1) (fromIntegral len :: Word32) + putByteString (ptr `plusPtr` 5) s' + pure ptr + +chatReadFile :: FilePath -> ByteString -> ByteString -> IO (Either String LB.ByteString) +chatReadFile path keyStr nonceStr = runCatchExceptT $ do + key <- liftEither $ strDecode keyStr + nonce <- liftEither $ strDecode nonceStr + let file = CryptoFile path $ Just $ CFArgs key nonce + withExceptT show $ CF.readFile file + +cChatEncryptFile :: CString -> CString -> IO CJSONString +cChatEncryptFile cFromPath cToPath = do + 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 <$> runCatchExceptT encrypt + where + encrypt = do + cfArgs <- liftIO $ CF.randomArgs + let toFile = CryptoFile toPath $ Just cfArgs + withExceptT show $ + withFile fromPath ReadMode $ \r -> CF.withFile toFile WriteMode $ \w -> do + encryptChunks r w + liftIO $ CF.hPutTag w + pure cfArgs + encryptChunks r w = do + ch <- liftIO $ LB.hGet r chunkSize + unless (LB.null ch) $ liftIO $ CF.hPut w ch + unless (LB.length ch < chunkSize) $ encryptChunks r w + +cChatDecryptFile :: CString -> CString -> CString -> CString -> IO CString +cChatDecryptFile cFromPath cKey cNonce cToPath = do + fromPath <- peekCString cFromPath + key <- B.packCString cKey + nonce <- B.packCString cNonce + toPath <- peekCString cToPath + r <- chatDecryptFile fromPath key nonce toPath + newCAString r + +chatDecryptFile :: FilePath -> ByteString -> ByteString -> FilePath -> IO String +chatDecryptFile fromPath keyStr nonceStr toPath = fromLeft "" <$> runCatchExceptT decrypt + where + decrypt = do + key <- liftEither $ strDecode keyStr + nonce <- liftEither $ strDecode nonceStr + let fromFile = CryptoFile fromPath $ Just $ CFArgs key nonce + size <- liftIO $ CF.getFileContentsSize fromFile + withExceptT show $ + CF.withFile fromFile ReadMode $ \r -> withFile toPath WriteMode $ \w -> do + decryptChunks r w size + CF.hGetTag r + decryptChunks :: CryptoFileHandle -> Handle -> Integer -> ExceptT FTCryptoError IO () + decryptChunks r w !size = do + let chSize = min size chunkSize + chSize' = fromIntegral chSize + size' = size - chSize + ch <- liftIO $ CF.hGet r chSize' + when (B.length ch /= chSize') $ throwError $ FTCEFileIOError "encrypting file: unexpected EOF" + 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/src/Simplex/Chat/Mobile/Shared.hs b/src/Simplex/Chat/Mobile/Shared.hs new file mode 100644 index 0000000000..a73a25fb6e --- /dev/null +++ b/src/Simplex/Chat/Mobile/Shared.hs @@ -0,0 +1,19 @@ +module Simplex.Chat.Mobile.Shared where + +import qualified Data.ByteString as B +import Data.ByteString.Internal (ByteString (PS), memcpy) +import Foreign.C (CInt, CString) +import Foreign (Ptr, Word8, newForeignPtr_, plusPtr) +import Foreign.ForeignPtr.Unsafe + +type CJSONString = CString + +getByteString :: Ptr Word8 -> CInt -> IO ByteString +getByteString ptr len = do + fp <- newForeignPtr_ ptr + pure $ PS fp 0 $ fromIntegral len + +putByteString :: Ptr Word8 -> ByteString -> IO () +putByteString ptr bs@(PS fp offset _) = do + let p = unsafeForeignPtrToPtr fp `plusPtr` offset + memcpy ptr p $ B.length bs diff --git a/src/Simplex/Chat/Mobile/WebRTC.hs b/src/Simplex/Chat/Mobile/WebRTC.hs index 98b622b5dc..7840a069fa 100644 --- a/src/Simplex/Chat/Mobile/WebRTC.hs +++ b/src/Simplex/Chat/Mobile/WebRTC.hs @@ -14,16 +14,15 @@ import Control.Monad.IO.Class import qualified Crypto.Cipher.Types as AES import Data.Bifunctor (bimap) import qualified Data.ByteArray as BA +import Data.ByteString (ByteString) import qualified Data.ByteString as B import qualified Data.ByteString.Base64.URL as U -import Data.ByteString.Internal (ByteString (PS), memcpy) import Data.Either (fromLeft) import Data.Word (Word8) import Foreign.C (CInt, CString, newCAString) -import Foreign.ForeignPtr (newForeignPtr_) -import Foreign.ForeignPtr.Unsafe (unsafeForeignPtrToPtr) -import Foreign.Ptr (Ptr, plusPtr) +import Foreign.Ptr (Ptr) import qualified Simplex.Messaging.Crypto as C +import Simplex.Chat.Mobile.Shared cChatEncryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString cChatEncryptMedia = cTransformMedia chatEncryptMedia @@ -34,16 +33,10 @@ cChatDecryptMedia = cTransformMedia chatDecryptMedia cTransformMedia :: (ByteString -> ByteString -> ExceptT String IO ByteString) -> CString -> Ptr Word8 -> CInt -> IO CString cTransformMedia f cKey cFrame cFrameLen = do key <- B.packCString cKey - frame <- getFrame + frame <- getByteString cFrame cFrameLen runExceptT (f key frame >>= liftIO . putFrame) >>= newCAString . fromLeft "" where - getFrame = do - fp <- newForeignPtr_ cFrame - pure $ PS fp 0 $ fromIntegral cFrameLen - putFrame bs@(PS fp offset _) = do - let len = B.length bs - p = unsafeForeignPtrToPtr fp `plusPtr` offset - when (len <= fromIntegral cFrameLen) $ memcpy cFrame p len + putFrame s = when (B.length s <= fromIntegral cFrameLen) $ putByteString cFrame s {-# INLINE cTransformMedia #-} chatEncryptMedia :: ByteString -> ByteString -> ExceptT String IO ByteString diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 3cb7e94f9a..adef3e30a2 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -48,6 +48,17 @@ import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) +import Simplex.Messaging.Version hiding (version) + +currentChatVersion :: Version +currentChatVersion = 2 + +supportedChatVRange :: VersionRange +supportedChatVRange = mkVersionRange 1 currentChatVersion + +-- version range that supports skipping establishing direct connections in a group +groupNoDirectVRange :: VersionRange +groupNoDirectVRange = mkVersionRange 2 currentChatVersion data ConnectionEntity = RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact} @@ -104,7 +115,8 @@ data AppMessage (e :: MsgEncoding) where -- chat message is sent as JSON with these properties data AppMessageJson = AppMessageJson - { msgId :: Maybe SharedMsgId, + { v :: Maybe ChatVersionRange, + msgId :: Maybe SharedMsgId, event :: Text, params :: J.Object } @@ -163,7 +175,11 @@ instance ToJSON MsgRef where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} -data ChatMessage e = ChatMessage {msgId :: Maybe SharedMsgId, chatMsgEvent :: ChatMsgEvent e} +data ChatMessage e = ChatMessage + { chatVRange :: VersionRange, + msgId :: Maybe SharedMsgId, + chatMsgEvent :: ChatMsgEvent e + } deriving (Eq, Show) data AChatMessage = forall e. MsgEncodingI e => ACMsg (SMsgEncoding e) (ChatMessage e) @@ -726,17 +742,17 @@ appBinaryToCM :: AppMessageBinary -> Either String (ChatMessage 'Binary) appBinaryToCM AppMessageBinary {msgId, tag, body} = do eventTag <- strDecode $ B.singleton tag chatMsgEvent <- parseAll (msg eventTag) body - pure ChatMessage {msgId, chatMsgEvent} + pure ChatMessage {chatVRange = chatInitialVRange, msgId, chatMsgEvent} where msg :: CMEventTag 'Binary -> A.Parser (ChatMsgEvent 'Binary) msg = \case BFileChunk_ -> BFileChunk <$> (SharedMsgId <$> smpP) <*> (unIFC <$> smpP) appJsonToCM :: AppMessageJson -> Either String (ChatMessage 'Json) -appJsonToCM AppMessageJson {msgId, event, params} = do +appJsonToCM AppMessageJson {v, msgId, event, params} = do eventTag <- strDecode $ encodeUtf8 event chatMsgEvent <- msg eventTag - pure ChatMessage {msgId, chatMsgEvent} + pure ChatMessage {chatVRange = maybe chatInitialVRange fromChatVRange v, msgId, chatMsgEvent} where p :: FromJSON a => J.Key -> Either String a p key = JT.parseEither (.: key) params @@ -786,11 +802,11 @@ appJsonToCM AppMessageJson {msgId, event, params} = do key .=? value = maybe id ((:) . (key .=)) value chatToAppMessage :: forall e. MsgEncodingI e => ChatMessage e -> AppMessage e -chatToAppMessage ChatMessage {msgId, chatMsgEvent} = case encoding @e of +chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @e of SBinary -> let (binaryMsgId, body) = toBody chatMsgEvent in AMBinary AppMessageBinary {msgId = binaryMsgId, tag = B.head $ strEncode tag, body} - SJson -> AMJson AppMessageJson {msgId, event = textEncode tag, params = params chatMsgEvent} + SJson -> AMJson AppMessageJson {v = Just $ ChatVersionRange chatVRange, msgId, event = textEncode tag, params = params chatMsgEvent} where tag = toCMEventTag chatMsgEvent o :: [(J.Key, J.Value)] -> J.Object @@ -806,7 +822,7 @@ chatToAppMessage ChatMessage {msgId, chatMsgEvent} = case encoding @e of XMsgUpdate msgId' content ttl live -> o $ ("ttl" .=? ttl) $ ("live" .=? live) ["msgId" .= msgId', "content" .= content] XMsgDel msgId' memberId -> o $ ("memberId" .=? memberId) ["msgId" .= msgId'] XMsgDeleted -> JM.empty - XMsgReact msgId' memberId reaction add -> o $ ("memberId" .=? memberId) ["msgId" .= msgId', "reaction" .= reaction, "add" .= add] + XMsgReact msgId' memberId reaction add -> o $ ("memberId" .=? memberId) ["msgId" .= msgId', "reaction" .= reaction, "add" .= add] XFile fileInv -> o ["file" .= fileInv] XFileAcpt fileName -> o ["fileName" .= fileName] XFileAcptInv sharedMsgId fileConnReq fileName -> o $ ("fileConnReq" .=? fileConnReq) ["msgId" .= sharedMsgId, "fileName" .= fileName] diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 0e48efa019..dd7848727e 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -1,4 +1,5 @@ {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} @@ -8,25 +9,31 @@ module Simplex.Chat.Store.Connections ( getConnectionEntity, + getConnectionsToSubscribe, + unsetConnectionToSubscribe, ) where import Control.Applicative ((<|>)) +import Control.Monad import Control.Monad.Except import Data.Int (Int64) -import Data.Maybe (fromMaybe) +import Data.Maybe (catMaybes, fromMaybe) import Data.Text (Text) import Data.Time.Clock (UTCTime (..)) -import Database.SQLite.Simple ((:.) (..)) +import Database.SQLite.Simple (Only (..), (:.) (..)) import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Store.Files import Simplex.Chat.Store.Groups +import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Types.Preferences +import Simplex.Messaging.Agent.Protocol (ConnId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, firstRow') import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Util (eitherToMaybe) getConnectionEntity :: DB.Connection -> User -> AgentConnId -> ExceptT StoreError IO ConnectionEntity getConnectionEntity db user@User {userId, userContactId} agentConnId = do @@ -51,7 +58,8 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do db [sql| SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, custom_user_profile_id, - conn_status, conn_type, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, auth_err_counter + conn_status, conn_type, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, auth_err_counter, + peer_chat_min_version, peer_chat_max_version FROM connections WHERE user_id = ? AND agent_conn_id = ? |] @@ -143,3 +151,17 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do userContact_ :: [(ConnReqContact, Maybe GroupId)] -> Either StoreError UserContact userContact_ [(cReq, groupId)] = Right UserContact {userContactLinkId, connReqContact = cReq, groupId} userContact_ _ = Left SEUserContactLinkNotFound + +getConnectionsToSubscribe :: DB.Connection -> IO ([ConnId], [ConnectionEntity]) +getConnectionsToSubscribe db = do + aConnIds <- map fromOnly <$> DB.query_ db "SELECT agent_conn_id FROM connections where to_subscribe = 1" + entities <- forM aConnIds $ \acId -> do + getUserByAConnId db acId >>= \case + Just user -> eitherToMaybe <$> runExceptT (getConnectionEntity db user acId) + Nothing -> pure Nothing + unsetConnectionToSubscribe db + let connIds = map (\(AgentConnId connId) -> connId) aConnIds + pure (connIds, catMaybes entities) + +unsetConnectionToSubscribe :: DB.Connection -> IO () +unsetConnectionToSubscribe db = DB.execute_ db "UPDATE connections SET to_subscribe = 0 WHERE to_subscribe = 1" diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 34bb754090..8283379b68 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -80,6 +80,8 @@ import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol (ConnId, InvitationId, UserId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Protocol (SubscriptionMode (..)) +import Simplex.Messaging.Version getPendingContactConnection :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO PendingContactConnection getPendingContactConnection db userId connId = do @@ -113,8 +115,8 @@ deletePendingContactConnection db userId connId = |] (userId, connId, ConnContact) -createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> IO PendingContactConnection -createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId = do +createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> SubscriptionMode -> IO PendingContactConnection +createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId subMode = do createdAt <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId createdAt) incognitoProfile let pccConnStatus = ConnJoined @@ -123,10 +125,10 @@ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile grou [sql| INSERT INTO connections ( user_id, agent_conn_id, conn_status, conn_type, - via_contact_uri_hash, xcontact_id, custom_user_profile_id, via_group_link, group_link_id, created_at, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?,?,?) + via_contact_uri_hash, xcontact_id, custom_user_profile_id, via_group_link, group_link_id, created_at, updated_at, to_subscribe + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) |] - ((userId, acId, pccConnStatus, ConnContact, cReqHash, xContactId) :. (customUserProfileId, isJust groupLinkId, groupLinkId, createdAt, createdAt)) + ((userId, acId, pccConnStatus, ConnContact, cReqHash, xContactId) :. (customUserProfileId, isJust groupLinkId, groupLinkId, createdAt, createdAt, subMode == SMOnlyCreate)) pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, viaUserContactLink = Nothing, groupLinkId, customUserProfileId, connReqInv = Nothing, localAlias = "", createdAt, updatedAt = createdAt} @@ -148,7 +150,8 @@ getConnReqContactXContactId db user@User {userId} cReqHash = do cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id JOIN connections c ON c.contact_id = ct.contact_id @@ -165,17 +168,17 @@ getConnReqContactXContactId db user@User {userId} cReqHash = do "SELECT xcontact_id FROM connections WHERE user_id = ? AND via_contact_uri_hash = ? LIMIT 1" (userId, cReqHash) -createDirectConnection :: DB.Connection -> User -> ConnId -> ConnReqInvitation -> ConnStatus -> Maybe Profile -> IO PendingContactConnection -createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile = do +createDirectConnection :: DB.Connection -> User -> ConnId -> ConnReqInvitation -> ConnStatus -> Maybe Profile -> SubscriptionMode -> IO PendingContactConnection +createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile subMode = do createdAt <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId createdAt) incognitoProfile DB.execute db [sql| INSERT INTO connections - (user_id, agent_conn_id, conn_req_inv, conn_status, conn_type, custom_user_profile_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?) + (user_id, agent_conn_id, conn_req_inv, conn_status, conn_type, custom_user_profile_id, created_at, updated_at, to_subscribe) VALUES (?,?,?,?,?,?,?,?,?) |] - (userId, acId, cReq, pccConnStatus, ConnContact, customUserProfileId, createdAt, createdAt) + (userId, acId, cReq, pccConnStatus, ConnContact, customUserProfileId, createdAt, createdAt, subMode == SMOnlyCreate) pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = False, viaUserContactLink = Nothing, groupLinkId = Nothing, customUserProfileId, connReqInv = Just cReq, localAlias = "", createdAt, updatedAt = createdAt} @@ -416,8 +419,8 @@ getUserContacts db user@User {userId} = do contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND deleted = 0" (Only userId) rights <$> mapM (runExceptT . getContact db user) contactIds -createOrUpdateContactRequest :: DB.Connection -> User -> Int64 -> InvitationId -> Profile -> Maybe XContactId -> ExceptT StoreError IO ContactOrRequest -createOrUpdateContactRequest db user@User {userId} userContactLinkId invId Profile {displayName, fullName, image, contactLink, preferences} xContactId_ = +createOrUpdateContactRequest :: DB.Connection -> User -> Int64 -> InvitationId -> VersionRange -> Profile -> Maybe XContactId -> ExceptT StoreError IO ContactOrRequest +createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (VersionRange minV maxV) Profile {displayName, fullName, image, contactLink, preferences} xContactId_ = liftIO (maybeM getContact' xContactId_) >>= \case Just contact -> pure $ CORContact contact Nothing -> CORRequest <$> createOrUpdate_ @@ -446,10 +449,10 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId Profi db [sql| INSERT INTO contact_requests - (user_contact_link_id, agent_invitation_id, contact_profile_id, local_display_name, user_id, created_at, updated_at, xcontact_id) - VALUES (?,?,?,?,?,?,?,?) + (user_contact_link_id, agent_invitation_id, peer_chat_min_version, peer_chat_max_version, contact_profile_id, local_display_name, user_id, created_at, updated_at, xcontact_id) + VALUES (?,?,?,?,?,?,?,?,?,?) |] - (userContactLinkId, invId, profileId, ldn, userId, currentTs, currentTs, xContactId_) + (userContactLinkId, invId, minV, maxV, profileId, ldn, userId, currentTs, currentTs, xContactId_) insertedRowId db getContact' :: XContactId -> IO (Maybe Contact) getContact' xContactId = @@ -463,7 +466,8 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId Profi cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id LEFT JOIN connections c ON c.contact_id = ct.contact_id @@ -480,7 +484,8 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId Profi [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, - c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, p.preferences, cr.created_at, cr.updated_at + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, p.preferences, cr.created_at, cr.updated_at, + cr.peer_chat_min_version, cr.peer_chat_max_version FROM contact_requests cr JOIN connections c USING (user_contact_link_id) JOIN contact_profiles p USING (contact_profile_id) @@ -494,10 +499,26 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId Profi currentTs <- liftIO getCurrentTime updateProfile currentTs if displayName == oldDisplayName - then Right <$> DB.execute db "UPDATE contact_requests SET agent_invitation_id = ?, updated_at = ? WHERE user_id = ? AND contact_request_id = ?" (invId, currentTs, userId, cReqId) + then + Right + <$> DB.execute + db + [sql| + UPDATE contact_requests + SET agent_invitation_id = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, updated_at = ? + WHERE user_id = ? AND contact_request_id = ? + |] + (invId, minV, maxV, currentTs, userId, cReqId) else withLocalDisplayName db userId displayName $ \ldn -> Right <$> do - DB.execute db "UPDATE contact_requests SET agent_invitation_id = ?, local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_request_id = ?" (invId, ldn, currentTs, userId, cReqId) + DB.execute + db + [sql| + UPDATE contact_requests + SET agent_invitation_id = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, local_display_name = ?, updated_at = ? + WHERE user_id = ? AND contact_request_id = ? + |] + (invId, minV, maxV, ldn, currentTs, userId, cReqId) DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (oldLdn, userId) where updateProfile currentTs = @@ -532,7 +553,8 @@ getContactRequest db User {userId} contactRequestId = [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, - c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, p.preferences, cr.created_at, cr.updated_at + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, p.preferences, cr.created_at, cr.updated_at, + cr.peer_chat_min_version, cr.peer_chat_max_version FROM contact_requests cr JOIN connections c USING (user_contact_link_id) JOIN contact_profiles p USING (contact_profile_id) @@ -571,8 +593,8 @@ deleteContactRequest db User {userId} contactRequestId = do (userId, userId, contactRequestId) DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ?" (userId, contactRequestId) -createAcceptedContact :: DB.Connection -> User -> ConnId -> ContactName -> ProfileId -> Profile -> Int64 -> Maybe XContactId -> Maybe IncognitoProfile -> IO Contact -createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}} agentConnId localDisplayName profileId profile userContactLinkId xContactId incognitoProfile = do +createAcceptedContact :: DB.Connection -> User -> ConnId -> VersionRange -> ContactName -> ProfileId -> Profile -> Int64 -> Maybe XContactId -> Maybe IncognitoProfile -> SubscriptionMode -> IO Contact +createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}} agentConnId cReqChatVRange localDisplayName profileId profile userContactLinkId xContactId incognitoProfile subMode = do DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) createdAt <- getCurrentTime customUserProfileId <- forM incognitoProfile $ \case @@ -584,7 +606,7 @@ createAcceptedContact db user@User {userId, profile = LocalProfile {preferences} "INSERT INTO contacts (user_id, local_display_name, contact_profile_id, enable_ntfs, user_preferences, created_at, updated_at, chat_ts, xcontact_id) VALUES (?,?,?,?,?,?,?,?,?)" (userId, localDisplayName, profileId, True, userPreferences, createdAt, createdAt, createdAt, xContactId) contactId <- insertedRowId db - activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId Nothing (Just userContactLinkId) customUserProfileId 0 createdAt + activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId cReqChatVRange Nothing (Just userContactLinkId) customUserProfileId 0 createdAt subMode let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn, viaGroup = Nothing, contactUsed = False, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = createdAt, updatedAt = createdAt, chatTs = Just createdAt} @@ -600,7 +622,7 @@ getContact_ :: DB.Connection -> User -> Int64 -> Bool -> ExceptT StoreError IO C getContact_ db user@User {userId} contactId deleted = ExceptT . fmap join . firstRow (toContactOrError user) (SEContactNotFound contactId) $ DB.query - db + db [sql| SELECT -- Contact @@ -608,7 +630,8 @@ getContact_ db user@User {userId} contactId deleted = cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id LEFT JOIN connections c ON c.contact_id = ct.contact_id @@ -656,7 +679,8 @@ getContactConnections db userId Contact {contactId} = db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter + c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN contacts ct ON ct.contact_id = c.contact_id WHERE c.user_id = ? AND ct.user_id = ? AND ct.contact_id = ? @@ -672,7 +696,8 @@ getConnectionById db User {userId} connId = ExceptT $ do db [sql| SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, custom_user_profile_id, - conn_status, conn_type, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, auth_err_counter + conn_status, conn_type, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, auth_err_counter, + peer_chat_min_version, peer_chat_max_version FROM connections WHERE user_id = ? AND connection_id = ? |] diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index e33268675a..001c41d2d4 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -56,6 +56,7 @@ module Simplex.Chat.Store.Files startRcvInlineFT, xftpAcceptRcvFT, setRcvFileToReceive, + setFileCryptoArgs, getRcvFilesToReceive, setRcvFTAgentDeleted, updateRcvFileStatus, @@ -86,18 +87,22 @@ import Data.Time.Clock (UTCTime (..), getCurrentTime, nominalDay) import Data.Type.Equality import Database.SQLite.Simple (Only (..), (:.) (..)) import Database.SQLite.Simple.QQ (sql) +import Simplex.Chat.Messages +import Simplex.Chat.Messages.CIContent +import Simplex.Chat.Protocol import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Messages import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared -import Simplex.Chat.Messages -import Simplex.Chat.Messages.CIContent -import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Util (week) import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, UserId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) +import qualified Simplex.Messaging.Crypto.File as CF +import Simplex.Messaging.Protocol (SubscriptionMode (..)) getLiveSndFileTransfers :: DB.Connection -> User -> IO [SndFileTransfer] getLiveSndFileTransfers db User {userId} = do @@ -154,8 +159,8 @@ getPendingSndChunks db fileId connId = |] (fileId, connId) -createSndDirectFileTransfer :: DB.Connection -> UserId -> Contact -> FilePath -> FileInvitation -> Maybe ConnId -> Integer -> IO FileTransferMeta -createSndDirectFileTransfer db userId Contact {contactId} filePath FileInvitation {fileName, fileSize, fileInline} acId_ chunkSize = do +createSndDirectFileTransfer :: DB.Connection -> UserId -> Contact -> FilePath -> FileInvitation -> Maybe ConnId -> Integer -> SubscriptionMode -> IO FileTransferMeta +createSndDirectFileTransfer db userId Contact {contactId} filePath FileInvitation {fileName, fileSize, fileInline} acId_ chunkSize subMode = do currentTs <- getCurrentTime DB.execute db @@ -163,7 +168,7 @@ createSndDirectFileTransfer db userId Contact {contactId} filePath FileInvitatio ((userId, contactId, fileName, filePath, fileSize, chunkSize) :. (fileInline, CIFSSndStored, FPSMP, currentTs, currentTs)) fileId <- insertedRowId db forM_ acId_ $ \acId -> do - Connection {connId} <- createSndFileConnection_ db userId fileId acId + Connection {connId} <- createSndFileConnection_ db userId fileId acId subMode let fileStatus = FSNew DB.execute db @@ -171,10 +176,10 @@ createSndDirectFileTransfer db userId Contact {contactId} filePath FileInvitatio (fileId, fileStatus, fileInline, connId, currentTs, currentTs) pure FileTransferMeta {fileId, xftpSndFile = Nothing, fileName, filePath, fileSize, fileInline, chunkSize, cancelled = False} -createSndDirectFTConnection :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> IO () -createSndDirectFTConnection db user@User {userId} fileId (cmdId, acId) = do +createSndDirectFTConnection :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> SubscriptionMode -> IO () +createSndDirectFTConnection db user@User {userId} fileId (cmdId, acId) subMode = do currentTs <- getCurrentTime - Connection {connId} <- createSndFileConnection_ db userId fileId acId + Connection {connId} <- createSndFileConnection_ db userId fileId acId subMode setCommandConnId db user cmdId connId DB.execute db @@ -191,10 +196,10 @@ createSndGroupFileTransfer db userId GroupInfo {groupId} filePath FileInvitation fileId <- insertedRowId db pure FileTransferMeta {fileId, xftpSndFile = Nothing, fileName, filePath, fileSize, fileInline, chunkSize, cancelled = False} -createSndGroupFileTransferConnection :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> GroupMember -> IO () -createSndGroupFileTransferConnection db user@User {userId} fileId (cmdId, acId) GroupMember {groupMemberId} = do +createSndGroupFileTransferConnection :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> GroupMember -> SubscriptionMode -> IO () +createSndGroupFileTransferConnection db user@User {userId} fileId (cmdId, acId) GroupMember {groupMemberId} subMode = do currentTs <- getCurrentTime - Connection {connId} <- createSndFileConnection_ db userId fileId acId + Connection {connId} <- createSndFileConnection_ db userId fileId acId subMode setCommandConnId db user cmdId connId DB.execute db @@ -259,14 +264,14 @@ getSndFTViaMsgDelivery db User {userId} Connection {connId, agentConnId} agentMs (\n -> SndFileTransfer {fileId, fileStatus, fileName, fileSize, chunkSize, filePath, fileDescrId, fileInline, groupMemberId, recipientDisplayName = n, connId, agentConnId}) <$> (contactName_ <|> memberName_) -createSndFileTransferXFTP :: DB.Connection -> User -> ContactOrGroup -> FilePath -> FileInvitation -> AgentSndFileId -> Integer -> IO FileTransferMeta -createSndFileTransferXFTP db User {userId} contactOrGroup filePath FileInvitation {fileName, fileSize} agentSndFileId chunkSize = do +createSndFileTransferXFTP :: DB.Connection -> User -> ContactOrGroup -> CryptoFile -> FileInvitation -> AgentSndFileId -> Integer -> IO FileTransferMeta +createSndFileTransferXFTP db User {userId} contactOrGroup (CryptoFile filePath cryptoArgs) FileInvitation {fileName, fileSize} agentSndFileId chunkSize = do currentTs <- getCurrentTime - let xftpSndFile = Just XFTPSndFile {agentSndFileId, privateSndFileDescr = Nothing, agentSndFileDeleted = False} + let xftpSndFile = Just XFTPSndFile {agentSndFileId, privateSndFileDescr = Nothing, agentSndFileDeleted = False, cryptoArgs} DB.execute db - "INSERT INTO files (contact_id, group_id, user_id, file_name, file_path, file_size, chunk_size, agent_snd_file_id, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)" - (contactAndGroupIds contactOrGroup :. (userId, fileName, filePath, fileSize, chunkSize, agentSndFileId, CIFSSndStored, FPXFTP, currentTs, currentTs)) + "INSERT INTO files (contact_id, group_id, user_id, file_name, file_path, file_crypto_key, file_crypto_nonce, file_size, chunk_size, agent_snd_file_id, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + (contactAndGroupIds contactOrGroup :. (userId, fileName, filePath, CF.fileKey <$> cryptoArgs, CF.fileNonce <$> cryptoArgs, fileSize, chunkSize, agentSndFileId, CIFSSndStored, FPXFTP, currentTs, currentTs)) fileId <- insertedRowId db pure FileTransferMeta {fileId, xftpSndFile, fileName, filePath, fileSize, fileInline = Nothing, chunkSize, cancelled = False} @@ -420,10 +425,10 @@ getChatRefByFileId db User {userId} fileId = |] (userId, fileId) -createSndFileConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> IO Connection -createSndFileConnection_ db userId fileId agentConnId = do +createSndFileConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> SubscriptionMode -> IO Connection +createSndFileConnection_ db userId fileId agentConnId subMode = do currentTs <- getCurrentTime - createConnection_ db userId ConnSndFile (Just fileId) agentConnId Nothing Nothing Nothing 0 currentTs + createConnection_ db userId ConnSndFile (Just fileId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode updateSndFileStatus :: DB.Connection -> SndFileTransfer -> FileStatus -> IO () updateSndFileStatus db SndFileTransfer {fileId, connId} status = do @@ -480,10 +485,9 @@ createRcvFileTransfer :: DB.Connection -> UserId -> Contact -> FileInvitation -> createRcvFileTransfer db userId Contact {contactId, localDisplayName = c} f@FileInvitation {fileName, fileSize, fileConnReq, fileInline, fileDescr} rcvFileInline chunkSize = do currentTs <- liftIO getCurrentTime rfd_ <- mapM (createRcvFD_ db userId currentTs) fileDescr - let getFDId :: RcvFileDescr -> Int64 - getFDId RcvFileDescr{fileDescrId} = fileDescrId - let rfdId = getFDId <$> rfd_ - xftpRcvFile = (\rfd -> XFTPRcvFile {rcvFileDescription = rfd, agentRcvFileId = Nothing, agentRcvFileDeleted = False}) <$> rfd_ + let rfdId = (\RcvFileDescr {fileDescrId} -> fileDescrId) <$> rfd_ + -- cryptoArgs = Nothing here, the decision to encrypt is made when receiving it + xftpRcvFile = (\rfd -> XFTPRcvFile {rcvFileDescription = rfd, agentRcvFileId = Nothing, agentRcvFileDeleted = False, cryptoArgs = Nothing}) <$> rfd_ fileProtocol = if isJust rfd_ then FPXFTP else FPSMP fileId <- liftIO $ do DB.execute @@ -502,10 +506,9 @@ createRcvGroupFileTransfer :: DB.Connection -> UserId -> GroupMember -> FileInvi createRcvGroupFileTransfer db userId GroupMember {groupId, groupMemberId, localDisplayName = c} f@FileInvitation {fileName, fileSize, fileConnReq, fileInline, fileDescr} rcvFileInline chunkSize = do currentTs <- liftIO getCurrentTime rfd_ <- mapM (createRcvFD_ db userId currentTs) fileDescr - let getFDId :: RcvFileDescr -> Int64 - getFDId RcvFileDescr{fileDescrId} = fileDescrId - let rfdId = getFDId <$> rfd_ - xftpRcvFile = (\rfd -> XFTPRcvFile {rcvFileDescription = rfd, agentRcvFileId = Nothing, agentRcvFileDeleted = False}) <$> rfd_ + let rfdId = (\RcvFileDescr {fileDescrId} -> fileDescrId) <$> rfd_ + -- cryptoArgs = Nothing here, the decision to encrypt is made when receiving it + xftpRcvFile = (\rfd -> XFTPRcvFile {rcvFileDescription = rfd, agentRcvFileId = Nothing, agentRcvFileDeleted = False, cryptoArgs = Nothing}) <$> rfd_ fileProtocol = if isJust rfd_ then FPXFTP else FPSMP fileId <- liftIO $ do DB.execute @@ -606,7 +609,7 @@ getRcvFileTransfer db User {userId} fileId = do [sql| SELECT r.file_status, r.file_queue_info, r.group_member_id, f.file_name, f.file_size, f.chunk_size, f.cancelled, cs.local_display_name, m.local_display_name, - f.file_path, r.file_inline, r.rcv_file_inline, r.agent_rcv_file_id, r.agent_rcv_file_deleted, c.connection_id, c.agent_conn_id + f.file_path, f.file_crypto_key, f.file_crypto_nonce, r.file_inline, r.rcv_file_inline, r.agent_rcv_file_id, r.agent_rcv_file_deleted, c.connection_id, c.agent_conn_id FROM rcv_files r JOIN files f USING (file_id) LEFT JOIN connections c ON r.file_id = c.rcv_file_id @@ -620,9 +623,9 @@ getRcvFileTransfer db User {userId} fileId = do where rcvFileTransfer :: Maybe RcvFileDescr -> - (FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe Bool) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, Bool) :. (Maybe Int64, Maybe AgentConnId) -> + (FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe Bool) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, Bool) :. (Maybe Int64, Maybe AgentConnId) -> ExceptT StoreError IO RcvFileTransfer - rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileInline, rcvFileInline, agentRcvFileId, agentRcvFileDeleted) :. (connId_, agentConnId_)) = + rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, agentRcvFileDeleted) :. (connId_, agentConnId_)) = case contactName_ <|> memberName_ of Nothing -> throwError $ SERcvFileInvalid fileId Just name -> do @@ -635,7 +638,8 @@ getRcvFileTransfer db User {userId} fileId = do where ft senderDisplayName fileStatus = let fileInvitation = FileInvitation {fileName, fileSize, fileDigest = Nothing, fileConnReq, fileInline, fileDescr = Nothing} - xftpRcvFile = (\rfd -> XFTPRcvFile {rcvFileDescription = rfd, agentRcvFileId, agentRcvFileDeleted}) <$> rfd_ + cryptoArgs = CFArgs <$> fileKey <*> fileNonce + xftpRcvFile = (\rfd -> XFTPRcvFile {rcvFileDescription = rfd, agentRcvFileId, agentRcvFileDeleted, cryptoArgs}) <$> rfd_ in RcvFileTransfer {fileId, xftpRcvFile, fileInvitation, fileStatus, rcvFileInline, senderDisplayName, chunkSize, cancelled, grpMemberId} rfi = maybe (throwError $ SERcvFileInvalid fileId) pure =<< rfi_ rfi_ = case (filePath_, connId_, agentConnId_) of @@ -643,14 +647,14 @@ getRcvFileTransfer db User {userId} fileId = do _ -> pure Nothing cancelled = fromMaybe False cancelled_ -acceptRcvFileTransfer :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> ConnStatus -> FilePath -> ExceptT StoreError IO AChatItem -acceptRcvFileTransfer db user@User {userId} fileId (cmdId, acId) connStatus filePath = ExceptT $ do +acceptRcvFileTransfer :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> ConnStatus -> FilePath -> SubscriptionMode -> ExceptT StoreError IO AChatItem +acceptRcvFileTransfer db user@User {userId} fileId (cmdId, acId) connStatus filePath subMode = ExceptT $ do currentTs <- getCurrentTime acceptRcvFT_ db user fileId filePath Nothing currentTs DB.execute db - "INSERT INTO connections (agent_conn_id, conn_status, conn_type, rcv_file_id, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" - (acId, connStatus, ConnRcvFile, fileId, userId, currentTs, currentTs) + "INSERT INTO connections (agent_conn_id, conn_status, conn_type, rcv_file_id, user_id, created_at, updated_at, to_subscribe) VALUES (?,?,?,?,?,?,?,?)" + (acId, connStatus, ConnRcvFile, fileId, userId, currentTs, currentTs, subMode == SMOnlyCreate) connId <- insertedRowId db setCommandConnId db user cmdId connId runExceptT $ getChatItemByFileId db user fileId @@ -689,13 +693,21 @@ acceptRcvFT_ db User {userId} fileId filePath rcvFileInline currentTs = do "UPDATE rcv_files SET rcv_file_inline = ?, file_status = ?, updated_at = ? WHERE file_id = ?" (rcvFileInline, FSAccepted, currentTs, fileId) -setRcvFileToReceive :: DB.Connection -> FileTransferId -> IO () -setRcvFileToReceive db fileId = do +setRcvFileToReceive :: DB.Connection -> FileTransferId -> Maybe CryptoFileArgs -> IO () +setRcvFileToReceive db fileId cfArgs_ = do currentTs <- getCurrentTime + DB.execute db "UPDATE rcv_files SET to_receive = 1, updated_at = ? WHERE file_id = ?" (currentTs, fileId) + forM_ cfArgs_ $ \cfArgs -> setFileCryptoArgs_ db fileId cfArgs currentTs + +setFileCryptoArgs :: DB.Connection -> FileTransferId -> CryptoFileArgs -> IO () +setFileCryptoArgs db fileId cfArgs = setFileCryptoArgs_ db fileId cfArgs =<< getCurrentTime + +setFileCryptoArgs_ :: DB.Connection -> FileTransferId -> CryptoFileArgs -> UTCTime -> IO () +setFileCryptoArgs_ db fileId (CFArgs key nonce) currentTs = DB.execute db - "UPDATE rcv_files SET to_receive = 1, updated_at = ? WHERE file_id = ?" - (currentTs, fileId) + "UPDATE files SET file_crypto_key = ?, file_crypto_nonce = ?, updated_at = ? WHERE file_id = ?" + (key, nonce, currentTs, fileId) getRcvFilesToReceive :: DB.Connection -> User -> IO [RcvFileTransfer] getRcvFilesToReceive db user@User {userId} = do @@ -848,15 +860,16 @@ getFileTransferMeta db User {userId} fileId = DB.query db [sql| - SELECT file_name, file_size, chunk_size, file_path, file_inline, agent_snd_file_id, agent_snd_file_deleted, private_snd_file_descr, cancelled + SELECT file_name, file_size, chunk_size, file_path, file_crypto_key, file_crypto_nonce, file_inline, agent_snd_file_id, agent_snd_file_deleted, private_snd_file_descr, cancelled FROM files WHERE user_id = ? AND file_id = ? |] (userId, fileId) where - fileTransferMeta :: (String, Integer, Integer, FilePath, Maybe InlineFileMode, Maybe AgentSndFileId, Bool, Maybe Text, Maybe Bool) -> FileTransferMeta - fileTransferMeta (fileName, fileSize, chunkSize, filePath, fileInline, aSndFileId_, agentSndFileDeleted, privateSndFileDescr, cancelled_) = - let xftpSndFile = (\fId -> XFTPSndFile {agentSndFileId = fId, privateSndFileDescr, agentSndFileDeleted}) <$> aSndFileId_ + fileTransferMeta :: (String, Integer, Integer, FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe AgentSndFileId, Bool, Maybe Text, Maybe Bool) -> FileTransferMeta + fileTransferMeta (fileName, fileSize, chunkSize, filePath, fileKey, fileNonce, fileInline, aSndFileId_, agentSndFileDeleted, privateSndFileDescr, cancelled_) = + let cryptoArgs = CFArgs <$> fileKey <*> fileNonce + xftpSndFile = (\fId -> XFTPSndFile {agentSndFileId = fId, privateSndFileDescr, agentSndFileDeleted, cryptoArgs}) <$> aSndFileId_ in FileTransferMeta {fileId, xftpSndFile, fileName, fileSize, chunkSize, filePath, fileInline, cancelled = fromMaybe False cancelled_} getContactFileInfo :: DB.Connection -> User -> Contact -> IO [CIFileInfo] diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index d48074e374..9ead2ff3e1 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -86,6 +86,7 @@ module Simplex.Chat.Store.Groups updateGroupSettings, getXGrpMemIntroContDirect, getXGrpMemIntroContGroup, + getHostConnId, ) where @@ -109,7 +110,9 @@ import Simplex.Messaging.Agent.Protocol (ConnId, UserId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Protocol (SubscriptionMode) import Simplex.Messaging.Util (eitherToMaybe) +import Simplex.Messaging.Version import UnliftIO.STM type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe Bool, Maybe Bool, Bool, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime) :. GroupMemberRow @@ -138,8 +141,8 @@ toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just member Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus) :. (invitedById, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, contactPreferences)) toMaybeGroupMember _ _ = Nothing -createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> ConnReqContact -> GroupLinkId -> GroupMemberRole -> ExceptT StoreError IO () -createGroupLink db User {userId} groupInfo@GroupInfo {groupId, localDisplayName} agentConnId cReq groupLinkId memberRole = +createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> ConnReqContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO () +createGroupLink db User {userId} groupInfo@GroupInfo {groupId, localDisplayName} agentConnId cReq groupLinkId memberRole subMode = checkConstraint (SEDuplicateGroupLink groupInfo) . liftIO $ do currentTs <- getCurrentTime DB.execute @@ -147,7 +150,7 @@ createGroupLink db User {userId} groupInfo@GroupInfo {groupId, localDisplayName} "INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" (userId, groupId, groupLinkId, "group_link_" <> localDisplayName, cReq, memberRole, True, currentTs, currentTs) userContactLinkId <- insertedRowId db - void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId Nothing Nothing Nothing 0 currentTs + void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode getGroupLinkConnection :: DB.Connection -> User -> GroupInfo -> ExceptT StoreError IO Connection getGroupLinkConnection db User {userId} groupInfo@GroupInfo {groupId} = @@ -156,7 +159,8 @@ getGroupLinkConnection db User {userId} groupInfo@GroupInfo {groupId} = db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter + c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id WHERE c.user_id = ? AND uc.user_id = ? AND uc.group_id = ? @@ -237,7 +241,8 @@ getGroupAndMember db User {userId, userContactId} groupMemberId = m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter + c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) JOIN groups g ON g.group_id = m.group_id @@ -483,20 +488,21 @@ getUserGroupsWithSummary db user _contactId_ search_ = -- the statuses on non-current members should match memberCurrent' function getGroupSummary :: DB.Connection -> User -> GroupId -> IO GroupSummary getGroupSummary db User {userId} groupId = do - currentMembers_ <- maybeFirstRow fromOnly $ - DB.query - db - [sql| - SELECT count (m.group_member_id) - FROM groups g - JOIN group_members m USING (group_id) - WHERE g.user_id = ? - AND g.group_id = ? - AND m.member_status != ? - AND m.member_status != ? - AND m.member_status != ? - |] - (userId, groupId, GSMemRemoved, GSMemLeft, GSMemInvited) + currentMembers_ <- + maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT count (m.group_member_id) + FROM groups g + JOIN group_members m USING (group_id) + WHERE g.user_id = ? + AND g.group_id = ? + AND m.member_status != ? + AND m.member_status != ? + AND m.member_status != ? + |] + (userId, groupId, GSMemRemoved, GSMemLeft, GSMemInvited) pure GroupSummary {currentMembers = fromMaybe 0 currentMembers_} getContactGroupPreferences :: DB.Connection -> User -> Contact -> IO [FullGroupPreferences] @@ -529,13 +535,14 @@ groupMemberQuery = m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter + c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN connections c ON c.connection_id = ( SELECT max(cc.connection_id) FROM connections cc - where cc.user_id = ? AND cc.group_member_id = m.group_member_id + WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id ) |] @@ -613,12 +620,12 @@ getGroupInvitation db user groupId = firstRow fromOnly (SEGroupNotFound groupId) $ DB.query db "SELECT g.inv_queue_info FROM groups g WHERE g.group_id = ? AND g.user_id = ?" (groupId, userId) -createNewContactMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupId -> Contact -> GroupMemberRole -> ConnId -> ConnReqInvitation -> ExceptT StoreError IO GroupMember -createNewContactMember db gVar User {userId, userContactId} groupId Contact {contactId, localDisplayName, profile} memberRole agentConnId connRequest = +createNewContactMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupId -> Contact -> GroupMemberRole -> ConnId -> ConnReqInvitation -> SubscriptionMode -> ExceptT StoreError IO GroupMember +createNewContactMember db gVar User {userId, userContactId} groupId Contact {contactId, localDisplayName, profile, activeConn = Connection {peerChatVRange}} memberRole agentConnId connRequest subMode = createWithRandomId gVar $ \memId -> do createdAt <- liftIO getCurrentTime member@GroupMember {groupMemberId} <- createMember_ (MemberId memId) createdAt - void $ createMemberConnection_ db userId groupMemberId agentConnId Nothing 0 createdAt + void $ createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 createdAt subMode pure member where createMember_ memberId createdAt = do @@ -653,13 +660,13 @@ createNewContactMember db gVar User {userId, userContactId} groupId Contact {con :. (userId, localDisplayName, contactId, localProfileId profile, connRequest, createdAt, createdAt) ) -createNewContactMemberAsync :: DB.Connection -> TVar ChaChaDRG -> User -> GroupId -> Contact -> GroupMemberRole -> (CommandId, ConnId) -> ExceptT StoreError IO () -createNewContactMemberAsync db gVar user@User {userId, userContactId} groupId Contact {contactId, localDisplayName, profile} memberRole (cmdId, agentConnId) = +createNewContactMemberAsync :: DB.Connection -> TVar ChaChaDRG -> User -> GroupId -> Contact -> GroupMemberRole -> (CommandId, ConnId) -> VersionRange -> SubscriptionMode -> ExceptT StoreError IO () +createNewContactMemberAsync db gVar user@User {userId, userContactId} groupId Contact {contactId, localDisplayName, profile} memberRole (cmdId, agentConnId) peerChatVRange subMode = createWithRandomId gVar $ \memId -> do createdAt <- liftIO getCurrentTime insertMember_ (MemberId memId) createdAt groupMemberId <- liftIO $ insertedRowId db - Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId Nothing 0 createdAt + Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 createdAt subMode setCommandConnId db user cmdId connId where insertMember_ memberId createdAt = @@ -675,30 +682,32 @@ createNewContactMemberAsync db gVar user@User {userId, userContactId} groupId Co :. (userId, localDisplayName, contactId, localProfileId profile, createdAt, createdAt) ) -getContactViaMember :: DB.Connection -> User -> GroupMember -> IO (Maybe Contact) +getContactViaMember :: DB.Connection -> User -> GroupMember -> ExceptT StoreError IO Contact getContactViaMember db user@User {userId} GroupMember {groupMemberId} = - maybeFirstRow (toContact user) $ - DB.query - db - [sql| - SELECT - -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, - -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter - FROM contacts ct - JOIN contact_profiles cp ON cp.contact_profile_id = ct.contact_profile_id - JOIN connections c ON c.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.contact_id = ct.contact_id - ) - JOIN group_members m ON m.contact_id = ct.contact_id - WHERE ct.user_id = ? AND m.group_member_id = ? AND ct.deleted = 0 - |] - (userId, groupMemberId) + ExceptT $ + firstRow (toContact user) (SEContactNotFoundByMemberId groupMemberId) $ + DB.query + db + [sql| + SELECT + -- Contact + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.peer_chat_min_version, c.peer_chat_max_version + FROM contacts ct + JOIN contact_profiles cp ON cp.contact_profile_id = ct.contact_profile_id + JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + where cc.contact_id = ct.contact_id + ) + JOIN group_members m ON m.contact_id = ct.contact_id + WHERE ct.user_id = ? AND m.group_member_id = ? AND ct.deleted = 0 + |] + (userId, groupMemberId) setNewContactMemberConnRequest :: DB.Connection -> User -> GroupMember -> ConnReqInvitation -> IO () setNewContactMemberConnRequest db User {userId} GroupMember {groupMemberId} connRequest = do @@ -710,15 +719,15 @@ getMemberInvitation db User {userId} groupMemberId = fmap join . maybeFirstRow fromOnly $ DB.query db "SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? AND user_id = ?" (groupMemberId, userId) -createMemberConnection :: DB.Connection -> UserId -> GroupMember -> ConnId -> IO () -createMemberConnection db userId GroupMember {groupMemberId} agentConnId = do +createMemberConnection :: DB.Connection -> UserId -> GroupMember -> ConnId -> VersionRange -> SubscriptionMode -> IO () +createMemberConnection db userId GroupMember {groupMemberId} agentConnId peerChatVRange subMode = do currentTs <- getCurrentTime - void $ createMemberConnection_ db userId groupMemberId agentConnId Nothing 0 currentTs + void $ createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 currentTs subMode -createMemberConnectionAsync :: DB.Connection -> User -> GroupMemberId -> (CommandId, ConnId) -> IO () -createMemberConnectionAsync db user@User {userId} groupMemberId (cmdId, agentConnId) = do +createMemberConnectionAsync :: DB.Connection -> User -> GroupMemberId -> (CommandId, ConnId) -> VersionRange -> SubscriptionMode -> IO () +createMemberConnectionAsync db user@User {userId} groupMemberId (cmdId, agentConnId) peerChatVRange subMode = do currentTs <- getCurrentTime - Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId Nothing 0 currentTs + Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 currentTs subMode setCommandConnId db user cmdId connId updateGroupMemberStatus :: DB.Connection -> UserId -> GroupMember -> GroupMemberStatus -> IO () @@ -738,25 +747,30 @@ updateGroupMemberStatusById db userId groupMemberId memStatus = do -- | add new member with profile createNewGroupMember :: DB.Connection -> User -> GroupInfo -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> ExceptT StoreError IO GroupMember -createNewGroupMember db user@User {userId} gInfo memInfo@(MemberInfo _ _ Profile {displayName, fullName, image, contactLink, preferences}) memCategory memStatus = - ExceptT . withLocalDisplayName db userId displayName $ \localDisplayName -> do - currentTs <- getCurrentTime +createNewGroupMember db user gInfo memInfo memCategory memStatus = do + currentTs <- liftIO getCurrentTime + (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memInfo currentTs + let newMember = + NewGroupMember + { memInfo, + memCategory, + memStatus, + memInvitedBy = IBUnknown, + localDisplayName, + memContactId = Nothing, + memProfileId + } + liftIO $ createNewMember_ db user gInfo newMember currentTs + +createNewMemberProfile_ :: DB.Connection -> User -> MemberInfo -> UTCTime -> ExceptT StoreError IO (Text, ProfileId) +createNewMemberProfile_ db User {userId} (MemberInfo _ _ _ Profile {displayName, fullName, image, contactLink, preferences}) createdAt = + ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do DB.execute db "INSERT INTO contact_profiles (display_name, full_name, image, contact_link, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" - (displayName, fullName, image, contactLink, userId, preferences, currentTs, currentTs) - memProfileId <- insertedRowId db - let newMember = - NewGroupMember - { memInfo, - memCategory, - memStatus, - memInvitedBy = IBUnknown, - localDisplayName, - memContactId = Nothing, - memProfileId - } - Right <$> createNewMember_ db user gInfo newMember currentTs + (displayName, fullName, image, contactLink, userId, preferences, createdAt, createdAt) + profileId <- insertedRowId db + pure $ Right (ldn, profileId) createNewMember_ :: DB.Connection -> User -> GroupInfo -> NewGroupMember -> UTCTime -> IO GroupMember createNewMember_ @@ -764,7 +778,7 @@ createNewMember_ User {userId, userContactId} GroupInfo {groupId} NewGroupMember - { memInfo = MemberInfo memberId memberRole memberProfile, + { memInfo = MemberInfo memberId memberRole _ memberProfile, memCategory = memberCategory, memStatus = memberStatus, memInvitedBy = invitedBy, @@ -908,47 +922,41 @@ getIntroduction_ db reMember toMember = ExceptT $ do where toIntro :: [(Int64, Maybe ConnReqInvitation, Maybe ConnReqInvitation, GroupMemberIntroStatus)] -> Either StoreError GroupMemberIntro toIntro [(introId, groupConnReq, directConnReq, introStatus)] = - let introInvitation = IntroInvitation <$> groupConnReq <*> directConnReq + let introInvitation = IntroInvitation <$> groupConnReq <*> pure directConnReq in Right GroupMemberIntro {introId, reMember, toMember, introStatus, introInvitation} toIntro _ = Left SEIntroNotFound -createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberInfo -> (CommandId, ConnId) -> (CommandId, ConnId) -> Maybe ProfileId -> ExceptT StoreError IO GroupMember -createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupMember {memberContactId, activeConn} memInfo@(MemberInfo _ _ memberProfile) (groupCmdId, groupAgentConnId) (directCmdId, directAgentConnId) customUserProfileId = do - let cLevel = 1 + case activeConn of - Just (Connection{connLevel}) -> connLevel - _ -> 0 +createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberInfo -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> ExceptT StoreError IO GroupMember +createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupMember {memberContactId, activeConn} memInfo@(MemberInfo _ _ memberChatVRange memberProfile) (groupCmdId, groupAgentConnId) directConnIds customUserProfileId subMode = do + let mcvr = maybe chatInitialVRange fromChatVRange memberChatVRange + cLevel = 1 + maybe 0 (\Connection {connLevel} -> connLevel) activeConn currentTs <- liftIO getCurrentTime - Connection {connId = directConnId} <- liftIO $ createConnection_ db userId ConnContact Nothing directAgentConnId memberContactId Nothing customUserProfileId cLevel currentTs - liftIO $ setCommandConnId db user directCmdId directConnId - (localDisplayName, contactId, memProfileId) <- createContact_ db userId directConnId memberProfile "" (Just groupId) currentTs Nothing + newMember <- case directConnIds of + Just (directCmdId, directAgentConnId) -> do + Connection {connId = directConnId} <- liftIO $ createConnection_ db userId ConnContact Nothing directAgentConnId mcvr memberContactId Nothing customUserProfileId cLevel currentTs subMode + liftIO $ setCommandConnId db user directCmdId directConnId + (localDisplayName, contactId, memProfileId) <- createContact_ db userId directConnId memberProfile "" (Just groupId) currentTs Nothing + pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memInvitedBy = IBUnknown, localDisplayName, memContactId = Just contactId, memProfileId} + Nothing -> do + (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memInfo currentTs + pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memInvitedBy = IBUnknown, localDisplayName, memContactId = Nothing, memProfileId} liftIO $ do - let newMember = - NewGroupMember - { memInfo, - memCategory = GCPreMember, - memStatus = GSMemIntroduced, - memInvitedBy = IBUnknown, - localDisplayName, - memContactId = Just contactId, - memProfileId - } member <- createNewMember_ db user gInfo newMember currentTs - conn@Connection {connId = groupConnId} <- createMemberConnection_ db userId (groupMemberId' member) groupAgentConnId memberContactId cLevel currentTs + conn@Connection {connId = groupConnId} <- createMemberConnection_ db userId (groupMemberId' member) groupAgentConnId mcvr memberContactId cLevel currentTs subMode liftIO $ setCommandConnId db user groupCmdId groupConnId pure (member :: GroupMember) {activeConn = Just conn} -createIntroToMemberContact :: DB.Connection -> User -> GroupMember -> GroupMember -> (CommandId, ConnId) -> (CommandId, ConnId) -> Maybe ProfileId -> IO () -createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId, localDisplayName} (groupCmdId, groupAgentConnId) (directCmdId, directAgentConnId) customUserProfileId = do - let cLevel = 1 + case activeConn of - Just (Connection{connLevel}) -> connLevel - _ -> 0 +createIntroToMemberContact :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionRange -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> IO () +createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId, localDisplayName} mcvr (groupCmdId, groupAgentConnId) directConnIds customUserProfileId subMode = do + let cLevel = 1 + maybe 0 (\Connection {connLevel} -> connLevel) activeConn currentTs <- getCurrentTime - Connection {connId = groupConnId} <- createMemberConnection_ db userId groupMemberId groupAgentConnId viaContactId cLevel currentTs + Connection {connId = groupConnId} <- createMemberConnection_ db userId groupMemberId groupAgentConnId mcvr viaContactId cLevel currentTs subMode setCommandConnId db user groupCmdId groupConnId - Connection {connId = directConnId} <- createConnection_ db userId ConnContact Nothing directAgentConnId viaContactId Nothing customUserProfileId cLevel currentTs - setCommandConnId db user directCmdId directConnId - contactId <- createMemberContact_ directConnId currentTs - updateMember_ contactId currentTs + forM_ directConnIds $ \(directCmdId, directAgentConnId) -> do + Connection {connId = directConnId} <- createConnection_ db userId ConnContact Nothing directAgentConnId mcvr viaContactId Nothing customUserProfileId cLevel currentTs subMode + setCommandConnId db user directCmdId directConnId + contactId <- createMemberContact_ directConnId currentTs + updateMember_ contactId currentTs where createMemberContact_ :: Int64 -> UTCTime -> IO Int64 createMemberContact_ connId ts = do @@ -975,8 +983,8 @@ createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = |] [":contact_id" := contactId, ":updated_at" := ts, ":group_member_id" := groupMemberId] -createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> Maybe Int64 -> Int -> UTCTime -> IO Connection -createMemberConnection_ db userId groupMemberId agentConnId viaContact = createConnection_ db userId ConnMember (Just groupMemberId) agentConnId viaContact Nothing Nothing +createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> VersionRange -> Maybe Int64 -> Int -> UTCTime -> SubscriptionMode -> IO Connection +createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange viaContact = createConnection_ db userId ConnMember (Just groupMemberId) agentConnId peerChatVRange viaContact Nothing Nothing getViaGroupMember :: DB.Connection -> User -> Contact -> IO (Maybe (GroupInfo, GroupMember)) getViaGroupMember db User {userId, userContactId} Contact {contactId} = @@ -996,7 +1004,8 @@ getViaGroupMember db User {userId, userContactId} Contact {contactId} = m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter + c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contacts ct ON ct.contact_id = m.contact_id JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) @@ -1029,7 +1038,8 @@ getViaGroupContact db user@User {userId} GroupMember {groupMemberId} = ct.contact_id, ct.contact_profile_id, ct.local_display_name, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, ct.via_group, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, p.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter + c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id JOIN connections c ON c.connection_id = ( @@ -1345,3 +1355,9 @@ getXGrpMemIntroContGroup db User {userId} GroupMember {groupMemberId} = do toCont (hostConnId, connReq_) = case connReq_ of Just connReq -> Just (hostConnId, connReq) _ -> Nothing + +getHostConnId :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO GroupMemberId +getHostConnId db user@User {userId} groupId = do + hostMemberId <- getHostMemberId_ db user groupId + ExceptT . firstRow fromOnly (SEConnectionNotFoundByMemberId hostMemberId) $ + DB.query db "SELECT connection_id FROM connections WHERE user_id = ? AND group_member_id = ?" (userId, hostMemberId) diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 2e48f02e8d..4cb0280a81 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -15,7 +15,6 @@ module Simplex.Chat.Store.Messages ( getContactConnIds_, getDirectChatReactions_, - toDirectChatItem, -- * Message and chat item functions deleteContactCIs, @@ -126,6 +125,8 @@ import Simplex.Chat.Types import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, MsgMeta (..), UserId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, firstRow', maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import Simplex.Messaging.Util (eitherToMaybe) import UnliftIO.STM @@ -482,12 +483,13 @@ getDirectChatPreviews_ db user@User {userId} = do -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.peer_chat_min_version, c.peer_chat_max_version, -- ChatStats COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), ct.unread_chat, -- ChatItem i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, -- CIFile - f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, f.protocol, + f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- DirectQuote ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM contacts ct @@ -552,7 +554,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do -- ChatItem i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, -- CIFile - f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, f.protocol, + f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- Maybe GroupMember - sender m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, @@ -612,7 +614,8 @@ getContactRequestChatPreviews_ db User {userId} = [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, - c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, p.preferences, cr.created_at, cr.updated_at + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, p.preferences, cr.created_at, cr.updated_at, + cr.peer_chat_min_version, cr.peer_chat_max_version FROM contact_requests cr JOIN connections c ON c.user_contact_link_id = cr.user_contact_link_id JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id @@ -673,7 +676,7 @@ getDirectChatItemsLast db User {userId} contactId count search = ExceptT $ do -- ChatItem i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, -- CIFile - f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, f.protocol, + f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- DirectQuote ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM chat_items i @@ -702,7 +705,7 @@ getDirectChatAfter_ db User {userId} ct@Contact {contactId} afterChatItemId coun -- ChatItem i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, -- CIFile - f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, f.protocol, + f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- DirectQuote ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM chat_items i @@ -732,7 +735,7 @@ getDirectChatBefore_ db User {userId} ct@Contact {contactId} beforeChatItemId co -- ChatItem i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, -- CIFile - f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, f.protocol, + f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- DirectQuote ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM chat_items i @@ -954,7 +957,7 @@ type ChatStatsRow = (Int, ChatItemId, Bool) toChatStats :: ChatStatsRow -> ChatStats toChatStats (unreadCount, minUnreadItemId, unreadChat) = ChatStats {unreadCount, minUnreadItemId, unreadChat} -type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, Maybe ACIFileStatus, Maybe FileProtocol) +type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe ACIFileStatus, Maybe FileProtocol) type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe Bool) @@ -975,7 +978,7 @@ toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _) dir -- this function can be changed so it never fails, not only avoid failure on invalid json toDirectChatItem :: UTCTime -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect) -toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileStatus_, fileProtocol_)) :. quoteRow) = +toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. quoteRow) = chatItem $ fromRight invalid $ dbParseACIContent itemContentText where invalid = ACIContent msgDir $ CIInvalidJSON itemContentText @@ -992,7 +995,10 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT maybeCIFile :: CIFileStatus d -> Maybe (CIFile d) maybeCIFile fileStatus = case (fileId_, fileName_, fileSize_, fileProtocol_) of - (Just fileId, Just fileName, Just fileSize, Just fileProtocol) -> Just CIFile {fileId, fileName, fileSize, filePath, fileStatus, fileProtocol} + (Just fileId, Just fileName, Just fileSize, Just fileProtocol) -> + let cfArgs = CFArgs <$> fileKey <*> fileNonce + fileSource = (`CryptoFile` cfArgs) <$> filePath + in Just CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol} _ -> Nothing cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTDirect d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTDirect cItem d chatDir ciStatus content file = @@ -1025,7 +1031,7 @@ toGroupQuote qr@(_, _, _, _, quotedSent) quotedMember_ = toQuote qr $ direction -- this function can be changed so it never fails, not only avoid failure on invalid json toGroupChatItem :: UTCTime -> Int64 -> ChatItemRow :. MaybeGroupMemberRow :. GroupQuoteRow :. MaybeGroupMemberRow -> Either StoreError (CChatItem 'CTGroup) -toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileStatus_, fileProtocol_)) :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = do +toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = do chatItem $ fromRight invalid $ dbParseACIContent itemContentText where member_ = toMaybeGroupMember userContactId memberRow_ @@ -1045,7 +1051,10 @@ toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, maybeCIFile :: CIFileStatus d -> Maybe (CIFile d) maybeCIFile fileStatus = case (fileId_, fileName_, fileSize_, fileProtocol_) of - (Just fileId, Just fileName, Just fileSize, Just fileProtocol) -> Just CIFile {fileId, fileName, fileSize, filePath, fileStatus, fileProtocol} + (Just fileId, Just fileName, Just fileSize, Just fileProtocol) -> + let cfArgs = CFArgs <$> fileKey <*> fileNonce + fileSource = (`CryptoFile` cfArgs) <$> filePath + in Just CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol} _ -> Nothing cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTGroup d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTGroup cItem d chatDir ciStatus content file = @@ -1145,7 +1154,7 @@ updateDirectChatItemStatus db user@User {userId} contactId itemId itemStatus = d correctDir :: CChatItem c -> Either StoreError (ChatItem c d) correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci -updateDirectChatItem :: forall d. (MsgDirectionI d) => DB.Connection -> User -> Int64 -> ChatItemId -> CIContent d -> Bool -> Maybe MessageId -> ExceptT StoreError IO (ChatItem 'CTDirect d) +updateDirectChatItem :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItemId -> CIContent d -> Bool -> Maybe MessageId -> ExceptT StoreError IO (ChatItem 'CTDirect d) updateDirectChatItem db user contactId itemId newContent live msgId_ = do ci <- liftEither . correctDir =<< getDirectChatItem db user contactId itemId liftIO $ updateDirectChatItem' db user contactId ci newContent live msgId_ @@ -1153,7 +1162,7 @@ updateDirectChatItem db user contactId itemId newContent live msgId_ = do correctDir :: CChatItem c -> Either StoreError (ChatItem c d) correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci -updateDirectChatItem' :: forall d. (MsgDirectionI d) => DB.Connection -> User -> Int64 -> ChatItem 'CTDirect d -> CIContent d -> Bool -> Maybe MessageId -> IO (ChatItem 'CTDirect d) +updateDirectChatItem' :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItem 'CTDirect d -> CIContent d -> Bool -> Maybe MessageId -> IO (ChatItem 'CTDirect d) updateDirectChatItem' db User {userId} contactId ci newContent live msgId_ = do currentTs <- liftIO getCurrentTime let ci' = updatedChatItem ci newContent live currentTs @@ -1298,7 +1307,7 @@ getDirectChatItem db User {userId} contactId itemId = ExceptT $ do -- ChatItem i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, -- CIFile - f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, f.protocol, + f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- DirectQuote ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM chat_items i @@ -1473,7 +1482,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do -- ChatItem i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, -- CIFile - f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, f.protocol, + f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 6da0d1cdcd..cbcc4ddd28 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -76,6 +76,9 @@ import Simplex.Chat.Migrations.M20230621_chat_item_moderations import Simplex.Chat.Migrations.M20230705_delivery_receipts import Simplex.Chat.Migrations.M20230721_group_snd_item_statuses import Simplex.Chat.Migrations.M20230814_indexes +import Simplex.Chat.Migrations.M20230827_file_encryption +import Simplex.Chat.Migrations.M20230829_connections_chat_vrange +import Simplex.Chat.Migrations.M20230903_connections_to_subscribe import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -151,7 +154,10 @@ schemaMigrations = ("20230621_chat_item_moderations", m20230621_chat_item_moderations, Just down_m20230621_chat_item_moderations), ("20230705_delivery_receipts", m20230705_delivery_receipts, Just down_m20230705_delivery_receipts), ("20230721_group_snd_item_statuses", m20230721_group_snd_item_statuses, Just down_m20230721_group_snd_item_statuses), - ("20230814_indexes", m20230814_indexes, Just down_m20230814_indexes) + ("20230814_indexes", m20230814_indexes, Just down_m20230814_indexes), + ("20230827_file_encryption", m20230827_file_encryption, Just down_m20230827_file_encryption), + ("20230829_connections_chat_vrange", m20230829_connections_chat_vrange, Just down_m20230829_connections_chat_vrange), + ("20230903_connections_to_subscribe", m20230903_connections_to_subscribe, Just down_m20230903_connections_to_subscribe) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 831b8d7cd2..e521cb43cf 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -84,7 +84,7 @@ import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolTypeI (..)) +import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolTypeI (..), SubscriptionMode) import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Util (safeDecodeUtf8) @@ -297,8 +297,8 @@ getUserContactProfiles db User {userId} = toContactProfile :: (ContactName, Text, Maybe ImageData, Maybe ConnReqContact, Maybe Preferences) -> Profile toContactProfile (displayName, fullName, image, contactLink, preferences) = Profile {displayName, fullName, image, contactLink, preferences} -createUserContactLink :: DB.Connection -> User -> ConnId -> ConnReqContact -> ExceptT StoreError IO () -createUserContactLink db User {userId} agentConnId cReq = +createUserContactLink :: DB.Connection -> User -> ConnId -> ConnReqContact -> SubscriptionMode -> ExceptT StoreError IO () +createUserContactLink db User {userId} agentConnId cReq subMode = checkConstraint SEDuplicateContactLink . liftIO $ do currentTs <- getCurrentTime DB.execute @@ -306,7 +306,7 @@ createUserContactLink db User {userId} agentConnId cReq = "INSERT INTO user_contact_links (user_id, conn_req_contact, created_at, updated_at) VALUES (?,?,?,?)" (userId, cReq, currentTs, currentTs) userContactLinkId <- insertedRowId db - void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId Nothing Nothing Nothing 0 currentTs + void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode getUserAddressConnections :: DB.Connection -> User -> ExceptT StoreError IO [Connection] getUserAddressConnections db User {userId} = do @@ -320,7 +320,8 @@ getUserAddressConnections db User {userId} = do db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter + c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id WHERE c.user_id = ? AND uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL @@ -335,6 +336,7 @@ getUserContactLinks db User {userId} = [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.peer_chat_min_version, c.peer_chat_max_version, uc.user_contact_link_id, uc.conn_req_contact, uc.group_id FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 27cda36cc0..affeeef740 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -18,8 +18,8 @@ import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG, randomBytesGenerate) import Data.Aeson (ToJSON) import qualified Data.Aeson as J -import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Base64 as B64 +import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import Data.Maybe (fromMaybe, isJust, listToMaybe) import Data.Text (Text) @@ -37,7 +37,9 @@ import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, UserId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) +import Simplex.Messaging.Protocol (SubscriptionMode (..)) import Simplex.Messaging.Util (allFinally) +import Simplex.Messaging.Version import UnliftIO.STM -- These error type constructors must be added to mobile apps @@ -51,6 +53,7 @@ data StoreError | SEUserNotFoundByContactRequestId {contactRequestId :: Int64} | SEContactNotFound {contactId :: ContactId} | SEContactNotFoundByName {contactName :: ContactName} + | SEContactNotFoundByMemberId {groupMemberId :: GroupMemberId} | SEContactNotReady {contactName :: ContactName} | SEDuplicateContactLink | SEUserContactLinkNotFound @@ -78,6 +81,7 @@ data StoreError | SERcvFileNotFoundXFTP {agentRcvFileId :: AgentRcvFileId} | SEConnectionNotFound {agentConnId :: AgentConnId} | SEConnectionNotFoundById {connId :: Int64} + | SEConnectionNotFoundByMemberId {groupMemberId :: GroupMemberId} | SEPendingConnectionNotFound {connId :: Int64} | SEIntroNotFound | SEUniqueID @@ -133,15 +137,16 @@ toFileInfo (fileId, fileStatus, filePath) = CIFileInfo {fileId, fileStatus, file type EntityIdsRow = (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64) -type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, Bool, Maybe GroupLinkId, Maybe Int64, ConnStatus, ConnType, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime, Int) +type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, Bool, Maybe GroupLinkId, Maybe Int64, ConnStatus, ConnType, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime, Int, Version, Version) -type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe Bool, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe Int) +type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe Bool, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe Int, Maybe Version, Maybe Version) toConnection :: ConnectionRow -> Connection -toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, authErrCounter)) = +toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, authErrCounter, minVer, maxVer)) = let entityId = entityId_ connType connectionCode = SecurityCode <$> code_ <*> verifiedAt_ - in Connection {connId, agentConnId = AgentConnId acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias, entityId, connectionCode, authErrCounter, createdAt} + peerChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer + in Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias, entityId, connectionCode, authErrCounter, createdAt} where entityId_ :: ConnType -> Maybe Int64 entityId_ ConnContact = contactId @@ -151,12 +156,12 @@ toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroup entityId_ ConnUserContact = userContactLinkId toMaybeConnection :: MaybeConnectionRow -> Maybe Connection -toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, customUserProfileId, Just connStatus, Just connType, Just localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, Just authErrCounter)) = - Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, authErrCounter)) +toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, customUserProfileId, Just connStatus, Just connType, Just localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, Just authErrCounter, Just minVer, Just maxVer)) = + Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, authErrCounter, minVer, maxVer)) toMaybeConnection _ = Nothing -createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> IO Connection -createConnection_ db userId connType entityId acId viaContact viaUserContactLink customUserProfileId connLevel currentTs = do +createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> VersionRange -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> SubscriptionMode -> IO Connection +createConnection_ db userId connType entityId acId peerChatVRange@(VersionRange minV maxV) viaContact viaUserContactLink customUserProfileId connLevel currentTs subMode = do viaLinkGroupId :: Maybe Int64 <- fmap join . forM viaUserContactLink $ \ucLinkId -> maybeFirstRow fromOnly $ DB.query db "SELECT group_id FROM user_contact_links WHERE user_id = ? AND user_contact_link_id = ? AND group_id IS NOT NULL" (userId, ucLinkId) let viaGroupLink = isJust viaLinkGroupId @@ -165,17 +170,30 @@ createConnection_ db userId connType entityId acId viaContact viaUserContactLink [sql| INSERT INTO connections ( user_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, custom_user_profile_id, conn_status, conn_type, - contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version, to_subscribe + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (userId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, customUserProfileId, ConnNew, connType) :. (ent ConnContact, ent ConnMember, ent ConnSndFile, ent ConnRcvFile, ent ConnUserContact, currentTs, currentTs) + :. (minV, maxV, subMode == SMOnlyCreate) ) connId <- insertedRowId db - pure Connection {connId, agentConnId = AgentConnId acId, connType, entityId, viaContact, viaUserContactLink, viaGroupLink, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} + pure Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType, entityId, viaContact, viaUserContactLink, viaGroupLink, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} where ent ct = if connType == ct then entityId else Nothing +setPeerChatVRange :: DB.Connection -> Int64 -> VersionRange -> IO () +setPeerChatVRange db connId (VersionRange minVer maxVer) = + DB.execute + db + [sql| + UPDATE connections + SET peer_chat_min_version = ?, peer_chat_max_version = ? + WHERE connection_id = ? + |] + (minVer, maxVer, connId) + setCommandConnId :: DB.Connection -> User -> CommandId -> Int64 -> IO () setCommandConnId db User {userId} cmdId connId = do updatedAt <- getCurrentTime @@ -257,12 +275,13 @@ getProfileById db userId profileId = toProfile :: (ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences) -> LocalProfile toProfile (displayName, fullName, image, contactLink, localAlias, preferences) = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} -type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact) :. (Maybe XContactId, Maybe Preferences, UTCTime, UTCTime) +type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact) :. (Maybe XContactId, Maybe Preferences, UTCTime, UTCTime, Version, Version) toContactRequest :: ContactRequestRow -> UserContactRequest -toContactRequest ((contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName, image, contactLink) :. (xContactId, preferences, createdAt, updatedAt)) = do +toContactRequest ((contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName, image, contactLink) :. (xContactId, preferences, createdAt, updatedAt, minVer, maxVer)) = do let profile = Profile {displayName, fullName, image, contactLink, preferences} - in UserContactRequest {contactRequestId, agentInvitationId, userContactLinkId, agentContactConnId, localDisplayName, profileId, profile, xContactId, createdAt, updatedAt} + cReqChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer + in UserContactRequest {contactRequestId, agentInvitationId, userContactLinkId, agentContactConnId, cReqChatVRange, localDisplayName, profileId, profile, xContactId, createdAt, updatedAt} userQuery :: Query userQuery = diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 180356c3f6..665a0fc29a 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -44,10 +44,12 @@ import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Util import Simplex.FileTransfer.Description (FileDigest) import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), APartyCmdTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, SAEntity (..), UserId) +import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, sumTypeJSON, taggedObjectJSON) import Simplex.Messaging.Protocol (ProtoServerWithAuth, ProtocolTypeI) import Simplex.Messaging.Util ((<$?>)) +import Simplex.Messaging.Version class IsContact a where contactId' :: a -> ContactId @@ -233,6 +235,7 @@ data UserContactRequest = UserContactRequest agentInvitationId :: AgentInvId, userContactLinkId :: Int64, agentContactConnId :: AgentConnId, -- connection id of user contact + cReqChatVRange :: VersionRange, localDisplayName :: ContactName, profileId :: Int64, profile :: Profile, @@ -347,11 +350,12 @@ data ChatSettings = ChatSettings instance ToJSON ChatSettings where toEncoding = J.genericToEncoding J.defaultOptions defaultChatSettings :: ChatSettings -defaultChatSettings = ChatSettings - { enableNtfs = True, - sendRcpts = Nothing, - favorite = False - } +defaultChatSettings = + ChatSettings + { enableNtfs = True, + sendRcpts = Nothing, + favorite = False + } pattern DisableNtfs :: ChatSettings pattern DisableNtfs <- ChatSettings {enableNtfs = False} @@ -538,24 +542,31 @@ instance ToJSON MemberIdRole where toEncoding = J.genericToEncoding J.defaultOpt data IntroInvitation = IntroInvitation { groupConnReq :: ConnReqInvitation, - directConnReq :: ConnReqInvitation + directConnReq :: Maybe ConnReqInvitation } deriving (Eq, Show, Generic, FromJSON) -instance ToJSON IntroInvitation where toEncoding = J.genericToEncoding J.defaultOptions +instance ToJSON IntroInvitation where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} data MemberInfo = MemberInfo { memberId :: MemberId, memberRole :: GroupMemberRole, + v :: Maybe ChatVersionRange, profile :: Profile } deriving (Eq, Show, Generic, FromJSON) -instance ToJSON MemberInfo where toEncoding = J.genericToEncoding J.defaultOptions +instance ToJSON MemberInfo where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} memberInfo :: GroupMember -> MemberInfo -memberInfo GroupMember {memberId, memberRole, memberProfile} = - MemberInfo memberId memberRole (fromLocalProfile memberProfile) +memberInfo GroupMember {memberId, memberRole, memberProfile, activeConn} = + MemberInfo memberId memberRole memberChatVRange (fromLocalProfile memberProfile) + where + memberChatVRange = ChatVersionRange . peerChatVRange <$> activeConn data ReceivedGroupInvitation = ReceivedGroupInvitation { fromMember :: GroupMember, @@ -955,7 +966,8 @@ instance ToJSON RcvFileTransfer where toEncoding = J.genericToEncoding J.default data XFTPRcvFile = XFTPRcvFile { rcvFileDescription :: RcvFileDescr, agentRcvFileId :: Maybe AgentRcvFileId, - agentRcvFileDeleted :: Bool + agentRcvFileDeleted :: Bool, + cryptoArgs :: Maybe CryptoFileArgs } deriving (Eq, Show, Generic) @@ -1110,7 +1122,8 @@ instance ToJSON FileTransferMeta where toEncoding = J.genericToEncoding J.defaul data XFTPSndFile = XFTPSndFile { agentSndFileId :: AgentSndFileId, privateSndFileDescr :: Maybe Text, - agentSndFileDeleted :: Bool + agentSndFileDeleted :: Bool, + cryptoArgs :: Maybe CryptoFileArgs } deriving (Eq, Show, Generic) @@ -1156,6 +1169,7 @@ type ConnReqContact = ConnectionRequestUri 'CMContact data Connection = Connection { connId :: Int64, agentConnId :: AgentConnId, + peerChatVRange :: VersionRange, connLevel :: Int, viaContact :: Maybe Int64, -- group member contact ID, if not direct connection viaUserContactLink :: Maybe Int64, -- user contact link ID, if connected via "user address" @@ -1466,3 +1480,15 @@ instance ProtocolTypeI p => ToJSON (ServerCfg p) where instance ProtocolTypeI p => FromJSON (ServerCfg p) where parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} + +newtype ChatVersionRange = ChatVersionRange {fromChatVRange :: VersionRange} deriving (Eq, Show) + +chatInitialVRange :: VersionRange +chatInitialVRange = versionToRange 1 + +instance FromJSON ChatVersionRange where + parseJSON v = ChatVersionRange <$> strParseJSON "ChatVersionRange" v + +instance ToJSON ChatVersionRange where + toJSON (ChatVersionRange vr) = strToJSON vr + toEncoding (ChatVersionRange vr) = strToJEncoding vr diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 1cadd5995e..053069da62 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -21,6 +21,7 @@ import Data.Int (Int64) import Data.List (groupBy, intercalate, intersperse, partition, sortOn) import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L +import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isJust, isNothing, mapMaybe) import Data.Text (Text) @@ -51,6 +52,7 @@ import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..)) import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON) @@ -58,6 +60,7 @@ import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType, Pro import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Util (bshow, tshow) +import Simplex.Messaging.Version hiding (version) import System.Console.ANSI.Types type CurrentTime = UTCTime @@ -161,7 +164,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRRcvFileDescrReady _ _ -> [] CRRcvFileDescrNotReady _ _ -> [] CRRcvFileProgressXFTP {} -> [] - CRRcvFileAccepted u ci -> ttyUser u $ savingFile' ci + CRRcvFileAccepted u ci -> ttyUser u $ savingFile' testView ci CRRcvFileAcceptedSndCancelled u ft -> ttyUser u $ viewRcvFileSndCancelled ft CRSndFileCancelled u _ ftm fts -> ttyUser u $ viewSndFileCancelled ftm fts CRRcvFileCancelled u _ ft -> ttyUser u $ receivingFile_ "cancelled" ft @@ -252,7 +255,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRSQLResult rows -> map plain rows CRSlowSQLQueries {chatQueries, agentQueries} -> let viewQuery SlowSQLQuery {query, queryStats = SlowQueryStats {count, timeMax, timeAvg}} = - "count: " <> sShow count + ("count: " <> sShow count) <> (" :: max: " <> sShow timeMax <> " ms") <> (" :: avg: " <> sShow timeAvg <> " ms") <> (" :: " <> plain (T.unwords $ T.lines query)) @@ -262,20 +265,21 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView plain $ "agent locks: " <> LB.unpack (J.encode agentLocks) ] CRAgentStats stats -> map (plain . intercalate ",") stats - CRAgentSubs {activeSubs, distinctActiveSubs, pendingSubs, distinctPendingSubs} -> - [plain $ "Subscriptions: active = " <> show (sum activeSubs) <> ", distinct active = " <> show (sum distinctActiveSubs) <> ", pending = " <> show (sum pendingSubs) <> ", distinct pending = " <> show (sum distinctPendingSubs)] + CRAgentSubs {activeSubs, pendingSubs, removedSubs} -> + [plain $ "Subscriptions: active = " <> show (sum activeSubs) <> ", pending = " <> show (sum pendingSubs) <> ", removed = " <> show (sum $ M.map length removedSubs)] <> ("active subscriptions:" : listSubs activeSubs) - <> ("distinct active subscriptions:" : listSubs distinctActiveSubs) <> ("pending subscriptions:" : listSubs pendingSubs) - <> ("distinct pending subscriptions:" : listSubs distinctPendingSubs) + <> ("removed subscriptions:" : listSubs removedSubs) where - listSubs = map (\(srv, count) -> plain $ srv <> ": " <> tshow count) . M.assocs - CRAgentSubsDetails SubscriptionsInfo {activeSubscriptions, pendingSubscriptions} -> + listSubs :: Show a => Map Text a -> [StyledString] + listSubs = map (\(srv, info) -> plain $ srv <> ": " <> tshow info) . M.assocs + CRAgentSubsDetails SubscriptionsInfo {activeSubscriptions, pendingSubscriptions, removedSubscriptions} -> ("active subscriptions:" : map sShow activeSubscriptions) <> ("pending subscriptions: " : map sShow pendingSubscriptions) + <> ("removed subscriptions: " : map sShow removedSubscriptions) CRConnectionDisabled entity -> viewConnectionEntityDisabled entity CRAgentRcvQueueDeleted acId srv aqId err_ -> - [ "completed deleting rcv queue, agent connection id: " <> sShow acId + [ ("completed deleting rcv queue, agent connection id: " <> sShow acId) <> (", server: " <> sShow srv) <> (", agent queue id: " <> sShow aqId) <> maybe "" (\e -> ", error: " <> sShow e) err_ @@ -328,7 +332,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView Just CIQuote {chatDir = quoteDir, content} -> Just (msgDirectionInt $ quoteMsgDirection quoteDir, msgContentText content) fPath = case file of - Just CIFile {filePath = Just fp} -> Just fp + Just CIFile {fileSource = Just (CryptoFile fp _)} -> Just fp _ -> Nothing testViewItem :: CChatItem c -> Maybe GroupMember -> Text testViewItem (CChatItem _ ci@ChatItem {meta = CIMeta {itemText}}) membership_ = @@ -954,8 +958,9 @@ viewNetworkConfig NetworkConfig {socksProxy, tcpTimeout} = ] viewContactInfo :: Contact -> ConnectionStats -> Maybe Profile -> [StyledString] -viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink}} stats incognitoProfile = - ["contact ID: " <> sShow contactId] <> viewConnectionStats stats +viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink}, activeConn} stats incognitoProfile = + ["contact ID: " <> sShow contactId] + <> viewConnectionStats stats <> maybe [] (\l -> ["contact address: " <> (plain . strEncode) l]) contactLink <> maybe ["you've shared main profile with this contact"] @@ -963,6 +968,7 @@ viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, conta incognitoProfile <> ["alias: " <> plain localAlias | localAlias /= ""] <> [viewConnectionVerified (contactSecurityCode ct)] + <> [viewPeerChatVRange (peerChatVRange activeConn)] viewGroupInfo :: GroupInfo -> GroupSummary -> [StyledString] viewGroupInfo GroupInfo {groupId} s = @@ -971,18 +977,22 @@ viewGroupInfo GroupInfo {groupId} s = ] viewGroupMemberInfo :: GroupInfo -> GroupMember -> Maybe ConnectionStats -> [StyledString] -viewGroupMemberInfo GroupInfo {groupId} m@GroupMember {groupMemberId, memberProfile = LocalProfile {localAlias}} stats = +viewGroupMemberInfo GroupInfo {groupId} m@GroupMember {groupMemberId, memberProfile = LocalProfile {localAlias}, activeConn} stats = [ "group ID: " <> sShow groupId, "member ID: " <> sShow groupMemberId ] <> maybe ["member not connected"] viewConnectionStats stats <> ["alias: " <> plain localAlias | localAlias /= ""] <> [viewConnectionVerified (memberSecurityCode m) | isJust stats] + <> maybe [] (\ac -> [viewPeerChatVRange (peerChatVRange ac)]) activeConn viewConnectionVerified :: Maybe SecurityCode -> StyledString viewConnectionVerified (Just _) = "connection verified" -- TODO show verification time? viewConnectionVerified _ = "connection not verified, use " <> highlight' "/code" <> " command to see security code" +viewPeerChatVRange :: VersionRange -> StyledString +viewPeerChatVRange (VersionRange minVer maxVer) = "peer chat protocol version range: (" <> sShow minVer <> ", " <> sShow maxVer <> ")" + viewConnectionStats :: ConnectionStats -> [StyledString] viewConnectionStats ConnectionStats {rcvQueuesInfo, sndQueuesInfo} = ["receiving messages via: " <> viewRcvQueuesInfo rcvQueuesInfo | not $ null rcvQueuesInfo] @@ -1274,8 +1284,8 @@ viewSentBroadcast mc s f ts tz time = prependFirst (highlight' "/feed" <> " (" < | otherwise = "" viewSentFileInvitation :: StyledString -> CIFile d -> CurrentTime -> TimeZone -> CIMeta c d -> [StyledString] -viewSentFileInvitation to CIFile {fileId, filePath, fileStatus} ts tz = case filePath of - Just fPath -> sentWithTime_ ts tz $ ttySentFile fPath +viewSentFileInvitation to CIFile {fileId, fileSource, fileStatus} ts tz = case fileSource of + Just (CryptoFile fPath _) -> sentWithTime_ ts tz $ ttySentFile fPath _ -> const [] where ttySentFile fPath = ["/f " <> to <> ttyFilePath fPath] <> cancelSending @@ -1343,14 +1353,20 @@ humanReadableSize size mB = kB * 1024 gB = mB * 1024 -savingFile' :: AChatItem -> [StyledString] -savingFile' (AChatItem _ _ (DirectChat Contact {localDisplayName = c}) ChatItem {file = Just CIFile {fileId, filePath = Just filePath}, chatDir = CIDirectRcv}) = - ["saving file " <> sShow fileId <> " from " <> ttyContact c <> " to " <> plain filePath] -savingFile' (AChatItem _ _ _ ChatItem {file = Just CIFile {fileId, filePath = Just filePath}, chatDir = CIGroupRcv GroupMember {localDisplayName = m}}) = - ["saving file " <> sShow fileId <> " from " <> ttyContact m <> " to " <> plain filePath] -savingFile' (AChatItem _ _ _ ChatItem {file = Just CIFile {fileId, filePath = Just filePath}}) = - ["saving file " <> sShow fileId <> " to " <> plain filePath] -savingFile' _ = ["saving file"] -- shouldn't happen +savingFile' :: Bool -> AChatItem -> [StyledString] +savingFile' testView (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, fileSource = Just (CryptoFile filePath cfArgs_)}, chatDir}) = + let from = case (chat, chatDir) of + (DirectChat Contact {localDisplayName = c}, CIDirectRcv) -> " from " <> ttyContact c + (_, CIGroupRcv GroupMember {localDisplayName = m}) -> " from " <> ttyContact m + _ -> "" + in ["saving file " <> sShow fileId <> from <> " to " <> plain filePath] <> cfArgsStr + where + cfArgsStr = case cfArgs_ of + Just cfArgs@(CFArgs key nonce) + | testView -> [plain $ LB.unpack $ J.encode cfArgs] + | otherwise -> [plain $ "encryption key: " <> strEncode key <> ", nonce: " <> strEncode nonce] + _ -> [] +savingFile' _ _ = ["saving file"] -- shouldn't happen receivingFile_' :: StyledString -> AChatItem -> [StyledString] receivingFile_' status (AChatItem _ _ (DirectChat Contact {localDisplayName = c}) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIDirectRcv}) = @@ -1403,7 +1419,7 @@ viewFileTransferStatus (FTRcv ft@RcvFileTransfer {fileId, fileInvitation = FileI RFSCancelled Nothing -> "cancelled" viewFileTransferStatusXFTP :: AChatItem -> [StyledString] -viewFileTransferStatusXFTP (AChatItem _ _ _ ChatItem {file = Just CIFile {fileId, fileName, fileSize, fileStatus, filePath}}) = +viewFileTransferStatusXFTP (AChatItem _ _ _ ChatItem {file = Just CIFile {fileId, fileName, fileSize, fileStatus, fileSource}}) = case fileStatus of CIFSSndStored -> ["sending " <> fstr <> " just started"] CIFSSndTransfer progress total -> ["sending " <> fstr <> " in progress " <> fileProgressXFTP progress total fileSize] @@ -1413,7 +1429,7 @@ viewFileTransferStatusXFTP (AChatItem _ _ _ ChatItem {file = Just CIFile {fileId CIFSRcvInvitation -> ["receiving " <> fstr <> " not accepted yet, use " <> highlight ("/fr " <> show fileId) <> " to receive file"] CIFSRcvAccepted -> ["receiving " <> fstr <> " just started"] CIFSRcvTransfer progress total -> ["receiving " <> fstr <> " progress " <> fileProgressXFTP progress total fileSize] - CIFSRcvComplete -> ["receiving " <> fstr <> " complete" <> maybe "" (\fp -> ", path: " <> plain fp) filePath] + CIFSRcvComplete -> ["receiving " <> fstr <> " complete" <> maybe "" (\(CryptoFile fp _) -> ", path: " <> plain fp) fileSource] CIFSRcvCancelled -> ["receiving " <> fstr <> " cancelled"] CIFSRcvError -> ["receiving " <> fstr <> " error"] CIFSInvalid text -> [fstr <> " invalid status: " <> plain text] diff --git a/stack.yaml b/stack.yaml index ddfd26c28c..d071c21ce8 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: dc65197cfd1276a28bbc542d78c761eb68742186 + commit: 887ccbcf6c5da85540f029d162cc183badc5365b - github: kazu-yamamoto/http2 commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 # - ../direct-sqlcipher diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 4d7813c301..0e315190c5 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -14,13 +14,14 @@ import Control.Monad (forM_) import Directory.Options import Directory.Service import Directory.Store +import GHC.IO.Handle (hClose) import Simplex.Chat.Bot.KnownContacts +import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Core import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..)) import Simplex.Chat.Types (GroupMemberRole (..), Profile (..)) import System.FilePath (()) import Test.Hspec -import GHC.IO.Handle (hClose) directoryServiceTests :: SpecWith FilePath directoryServiceTests = do @@ -233,10 +234,10 @@ testJoinGroup tmp = dan <## "bob (Bob): contact is connected" dan <## "#privacy: you joined the group" dan <# ("#privacy bob> " <> welcomeMsg) - dan <### - [ "#privacy: member SimpleX-Directory is connected", - "#privacy: member cath (Catherine) is connected" - ], + dan + <### [ "#privacy: member SimpleX-Directory is connected", + "#privacy: member cath (Catherine) is connected" + ], do cath <## "#privacy: bob added dan (Daniel) to the group (connecting...)" cath <## "#privacy: new member dan is connected" @@ -244,9 +245,9 @@ testJoinGroup tmp = testDelistedOwnerLeaves :: HasCallStack => FilePath -> IO () testDelistedOwnerLeaves tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -260,9 +261,9 @@ testDelistedOwnerLeaves tmp = testDelistedOwnerRemoved :: HasCallStack => FilePath -> IO () testDelistedOwnerRemoved tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -275,9 +276,9 @@ testDelistedOwnerRemoved tmp = testNotDelistedMemberLeaves :: HasCallStack => FilePath -> IO () testNotDelistedMemberLeaves tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -287,10 +288,10 @@ testNotDelistedMemberLeaves tmp = groupFound cath "privacy" testNotDelistedMemberRemoved :: HasCallStack => FilePath -> IO () -testNotDelistedMemberRemoved tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testNotDelistedMemberRemoved tmp = + withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -300,9 +301,9 @@ testNotDelistedMemberRemoved tmp = testDelistedServiceRemoved :: HasCallStack => FilePath -> IO () testDelistedServiceRemoved tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -317,9 +318,9 @@ testDelistedServiceRemoved tmp = testDelistedRoleChanges :: HasCallStack => FilePath -> IO () testDelistedRoleChanges tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -363,9 +364,9 @@ testDelistedRoleChanges tmp = testNotDelistedMemberRoleChanged :: HasCallStack => FilePath -> IO () testNotDelistedMemberRoleChanged tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -427,9 +428,9 @@ testNotApprovedBadRoles tmp = testRegOwnerChangedProfile :: HasCallStack => FilePath -> IO () testRegOwnerChangedProfile tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -446,9 +447,9 @@ testRegOwnerChangedProfile tmp = testAnotherOwnerChangedProfile :: HasCallStack => FilePath -> IO () testAnotherOwnerChangedProfile tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -465,9 +466,9 @@ testAnotherOwnerChangedProfile tmp = testRegOwnerRemovedLink :: HasCallStack => FilePath -> IO () testRegOwnerRemovedLink tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -498,9 +499,9 @@ testRegOwnerRemovedLink tmp = testAnotherOwnerRemovedLink :: HasCallStack => FilePath -> IO () testAnotherOwnerRemovedLink tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -647,9 +648,9 @@ testDuplicateProhibitApproval tmp = testListUserGroups :: HasCallStack => FilePath -> IO () testListUserGroups tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink cath `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" @@ -687,15 +688,15 @@ testRestoreDirectory tmp = do withTestChat tmp "bob" $ \bob -> withTestChat tmp "cath" $ \cath -> do bob <## "2 contacts connected (use /cs for the list)" - bob <### - [ "#privacy (Privacy): connected to server(s)", - "#security (Security): connected to server(s)" - ] + bob + <### [ "#privacy (Privacy): connected to server(s)", + "#security (Security): connected to server(s)" + ] cath <## "2 contacts connected (use /cs for the list)" - cath <### - [ "#privacy (Privacy): connected to server(s)", - "#anonymity (Anonymity): connected to server(s)" - ] + cath + <### [ "#privacy (Privacy): connected to server(s)", + "#anonymity (Anonymity): connected to server(s)" + ] listGroups superUser bob cath groupFoundN 3 bob "privacy" groupFound bob "security" @@ -785,14 +786,17 @@ addCathAsOwner bob cath = do cath <## "#privacy: member SimpleX-Directory is connected" withDirectoryService :: HasCallStack => FilePath -> (TestCC -> String -> IO ()) -> IO () -withDirectoryService tmp test = do +withDirectoryService tmp = withDirectoryServiceCfg tmp testCfg + +withDirectoryServiceCfg :: HasCallStack => FilePath -> ChatConfig -> (TestCC -> String -> IO ()) -> IO () +withDirectoryServiceCfg tmp cfg test = do dsLink <- - withNewTestChat tmp serviceDbPrefix directoryProfile $ \ds -> - withNewTestChat tmp "super_user" aliceProfile $ \superUser -> do + withNewTestChatCfg tmp cfg serviceDbPrefix directoryProfile $ \ds -> + withNewTestChatCfg tmp cfg "super_user" aliceProfile $ \superUser -> do connectUsers ds superUser ds ##> "/ad" getContactLink ds True - withDirectory tmp dsLink test + withDirectory tmp cfg dsLink test restoreDirectoryService :: HasCallStack => FilePath -> Int -> Int -> (TestCC -> String -> IO ()) -> IO () restoreDirectoryService tmp ctCount grCount test = do @@ -801,29 +805,29 @@ restoreDirectoryService tmp ctCount grCount test = do ds <## (show ctCount <> " contacts connected (use /cs for the list)") ds <## "Your address is active! To show: /sa" ds <## (show grCount <> " group links active") - forM_ [1..grCount] $ \_ -> ds <##. "#" + forM_ [1 .. grCount] $ \_ -> ds <##. "#" ds ##> "/sa" dsLink <- getContactLink ds False ds <## "auto_accept on" pure dsLink - withDirectory tmp dsLink test + withDirectory tmp testCfg dsLink test -withDirectory :: HasCallStack => FilePath -> String -> (TestCC -> String -> IO ()) -> IO () -withDirectory tmp dsLink test = do +withDirectory :: HasCallStack => FilePath -> ChatConfig -> String -> (TestCC -> String -> IO ()) -> IO () +withDirectory tmp cfg dsLink test = do let opts = mkDirectoryOpts tmp [KnownContact 2 "alice"] - runDirectory opts $ - withTestChat tmp "super_user" $ \superUser -> do + runDirectory cfg opts $ + withTestChatCfg tmp cfg "super_user" $ \superUser -> do superUser <## "1 contacts connected (use /cs for the list)" test superUser dsLink -runDirectory :: DirectoryOpts -> IO () -> IO () -runDirectory opts@DirectoryOpts {directoryLog} action = do +runDirectory :: ChatConfig -> DirectoryOpts -> IO () -> IO () +runDirectory cfg opts@DirectoryOpts {directoryLog} action = do st <- restoreDirectoryStore directoryLog t <- forkIO $ bot st threadDelay 500000 action `finally` (mapM_ hClose (directoryLogFile st) >> killThread t) where - bot st = simplexChatCore testCfg (mkChatOpts opts) Nothing $ directoryService st opts + bot st = simplexChatCore cfg (mkChatOpts opts) Nothing $ directoryService st opts registerGroup :: TestCC -> TestCC -> String -> String -> IO () registerGroup su u n fn = registerGroupId su u n fn 1 1 diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 690e16a148..e57b0263b6 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -136,6 +136,16 @@ testAgentCfgV1 = testCfgV1 :: ChatConfig testCfgV1 = testCfg {agentConfig = testAgentCfgV1} +testCfgCreateGroupDirect :: ChatConfig +testCfgCreateGroupDirect = + mkCfgCreateGroupDirect testCfg + +mkCfgCreateGroupDirect :: ChatConfig -> ChatConfig +mkCfgCreateGroupDirect cfg = cfg {chatVRange = groupCreateDirectVRange} + +groupCreateDirectVRange :: VersionRange +groupCreateDirectVRange = mkVersionRange 1 1 + createTestChat :: FilePath -> ChatConfig -> ChatOpts -> String -> Profile -> IO TestCC createTestChat tmp cfg opts@ChatOpts {coreOptions = CoreChatOpts {dbKey}} dbPrefix profile = do Right db@ChatDatabase {chatStore} <- createChatDatabase (tmp dbPrefix) dbKey MCError @@ -252,7 +262,7 @@ getTermLine cc = Just s -> do -- remove condition to always echo virtual terminal when (printOutput cc) $ do - -- when True $ do + -- when True $ do name <- userName cc putStrLn $ name <> ": " <> s pure s @@ -291,7 +301,10 @@ testChatCfgOpts3 cfg opts p1 p2 p3 test = testChatN cfg opts [p1, p2, p3] test_ test_ _ = error "expected 3 chat clients" testChat4 :: HasCallStack => Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> FilePath -> IO () -testChat4 p1 p2 p3 p4 test = testChatN testCfg testOpts [p1, p2, p3, p4] test_ +testChat4 = testChatCfg4 testCfg + +testChatCfg4 :: HasCallStack => ChatConfig -> Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> FilePath -> IO () +testChatCfg4 cfg p1 p2 p3 p4 test = testChatN cfg testOpts [p1, p2, p3, p4] test_ where test_ :: HasCallStack => [TestCC] -> IO () test_ [tc1, tc2, tc3, tc4] = test tc1 tc2 tc3 tc4 diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 2384daac3f..3db4052226 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -17,9 +17,11 @@ import qualified Data.ByteString.Lazy.Char8 as LB import Simplex.Chat.Call import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Options (ChatOpts (..)) +import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Store (agentStoreFile, chatStoreFile) import Simplex.Chat.Types (authErrDisableCount, sameVerificationCode, verificationCode) import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Version import System.Directory (copyFile, doesDirectoryExist, doesFileExist) import System.FilePath (()) import Test.Hspec @@ -55,6 +57,8 @@ chatDirectTests = do it "start/stop/export/import chat" testMaintenanceMode it "export/import chat with files" testMaintenanceModeWithFiles it "encrypt/decrypt database" testDatabaseEncryption + describe "coordination between app and NSE" $ do + it "should not subscribe in NSE and subscribe in the app" testSubscribeAppNSE describe "mute/unmute messages" $ do it "mute/unmute contact" testMuteContact it "mute/unmute group" testMuteGroup @@ -94,6 +98,21 @@ chatDirectTests = do describe "delivery receipts" $ do it "should send delivery receipts" testSendDeliveryReceipts it "should send delivery receipts depending on configuration" testConfigureDeliveryReceipts + describe "negotiate connection peer chat protocol version range" $ do + describe "peer version range correctly set for new connection via invitation" $ do + testInvVRange supportedChatVRange supportedChatVRange + testInvVRange supportedChatVRange vr11 + testInvVRange vr11 supportedChatVRange + testInvVRange vr11 vr11 + describe "peer version range correctly set for new connection via contact request" $ do + testReqVRange supportedChatVRange supportedChatVRange + testReqVRange supportedChatVRange vr11 + testReqVRange vr11 supportedChatVRange + testReqVRange vr11 vr11 + it "update peer version range on received messages" testUpdatePeerChatVRange + where + testInvVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnInvChatVRange vr1 vr2 + testReqVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnReqChatVRange vr1 vr2 testAddContact :: HasCallStack => SpecWith FilePath testAddContact = versionTestMatrix2 runTestAddContact @@ -953,6 +972,35 @@ testDatabaseEncryption tmp = do withTestChat tmp "alice" $ \alice -> do testChatWorking alice bob +testSubscribeAppNSE :: HasCallStack => FilePath -> IO () +testSubscribeAppNSE tmp = + withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChat tmp "alice" aliceProfile $ \alice -> do + withTestChatOpts tmp testOpts {maintenance = True} "alice" $ \nseAlice -> do + alice ##> "/_app suspend 1" + alice <## "ok" + alice <## "chat suspended" + nseAlice ##> "/_start subscribe=off expire=off xftp=off" + nseAlice <## "chat started" + nseAlice ##> "/ad" + cLink <- getContactLink nseAlice True + bob ##> ("/c " <> cLink) + bob <## "connection request sent!" + (nseAlice "/_app activate" + alice <## "ok" + alice <## "Your address is active! To show: /sa" + alice <## "bob (Bob) wants to connect to you!" + alice <## "to accept: /ac bob" + alice <## "to reject: /rc bob (the sender will NOT be notified)" + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + threadDelay 100000 + alice <##> bob + testMuteContact :: HasCallStack => FilePath -> IO () testMuteContact = testChat2 aliceProfile bobProfile $ @@ -1939,8 +1987,7 @@ testMarkContactVerified = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob alice ##> "/i bob" - bobInfo alice - alice <## "connection not verified, use /code command to see security code" + bobInfo alice False alice ##> "/code bob" bCode <- getTermLine alice bob ##> "/code alice" @@ -1951,28 +1998,31 @@ testMarkContactVerified = alice ##> ("/verify bob " <> aCode) alice <## "connection verified" alice ##> "/i bob" - bobInfo alice - alice <## "connection verified" + bobInfo alice True alice ##> "/verify bob" alice <##. "connection not verified, current code is " alice ##> "/i bob" - bobInfo alice - alice <## "connection not verified, use /code command to see security code" + bobInfo alice False where - bobInfo :: HasCallStack => TestCC -> IO () - bobInfo alice = do + bobInfo :: HasCallStack => TestCC -> Bool -> IO () + bobInfo alice verified = do alice <## "contact ID: 2" alice <## "receiving messages via: localhost" alice <## "sending messages via: localhost" alice <## "you've shared main profile with this contact" + alice <## connVerified + alice <## currentChatVRangeInfo + where + connVerified + | verified = "connection verified" + | otherwise = "connection not verified, use /code command to see security code" testMarkGroupMemberVerified :: HasCallStack => FilePath -> IO () testMarkGroupMemberVerified = testChat2 aliceProfile bobProfile $ \alice bob -> do createGroup2 "team" alice bob alice ##> "/i #team bob" - bobInfo alice - alice <## "connection not verified, use /code command to see security code" + bobInfo alice False alice ##> "/code #team bob" bCode <- getTermLine alice bob ##> "/code #team alice" @@ -1983,20 +2033,24 @@ testMarkGroupMemberVerified = alice ##> ("/verify #team bob " <> aCode) alice <## "connection verified" alice ##> "/i #team bob" - bobInfo alice - alice <## "connection verified" + bobInfo alice True alice ##> "/verify #team bob" alice <##. "connection not verified, current code is " alice ##> "/i #team bob" - bobInfo alice - alice <## "connection not verified, use /code command to see security code" + bobInfo alice False where - bobInfo :: HasCallStack => TestCC -> IO () - bobInfo alice = do + bobInfo :: HasCallStack => TestCC -> Bool -> IO () + bobInfo alice verified = do alice <## "group ID: 1" alice <## "member ID: 2" alice <## "receiving messages via: localhost" alice <## "sending messages via: localhost" + alice <## connVerified + alice <## currentChatVRangeInfo + where + connVerified + | verified = "connection verified" + | otherwise = "connection not verified, use /code command to see security code" testMsgDecryptError :: HasCallStack => FilePath -> IO () testMsgDecryptError tmp = @@ -2088,8 +2142,7 @@ testSyncRatchetCodeReset tmp = alice <# "bob> hey" -- connection not verified bob ##> "/i alice" - aliceInfo bob - bob <## "connection not verified, use /code command to see security code" + aliceInfo bob False -- verify connection alice ##> "/code bob" bCode <- getTermLine alice @@ -2097,8 +2150,7 @@ testSyncRatchetCodeReset tmp = bob <## "connection verified" -- connection verified bob ##> "/i alice" - aliceInfo bob - bob <## "connection verified" + aliceInfo bob True setupDesynchronizedRatchet tmp alice withTestChat tmp "bob_old" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" @@ -2115,20 +2167,25 @@ testSyncRatchetCodeReset tmp = -- connection not verified bob ##> "/i alice" - aliceInfo bob - bob <## "connection not verified, use /code command to see security code" + aliceInfo bob False alice #> "@bob hello again" bob <# "alice> hello again" bob #> "@alice received!" alice <# "bob> received!" where - aliceInfo :: HasCallStack => TestCC -> IO () - aliceInfo bob = do + aliceInfo :: HasCallStack => TestCC -> Bool -> IO () + aliceInfo bob verified = do bob <## "contact ID: 2" bob <## "receiving messages via: localhost" bob <## "sending messages via: localhost" bob <## "you've shared main profile with this contact" + bob <## connVerified + bob <## currentChatVRangeInfo + where + connVerified + | verified = "connection verified" + | otherwise = "connection not verified, use /code command to see security code" testSetMessageReactions :: HasCallStack => FilePath -> IO () testSetMessageReactions = @@ -2271,3 +2328,85 @@ testConfigureDeliveryReceipts tmp = cc1 #> ("@" <> name2 <> " " <> msg) cc2 <# (name1 <> "> " <> msg) cc1 VersionRange -> VersionRange -> FilePath -> IO () +testConnInvChatVRange ct1VRange ct2VRange tmp = + withNewTestChatCfg tmp testCfg {chatVRange = ct1VRange} "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp testCfg {chatVRange = ct2VRange} "bob" bobProfile $ \bob -> do + connectUsers alice bob + + alice ##> "/i bob" + contactInfoChatVRange alice ct2VRange + + bob ##> "/i alice" + contactInfoChatVRange bob ct1VRange + +testConnReqChatVRange :: HasCallStack => VersionRange -> VersionRange -> FilePath -> IO () +testConnReqChatVRange ct1VRange ct2VRange tmp = + withNewTestChatCfg tmp testCfg {chatVRange = ct1VRange} "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp testCfg {chatVRange = ct2VRange} "bob" bobProfile $ \bob -> do + alice ##> "/ad" + cLink <- getContactLink alice True + bob ##> ("/c " <> cLink) + alice <#? bob + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + + alice ##> "/i bob" + contactInfoChatVRange alice ct2VRange + + bob ##> "/i alice" + contactInfoChatVRange bob ct1VRange + +testUpdatePeerChatVRange :: HasCallStack => FilePath -> IO () +testUpdatePeerChatVRange tmp = + withNewTestChat tmp "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp cfg11 "bob" bobProfile $ \bob -> do + connectUsers alice bob + + alice ##> "/i bob" + contactInfoChatVRange alice vr11 + + bob ##> "/i alice" + contactInfoChatVRange bob supportedChatVRange + + withTestChat tmp "bob" $ \bob -> do + bob <## "1 contacts connected (use /cs for the list)" + + bob #> "@alice hello 1" + alice <# "bob> hello 1" + + alice ##> "/i bob" + contactInfoChatVRange alice supportedChatVRange + + bob ##> "/i alice" + contactInfoChatVRange bob supportedChatVRange + + withTestChatCfg tmp cfg11 "bob" $ \bob -> do + bob <## "1 contacts connected (use /cs for the list)" + + bob #> "@alice hello 2" + alice <# "bob> hello 2" + + alice ##> "/i bob" + contactInfoChatVRange alice vr11 + + bob ##> "/i alice" + contactInfoChatVRange bob supportedChatVRange + where + cfg11 = testCfg {chatVRange = vr11} :: ChatConfig + +vr11 :: VersionRange +vr11 = mkVersionRange 1 1 + +contactInfoChatVRange :: TestCC -> VersionRange -> IO () +contactInfoChatVRange cc (VersionRange minVer maxVer) = do + cc <## "contact ID: 2" + cc <## "receiving messages via: localhost" + cc <## "sending messages via: localhost" + cc <## "you've shared main profile with this contact" + cc <## "connection not verified, use /code command to see security code" + cc <## ("peer chat protocol version range: (" <> show minVer <> ", " <> show maxVer <> ")") diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index 6600e175bd..4213d39686 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -10,14 +10,19 @@ import ChatClient import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) +import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy.Char8 as LB import Simplex.Chat (roundedFDCount) import Simplex.Chat.Controller (ChatConfig (..), InlineFilesConfig (..), XFTPFileConfig (..), defaultInlineFilesConfig) +import Simplex.Chat.Mobile.File import Simplex.Chat.Options (ChatOpts (..)) import Simplex.FileTransfer.Client.Main (xftpClientCLI) import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..)) +import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) +import Simplex.Messaging.Encoding.String import Simplex.Messaging.Util (unlessM) -import System.Directory (copyFile, doesFileExist) +import System.Directory (copyFile, createDirectoryIfMissing, doesFileExist, getFileSize) import System.Environment (withArgs) import System.IO.Silently (capture_) import Test.Hspec @@ -48,7 +53,7 @@ chatFileTests = do it "files folder: sender deleted file during transfer" testFilesFoldersImageSndDelete it "files folder: recipient deleted file during transfer" testFilesFoldersImageRcvDelete it "send and receive image with text and quote" testSendImageWithTextAndQuote - describe "send and receive image to group" testGroupSendImage + it "send and receive image to group" testGroupSendImage it "send and receive image with text and quote to group" testGroupSendImageWithTextAndQuote describe "async sending and receiving files" $ do -- fails on CI @@ -61,6 +66,7 @@ chatFileTests = do describe "file transfer over XFTP" $ do it "round file description count" $ const testXFTPRoundFDCount it "send and receive file" testXFTPFileTransfer + it "send and receive locally encrypted files" testXFTPFileTransferEncrypted it "send and receive file, accepting after upload" testXFTPAcceptAfterUpload it "send and receive file in group" testXFTPGroupFileTransfer it "delete uploaded file" testXFTPDeleteUploadedFile @@ -726,11 +732,10 @@ testSendImageWithTextAndQuote = (alice <## "completed sending file 3 (test.jpg) to bob") B.readFile "./tests/tmp/test_1.jpg" `shouldReturn` src -testGroupSendImage :: SpecWith FilePath -testGroupSendImage = versionTestMatrix3 runTestGroupSendImage - where - runTestGroupSendImage :: HasCallStack => TestCC -> TestCC -> TestCC -> IO () - runTestGroupSendImage alice bob cath = do +testGroupSendImage :: HasCallStack => FilePath -> IO () +testGroupSendImage = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do createGroup3 "team" alice bob cath threadDelay 1000000 alice ##> "/_send #1 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}" @@ -1015,6 +1020,35 @@ testXFTPFileTransfer = where cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} +testXFTPFileTransferEncrypted :: HasCallStack => FilePath -> IO () +testXFTPFileTransferEncrypted = + testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do + src <- B.readFile "./tests/fixtures/test.pdf" + srcLen <- getFileSize "./tests/fixtures/test.pdf" + let srcPath = "./tests/tmp/alice/test.pdf" + createDirectoryIfMissing True "./tests/tmp/alice/" + createDirectoryIfMissing True "./tests/tmp/bob/" + WFResult cfArgs <- chatWriteFile srcPath src + let fileJSON = LB.unpack $ J.encode $ CryptoFile srcPath $ Just cfArgs + withXFTPServer $ do + connectUsers alice bob + alice ##> ("/_send @2 json {\"msgContent\":{\"type\":\"file\", \"text\":\"\"}, \"fileSource\": " <> fileJSON <> "}") + alice <# "/f @bob ./tests/tmp/alice/test.pdf" + alice <## "use /fc 1 to cancel sending" + bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + bob ##> "/fr 1 encrypt=on ./tests/tmp/bob/" + bob <## "saving file 1 from alice to ./tests/tmp/bob/test.pdf" + Just (CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine bob + alice <## "completed uploading file 1 (test.pdf) for bob" + bob <## "started receiving file 1 (test.pdf) from alice" + bob <## "completed receiving file 1 (test.pdf) from alice" + Right dest <- chatReadFile "./tests/tmp/bob/test.pdf" (strEncode key) (strEncode nonce) + LB.length dest `shouldBe` fromIntegral srcLen + LB.toStrict dest `shouldBe` src + where + cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} + testXFTPAcceptAfterUpload :: HasCallStack => FilePath -> IO () testXFTPAcceptAfterUpload = testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do @@ -1449,7 +1483,7 @@ startFileTransfer alice bob = startFileTransfer' alice bob "test.jpg" "136.5 KiB / 139737 bytes" startFileTransfer' :: HasCallStack => TestCC -> TestCC -> String -> String -> IO () -startFileTransfer' cc1 cc2 fileName fileSize = startFileTransferWithDest' cc1 cc2 fileName fileSize $ Just "./tests/tmp" +startFileTransfer' cc1 cc2 fName fSize = startFileTransferWithDest' cc1 cc2 fName fSize $ Just "./tests/tmp" checkPartialTransfer :: HasCallStack => String -> IO () checkPartialTransfer fileName = do diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 2f5b2689ba..d476285fcd 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -10,8 +10,10 @@ import Control.Concurrent.Async (concurrently_) import Control.Monad (when) import qualified Data.Text as T import Simplex.Chat.Controller (ChatConfig (..)) +import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Store (agentStoreFile, chatStoreFile) import Simplex.Chat.Types (GroupMemberRole (..)) +import Simplex.Messaging.Version import System.Directory (copyFile) import System.FilePath (()) import Test.Hspec @@ -19,7 +21,7 @@ import Test.Hspec chatGroupTests :: SpecWith FilePath chatGroupTests = do describe "chat groups" $ do - describe "add contacts, create group and send/receive messages" testGroup + it "add contacts, create group and send/receive messages" testGroup it "add contacts, create group and send/receive messages, check messages" testGroupCheckMessages it "create and join group with 4 members" testGroup2 it "create and delete group" testGroupDelete @@ -64,15 +66,54 @@ chatGroupTests = do describe "group delivery receipts" $ do it "should send delivery receipts in group" testSendGroupDeliveryReceipts it "should send delivery receipts in group depending on configuration" testConfigureGroupDeliveryReceipts - -testGroup :: HasCallStack => SpecWith FilePath -testGroup = versionTestMatrix3 runTestGroup + describe "direct connections in group are not established based on chat protocol version" $ do + describe "3 members group" $ do + testNoDirect _0 _0 False -- True + testNoDirect _0 _1 False -- True + testNoDirect _1 _0 False + testNoDirect _1 _1 False + describe "4 members group" $ do + testNoDirect4 _0 _0 _0 False False False -- True True True + testNoDirect4 _0 _0 _1 False False False -- True True True + testNoDirect4 _0 _1 _0 False False False -- True True False + testNoDirect4 _0 _1 _1 False False False -- True True False + testNoDirect4 _1 _0 _0 False False False -- False False True + testNoDirect4 _1 _0 _1 False False False -- False False True + testNoDirect4 _1 _1 _0 False False False + testNoDirect4 _1 _1 _1 False False False where - runTestGroup alice bob cath = testGroupShared alice bob cath False + _0 = supportedChatVRange -- don't create direct connections + _1 = groupCreateDirectVRange + -- having host configured with older version doesn't have effect in tests + -- because host uses current code and sends version in MemberInfo + testNoDirect vrMem2 vrMem3 noConns = + it + ( "host " <> vRangeStr supportedChatVRange + <> (", 2nd mem " <> vRangeStr vrMem2) + <> (", 3rd mem " <> vRangeStr vrMem3) + <> (if noConns then " : 2 3" else " : 2 <##> 3") + ) + $ testNoGroupDirectConns supportedChatVRange vrMem2 vrMem3 noConns + testNoDirect4 vrMem2 vrMem3 vrMem4 noConns23 noConns24 noConns34 = + it + ( "host " <> vRangeStr supportedChatVRange + <> (", 2nd mem " <> vRangeStr vrMem2) + <> (", 3rd mem " <> vRangeStr vrMem3) + <> (", 4th mem " <> vRangeStr vrMem4) + <> (if noConns23 then " : 2 3" else " : 2 <##> 3") + <> (if noConns24 then " : 2 4" else " : 2 <##> 4") + <> (if noConns34 then " : 3 4" else " : 3 <##> 4") + ) + $ testNoGroupDirectConns4Members supportedChatVRange vrMem2 vrMem3 vrMem4 noConns23 noConns24 noConns34 + +testGroup :: HasCallStack => FilePath -> IO () +testGroup = + testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ + \alice bob cath -> testGroupShared alice bob cath False testGroupCheckMessages :: HasCallStack => FilePath -> IO () testGroupCheckMessages = - testChat3 aliceProfile bobProfile cathProfile $ + testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> testGroupShared alice bob cath True testGroupShared :: HasCallStack => TestCC -> TestCC -> TestCC -> Bool -> IO () @@ -233,7 +274,7 @@ testGroupShared alice bob cath checkMessages = do testGroup2 :: HasCallStack => FilePath -> IO () testGroup2 = - testChat4 aliceProfile bobProfile cathProfile danProfile $ + testChatCfg4 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do connectUsers alice bob connectUsers alice cath @@ -679,7 +720,7 @@ testDeleteGroupMemberProfileKept = testGroupRemoveAdd :: HasCallStack => FilePath -> IO () testGroupRemoveAdd = - testChat3 aliceProfile bobProfile cathProfile $ + testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath -- remove member @@ -754,7 +795,7 @@ testGroupList = testGroupMessageQuotedReply :: HasCallStack => FilePath -> IO () testGroupMessageQuotedReply = - testChat3 aliceProfile bobProfile cathProfile $ + testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath threadDelay 1000000 @@ -1232,7 +1273,7 @@ testGroupDeleteUnusedContacts = cath <## "alice (Alice)" cath `hasContactProfiles` ["alice", "cath"] where - cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerInterval = 1, cleanupManagerStepDelay = 0} + cfg = mkCfgCreateGroupDirect $ testCfg {initialCleanupManagerDelay = 0, cleanupManagerInterval = 1, cleanupManagerStepDelay = 0} deleteGroup :: HasCallStack => TestCC -> TestCC -> TestCC -> String -> IO () deleteGroup alice bob cath group = do alice ##> ("/d #" <> group) @@ -1321,7 +1362,7 @@ testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile testGroupModerate :: HasCallStack => FilePath -> IO () testGroupModerate = - testChat3 aliceProfile bobProfile cathProfile $ + testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath alice ##> "/mr team cath member" @@ -1352,7 +1393,7 @@ testGroupModerate = testGroupModerateFullDelete :: HasCallStack => FilePath -> IO () testGroupModerateFullDelete = - testChat3 aliceProfile bobProfile cathProfile $ + testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath alice ##> "/mr team cath member" @@ -1390,10 +1431,10 @@ testGroupModerateFullDelete = testGroupDelayedModeration :: HasCallStack => FilePath -> IO () testGroupDelayedModeration tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withNewTestChatCfg tmp cfg "cath" cathProfile $ \cath -> do connectUsers alice cath addMember "team" alice cath GRMember cath ##> "/j team" @@ -1407,11 +1448,11 @@ testGroupDelayedModeration tmp = do alice ##> "\\\\ #team @cath hi" alice <## "message marked deleted by you" cath <# "#team cath> [marked deleted by alice] hi" - withTestChat tmp "bob" $ \bob -> do + withTestChatCfg tmp cfg "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" bob <## "#team: alice added cath (Catherine) to the group (connecting...)" - withTestChat tmp "cath" $ \cath -> do + withTestChatCfg tmp cfg "cath" $ \cath -> do cath <## "2 contacts connected (use /cs for the list)" cath <## "#team: connected to server(s)" cath <## "#team: member bob (Bob) is connected" @@ -1424,13 +1465,15 @@ testGroupDelayedModeration tmp = do bob ##> "/_get chat #1 count=2" r <- chat <$> getTermLine bob r `shouldMatchList` [(0, "connected"), (0, "hi [marked deleted by alice]")] + where + cfg = testCfgCreateGroupDirect testGroupDelayedModerationFullDelete :: HasCallStack => FilePath -> IO () testGroupDelayedModerationFullDelete tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withNewTestChatCfg tmp cfg "cath" cathProfile $ \cath -> do connectUsers alice cath addMember "team" alice cath GRMember cath ##> "/j team" @@ -1452,14 +1495,14 @@ testGroupDelayedModerationFullDelete tmp = do cath <## "alice updated group #team:" cath <## "updated group preferences:" cath <## "Full deletion: on" - withTestChat tmp "bob" $ \bob -> do + withTestChatCfg tmp cfg "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" bob <## "#team: alice added cath (Catherine) to the group (connecting...)" bob <## "alice updated group #team:" bob <## "updated group preferences:" bob <## "Full deletion: on" - withTestChat tmp "cath" $ \cath -> do + withTestChatCfg tmp cfg "cath" $ \cath -> do cath <## "2 contacts connected (use /cs for the list)" cath <## "#team: connected to server(s)" cath <## "#team: member bob (Bob) is connected" @@ -1472,6 +1515,8 @@ testGroupDelayedModerationFullDelete tmp = do bob ##> "/_get chat #1 count=3" r <- chat <$> getTermLine bob r `shouldMatchList` [(0, "Full deletion: on"), (0, "connected"), (0, "moderated [deleted by alice]")] + where + cfg = testCfgCreateGroupDirect testGroupAsync :: HasCallStack => FilePath -> IO () testGroupAsync tmp = do @@ -2127,7 +2172,7 @@ testGroupLinkMemberRole = testGroupLinkLeaveDelete :: HasCallStack => FilePath -> IO () testGroupLinkLeaveDelete = - testChat3 aliceProfile bobProfile cathProfile $ + testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do connectUsers alice bob connectUsers cath bob @@ -2289,8 +2334,7 @@ testGroupSyncRatchetCodeReset tmp = alice <# "#team bob> hey" -- connection not verified bob ##> "/i #team alice" - aliceInfo bob - bob <## "connection not verified, use /code command to see security code" + aliceInfo bob False -- verify connection alice ##> "/code #team bob" bCode <- getTermLine alice @@ -2298,8 +2342,7 @@ testGroupSyncRatchetCodeReset tmp = bob <## "connection verified" -- connection verified bob ##> "/i #team alice" - aliceInfo bob - bob <## "connection verified" + aliceInfo bob True setupDesynchronizedRatchet tmp alice withTestChat tmp "bob_old" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" @@ -2317,20 +2360,25 @@ testGroupSyncRatchetCodeReset tmp = -- connection not verified bob ##> "/i #team alice" - aliceInfo bob - bob <## "connection not verified, use /code command to see security code" + aliceInfo bob False alice #> "#team hello again" bob <# "#team alice> hello again" bob #> "#team received!" alice <# "#team bob> received!" where - aliceInfo :: HasCallStack => TestCC -> IO () - aliceInfo bob = do + aliceInfo :: HasCallStack => TestCC -> Bool -> IO () + aliceInfo bob verified = do bob <## "group ID: 1" bob <## "member ID: 1" bob <## "receiving messages via: localhost" bob <## "sending messages via: localhost" + bob <## connVerified + bob <## currentChatVRangeInfo + where + connVerified + | verified = "connection verified" + | otherwise = "connection not verified, use /code command to see security code" testSetGroupMessageReactions :: HasCallStack => FilePath -> IO () testSetGroupMessageReactions = @@ -2559,7 +2607,7 @@ testConfigureGroupDeliveryReceipts tmp = receipt bob alice cath "team" "25" noReceipt bob alice cath "club" "26" where - cfg = testCfg {showReceipts = True} + cfg = mkCfgCreateGroupDirect $ testCfg {showReceipts = True} receipt cc1 cc2 cc3 gName msg = do name1 <- userName cc1 cc1 #> ("#" <> gName <> " " <> msg) @@ -2579,3 +2627,62 @@ testConfigureGroupDeliveryReceipts tmp = cc2 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) cc3 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) cc1 VersionRange -> VersionRange -> VersionRange -> Bool -> FilePath -> IO () +testNoGroupDirectConns hostVRange mem2VRange mem3VRange noDirectConns tmp = + withNewTestChatCfg tmp testCfg {chatVRange = hostVRange} "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp testCfg {chatVRange = mem2VRange} "bob" bobProfile $ \bob -> do + withNewTestChatCfg tmp testCfg {chatVRange = mem3VRange} "cath" cathProfile $ \cath -> do + createGroup3 "team" alice bob cath + if noDirectConns + then contactsDontExist bob cath + else bob <##> cath + where + contactsDontExist bob cath = do + bob ##> "@cath hi" + bob <## "no contact cath" + cath ##> "@bob hi" + cath <## "no contact bob" + +testNoGroupDirectConns4Members :: HasCallStack => VersionRange -> VersionRange -> VersionRange -> VersionRange -> Bool -> Bool -> Bool -> FilePath -> IO () +testNoGroupDirectConns4Members hostVRange mem2VRange mem3VRange mem4VRange noConns23 noConns24 noConns34 tmp = + withNewTestChatCfg tmp testCfg {chatVRange = hostVRange} "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp testCfg {chatVRange = mem2VRange} "bob" bobProfile $ \bob -> do + withNewTestChatCfg tmp testCfg {chatVRange = mem3VRange} "cath" cathProfile $ \cath -> do + withNewTestChatCfg tmp testCfg {chatVRange = mem4VRange} "dan" danProfile $ \dan -> do + createGroup3 "team" alice bob cath + connectUsers alice dan + addMember "team" alice dan GRMember + dan ##> "/j team" + concurrentlyN_ + [ alice <## "#team: dan joined the group", + do + dan <## "#team: you joined the group" + dan + <### [ "#team: member bob (Bob) is connected", + "#team: member cath (Catherine) is connected" + ], + aliceAddedDan bob, + aliceAddedDan cath + ] + if noConns23 + then contactsDontExist bob cath + else bob <##> cath + if noConns24 + then contactsDontExist bob dan + else bob <##> dan + if noConns34 + then contactsDontExist cath dan + else cath <##> dan + where + aliceAddedDan :: HasCallStack => TestCC -> IO () + aliceAddedDan cc = do + cc <## "#team: alice added dan (Daniel) to the group (connecting...)" + cc <## "#team: new member dan is connected" + contactsDontExist cc1 cc2 = do + name1 <- userName cc1 + name2 <- userName cc2 + cc1 ##> ("@" <> name2 <> " hi") + cc1 <## ("no contact " <> name2) + cc2 ##> ("@" <> name1 <> " hi") + cc2 <## ("no contact " <> name1) diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 98c840388e..1a2b74f76e 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -18,7 +18,7 @@ chatProfileTests = do it "update user profile and notify contacts" testUpdateProfile it "update user profile with image" testUpdateProfileImage describe "user contact link" $ do - describe "create and connect via contact link" testUserContactLink + it "create and connect via contact link" testUserContactLink it "add contact link to profile" testProfileLink it "auto accept contact requests" testUserContactLinkAutoAccept it "deduplicate contact requests" testDeduplicateContactRequests @@ -57,7 +57,7 @@ chatProfileTests = do testUpdateProfile :: HasCallStack => FilePath -> IO () testUpdateProfile = - testChat3 aliceProfile bobProfile cathProfile $ + testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath alice ##> "/p" @@ -117,33 +117,35 @@ testUpdateProfileImage = bob <## "use @alice2 to send messages" (bob do - alice ##> "/ad" - cLink <- getContactLink alice True - bob ##> ("/c " <> cLink) - alice <#? bob - alice @@@ [("<@bob", "")] - alice ##> "/ac bob" - alice <## "bob (Bob): accepting contact request..." - concurrently_ - (bob <## "alice (Alice): contact is connected") - (alice <## "bob (Bob): contact is connected") - threadDelay 100000 - alice @@@ [("@bob", lastChatFeature)] - alice <##> bob +testUserContactLink :: HasCallStack => FilePath -> IO () +testUserContactLink = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + alice ##> "/ad" + cLink <- getContactLink alice True + bob ##> ("/c " <> cLink) + alice <#? bob + alice @@@ [("<@bob", "")] + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + threadDelay 100000 + alice @@@ [("@bob", lastChatFeature)] + alice <##> bob - cath ##> ("/c " <> cLink) - alice <#? cath - alice @@@ [("<@cath", ""), ("@bob", "hey")] - alice ##> "/ac cath" - alice <## "cath (Catherine): accepting contact request..." - concurrently_ - (cath <## "alice (Alice): contact is connected") - (alice <## "cath (Catherine): contact is connected") - threadDelay 100000 - alice @@@ [("@cath", lastChatFeature), ("@bob", "hey")] - alice <##> cath + cath ##> ("/c " <> cLink) + alice <#? cath + alice @@@ [("<@cath", ""), ("@bob", "hey")] + alice ##> "/ac cath" + alice <## "cath (Catherine): accepting contact request..." + concurrently_ + (cath <## "alice (Alice): contact is connected") + (alice <## "cath (Catherine): contact is connected") + threadDelay 100000 + alice @@@ [("@cath", lastChatFeature), ("@bob", "hey")] + alice <##> cath testProfileLink :: HasCallStack => FilePath -> IO () testProfileLink = @@ -214,6 +216,7 @@ testProfileLink = cc <## ("contact address: " <> cLink) cc <## "you've shared main profile with this contact" cc <## "connection not verified, use /code command to see security code" + cc <## currentChatVRangeInfo checkAliceNoProfileLink cc = do cc ##> "/info alice" cc <## "contact ID: 2" @@ -221,6 +224,7 @@ testProfileLink = cc <##. "sending messages via" cc <## "you've shared main profile with this contact" cc <## "connection not verified, use /code command to see security code" + cc <## currentChatVRangeInfo testUserContactLinkAutoAccept :: HasCallStack => FilePath -> IO () testUserContactLinkAutoAccept = @@ -760,192 +764,193 @@ testSetResetSetConnectionIncognito = testChat2 aliceProfile bobProfile $ bob `hasContactProfiles` ["bob", T.pack aliceIncognito] testJoinGroupIncognito :: HasCallStack => FilePath -> IO () -testJoinGroupIncognito = testChat4 aliceProfile bobProfile cathProfile danProfile $ - \alice bob cath dan -> do - -- non incognito connections - connectUsers alice bob - connectUsers alice dan - connectUsers bob cath - connectUsers bob dan - connectUsers cath dan - -- cath connected incognito to alice - alice ##> "/c" - inv <- getInvitation alice - cath ##> ("/c i " <> inv) - cath <## "confirmation sent!" - cathIncognito <- getTermLine cath - concurrentlyN_ - [ do - cath <## ("alice (Alice): contact is connected, your incognito profile for this contact is " <> cathIncognito) - cath <## "use /i alice to print out this incognito profile again", - alice <## (cathIncognito <> ": contact is connected") - ] - -- alice creates group - alice ##> "/g secret_club" - alice <## "group #secret_club is created" - alice <## "to add members use /a secret_club or /create link #secret_club" - -- alice invites bob - alice ##> "/a secret_club bob admin" - concurrentlyN_ - [ alice <## "invitation to join the group #secret_club sent to bob", - do - bob <## "#secret_club: alice invites you to join the group as admin" - bob <## "use /j secret_club to accept" - ] - bob ##> "/j secret_club" - concurrently_ - (alice <## "#secret_club: bob joined the group") - (bob <## "#secret_club: you joined the group") - -- alice invites cath - alice ##> ("/a secret_club " <> cathIncognito <> " admin") - concurrentlyN_ - [ alice <## ("invitation to join the group #secret_club sent to " <> cathIncognito), - do - cath <## "#secret_club: alice invites you to join the group as admin" - cath <## ("use /j secret_club to join incognito as " <> cathIncognito) - ] - -- cath uses the same incognito profile when joining group, cath and bob don't merge contacts - cath ##> "/j secret_club" - concurrentlyN_ - [ alice <## ("#secret_club: " <> cathIncognito <> " joined the group"), - do - cath <## ("#secret_club: you joined the group incognito as " <> cathIncognito) - cath <## "#secret_club: member bob_1 (Bob) is connected", - do - bob <## ("#secret_club: alice added " <> cathIncognito <> " to the group (connecting...)") - bob <## ("#secret_club: new member " <> cathIncognito <> " is connected") - ] - -- cath cannot invite to the group because her membership is incognito - cath ##> "/a secret_club dan" - cath <## "you've connected to this group using an incognito profile - prohibited to invite contacts" - -- alice invites dan - alice ##> "/a secret_club dan admin" - concurrentlyN_ - [ alice <## "invitation to join the group #secret_club sent to dan", - do - dan <## "#secret_club: alice invites you to join the group as admin" - dan <## "use /j secret_club to accept" - ] - dan ##> "/j secret_club" - -- cath and dan don't merge contacts - concurrentlyN_ - [ alice <## "#secret_club: dan joined the group", - do - dan <## "#secret_club: you joined the group" - dan - <### [ ConsoleString $ "#secret_club: member " <> cathIncognito <> " is connected", - "#secret_club: member bob_1 (Bob) is connected", - "contact bob_1 is merged into bob", - "use @bob to send messages" - ], - do - bob <## "#secret_club: alice added dan_1 (Daniel) to the group (connecting...)" - bob <## "#secret_club: new member dan_1 is connected" - bob <## "contact dan_1 is merged into dan" - bob <## "use @dan to send messages", - do - cath <## "#secret_club: alice added dan_1 (Daniel) to the group (connecting...)" - cath <## "#secret_club: new member dan_1 is connected" - ] - -- send messages - group is incognito for cath - alice #> "#secret_club hello" - concurrentlyN_ - [ bob <# "#secret_club alice> hello", - cath ?<# "#secret_club alice> hello", - dan <# "#secret_club alice> hello" - ] - bob #> "#secret_club hi there" - concurrentlyN_ - [ alice <# "#secret_club bob> hi there", - cath ?<# "#secret_club bob_1> hi there", - dan <# "#secret_club bob> hi there" - ] - cath ?#> "#secret_club hey" - concurrentlyN_ - [ alice <# ("#secret_club " <> cathIncognito <> "> hey"), - bob <# ("#secret_club " <> cathIncognito <> "> hey"), - dan <# ("#secret_club " <> cathIncognito <> "> hey") - ] - dan #> "#secret_club how is it going?" - concurrentlyN_ - [ alice <# "#secret_club dan> how is it going?", - bob <# "#secret_club dan> how is it going?", - cath ?<# "#secret_club dan_1> how is it going?" - ] - -- cath and bob can send messages via new direct connection, cath is incognito - bob #> ("@" <> cathIncognito <> " hi, I'm bob") - cath ?<# "bob_1> hi, I'm bob" - cath ?#> "@bob_1 hey, I'm incognito" - bob <# (cathIncognito <> "> hey, I'm incognito") - -- cath and dan can send messages via new direct connection, cath is incognito - dan #> ("@" <> cathIncognito <> " hi, I'm dan") - cath ?<# "dan_1> hi, I'm dan" - cath ?#> "@dan_1 hey, I'm incognito" - dan <# (cathIncognito <> "> hey, I'm incognito") - -- non incognito connections are separate - bob <##> cath - dan <##> cath - -- list groups - cath ##> "/gs" - cath <## "i #secret_club (4 members)" - -- list group members - alice ##> "/ms secret_club" - alice - <### [ "alice (Alice): owner, you, created group", - "bob (Bob): admin, invited, connected", - ConsoleString $ cathIncognito <> ": admin, invited, connected", - "dan (Daniel): admin, invited, connected" - ] - bob ##> "/ms secret_club" - bob - <### [ "alice (Alice): owner, host, connected", - "bob (Bob): admin, you, connected", - ConsoleString $ cathIncognito <> ": admin, connected", - "dan (Daniel): admin, connected" - ] - cath ##> "/ms secret_club" - cath - <### [ "alice (Alice): owner, host, connected", - "bob_1 (Bob): admin, connected", - ConsoleString $ "i " <> cathIncognito <> ": admin, you, connected", - "dan_1 (Daniel): admin, connected" - ] - dan ##> "/ms secret_club" - dan - <### [ "alice (Alice): owner, host, connected", - "bob (Bob): admin, connected", - ConsoleString $ cathIncognito <> ": admin, connected", - "dan (Daniel): admin, you, connected" - ] - -- remove member - bob ##> ("/rm secret_club " <> cathIncognito) - concurrentlyN_ - [ bob <## ("#secret_club: you removed " <> cathIncognito <> " from the group"), - alice <## ("#secret_club: bob removed " <> cathIncognito <> " from the group"), - dan <## ("#secret_club: bob removed " <> cathIncognito <> " from the group"), - do - cath <## "#secret_club: bob_1 removed you from the group" - cath <## "use /d #secret_club to delete the group" - ] - bob #> "#secret_club hi" - concurrentlyN_ - [ alice <# "#secret_club bob> hi", - dan <# "#secret_club bob> hi", - (cath "#secret_club hello" - concurrentlyN_ - [ bob <# "#secret_club alice> hello", - dan <# "#secret_club alice> hello", - (cath "#secret_club hello" - cath <## "you are no longer a member of the group" - -- cath can still message members directly - bob #> ("@" <> cathIncognito <> " I removed you from group") - cath ?<# "bob_1> I removed you from group" - cath ?#> "@bob_1 ok" - bob <# (cathIncognito <> "> ok") +testJoinGroupIncognito = + testChatCfg4 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile danProfile $ + \alice bob cath dan -> do + -- non incognito connections + connectUsers alice bob + connectUsers alice dan + connectUsers bob cath + connectUsers bob dan + connectUsers cath dan + -- cath connected incognito to alice + alice ##> "/c" + inv <- getInvitation alice + cath ##> ("/c i " <> inv) + cath <## "confirmation sent!" + cathIncognito <- getTermLine cath + concurrentlyN_ + [ do + cath <## ("alice (Alice): contact is connected, your incognito profile for this contact is " <> cathIncognito) + cath <## "use /i alice to print out this incognito profile again", + alice <## (cathIncognito <> ": contact is connected") + ] + -- alice creates group + alice ##> "/g secret_club" + alice <## "group #secret_club is created" + alice <## "to add members use /a secret_club or /create link #secret_club" + -- alice invites bob + alice ##> "/a secret_club bob admin" + concurrentlyN_ + [ alice <## "invitation to join the group #secret_club sent to bob", + do + bob <## "#secret_club: alice invites you to join the group as admin" + bob <## "use /j secret_club to accept" + ] + bob ##> "/j secret_club" + concurrently_ + (alice <## "#secret_club: bob joined the group") + (bob <## "#secret_club: you joined the group") + -- alice invites cath + alice ##> ("/a secret_club " <> cathIncognito <> " admin") + concurrentlyN_ + [ alice <## ("invitation to join the group #secret_club sent to " <> cathIncognito), + do + cath <## "#secret_club: alice invites you to join the group as admin" + cath <## ("use /j secret_club to join incognito as " <> cathIncognito) + ] + -- cath uses the same incognito profile when joining group, cath and bob don't merge contacts + cath ##> "/j secret_club" + concurrentlyN_ + [ alice <## ("#secret_club: " <> cathIncognito <> " joined the group"), + do + cath <## ("#secret_club: you joined the group incognito as " <> cathIncognito) + cath <## "#secret_club: member bob_1 (Bob) is connected", + do + bob <## ("#secret_club: alice added " <> cathIncognito <> " to the group (connecting...)") + bob <## ("#secret_club: new member " <> cathIncognito <> " is connected") + ] + -- cath cannot invite to the group because her membership is incognito + cath ##> "/a secret_club dan" + cath <## "you've connected to this group using an incognito profile - prohibited to invite contacts" + -- alice invites dan + alice ##> "/a secret_club dan admin" + concurrentlyN_ + [ alice <## "invitation to join the group #secret_club sent to dan", + do + dan <## "#secret_club: alice invites you to join the group as admin" + dan <## "use /j secret_club to accept" + ] + dan ##> "/j secret_club" + -- cath and dan don't merge contacts + concurrentlyN_ + [ alice <## "#secret_club: dan joined the group", + do + dan <## "#secret_club: you joined the group" + dan + <### [ ConsoleString $ "#secret_club: member " <> cathIncognito <> " is connected", + "#secret_club: member bob_1 (Bob) is connected", + "contact bob_1 is merged into bob", + "use @bob to send messages" + ], + do + bob <## "#secret_club: alice added dan_1 (Daniel) to the group (connecting...)" + bob <## "#secret_club: new member dan_1 is connected" + bob <## "contact dan_1 is merged into dan" + bob <## "use @dan to send messages", + do + cath <## "#secret_club: alice added dan_1 (Daniel) to the group (connecting...)" + cath <## "#secret_club: new member dan_1 is connected" + ] + -- send messages - group is incognito for cath + alice #> "#secret_club hello" + concurrentlyN_ + [ bob <# "#secret_club alice> hello", + cath ?<# "#secret_club alice> hello", + dan <# "#secret_club alice> hello" + ] + bob #> "#secret_club hi there" + concurrentlyN_ + [ alice <# "#secret_club bob> hi there", + cath ?<# "#secret_club bob_1> hi there", + dan <# "#secret_club bob> hi there" + ] + cath ?#> "#secret_club hey" + concurrentlyN_ + [ alice <# ("#secret_club " <> cathIncognito <> "> hey"), + bob <# ("#secret_club " <> cathIncognito <> "> hey"), + dan <# ("#secret_club " <> cathIncognito <> "> hey") + ] + dan #> "#secret_club how is it going?" + concurrentlyN_ + [ alice <# "#secret_club dan> how is it going?", + bob <# "#secret_club dan> how is it going?", + cath ?<# "#secret_club dan_1> how is it going?" + ] + -- cath and bob can send messages via new direct connection, cath is incognito + bob #> ("@" <> cathIncognito <> " hi, I'm bob") + cath ?<# "bob_1> hi, I'm bob" + cath ?#> "@bob_1 hey, I'm incognito" + bob <# (cathIncognito <> "> hey, I'm incognito") + -- cath and dan can send messages via new direct connection, cath is incognito + dan #> ("@" <> cathIncognito <> " hi, I'm dan") + cath ?<# "dan_1> hi, I'm dan" + cath ?#> "@dan_1 hey, I'm incognito" + dan <# (cathIncognito <> "> hey, I'm incognito") + -- non incognito connections are separate + bob <##> cath + dan <##> cath + -- list groups + cath ##> "/gs" + cath <## "i #secret_club (4 members)" + -- list group members + alice ##> "/ms secret_club" + alice + <### [ "alice (Alice): owner, you, created group", + "bob (Bob): admin, invited, connected", + ConsoleString $ cathIncognito <> ": admin, invited, connected", + "dan (Daniel): admin, invited, connected" + ] + bob ##> "/ms secret_club" + bob + <### [ "alice (Alice): owner, host, connected", + "bob (Bob): admin, you, connected", + ConsoleString $ cathIncognito <> ": admin, connected", + "dan (Daniel): admin, connected" + ] + cath ##> "/ms secret_club" + cath + <### [ "alice (Alice): owner, host, connected", + "bob_1 (Bob): admin, connected", + ConsoleString $ "i " <> cathIncognito <> ": admin, you, connected", + "dan_1 (Daniel): admin, connected" + ] + dan ##> "/ms secret_club" + dan + <### [ "alice (Alice): owner, host, connected", + "bob (Bob): admin, connected", + ConsoleString $ cathIncognito <> ": admin, connected", + "dan (Daniel): admin, you, connected" + ] + -- remove member + bob ##> ("/rm secret_club " <> cathIncognito) + concurrentlyN_ + [ bob <## ("#secret_club: you removed " <> cathIncognito <> " from the group"), + alice <## ("#secret_club: bob removed " <> cathIncognito <> " from the group"), + dan <## ("#secret_club: bob removed " <> cathIncognito <> " from the group"), + do + cath <## "#secret_club: bob_1 removed you from the group" + cath <## "use /d #secret_club to delete the group" + ] + bob #> "#secret_club hi" + concurrentlyN_ + [ alice <# "#secret_club bob> hi", + dan <# "#secret_club bob> hi", + (cath "#secret_club hello" + concurrentlyN_ + [ bob <# "#secret_club alice> hello", + dan <# "#secret_club alice> hello", + (cath "#secret_club hello" + cath <## "you are no longer a member of the group" + -- cath can still message members directly + bob #> ("@" <> cathIncognito <> " I removed you from group") + cath ?<# "bob_1> I removed you from group" + cath ?#> "@bob_1 ok" + bob <# (cathIncognito <> "> ok") testCantInviteContactIncognito :: HasCallStack => FilePath -> IO () testCantInviteContactIncognito = testChat2 aliceProfile bobProfile $ @@ -1354,54 +1359,55 @@ testAllowFullDeletionGroup = testProhibitDirectMessages :: HasCallStack => FilePath -> IO () testProhibitDirectMessages = - testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do - createGroup3 "team" alice bob cath - threadDelay 1000000 - alice ##> "/set direct #team off" - alice <## "updated group preferences:" - alice <## "Direct messages: off" - directProhibited bob - directProhibited cath - threadDelay 1000000 - -- still can send direct messages to direct contacts - alice #> "@bob hello again" - bob <# "alice> hello again" - alice #> "@cath hello again" - cath <# "alice> hello again" - bob ##> "@cath hello again" - bob <## "direct messages to indirect contact cath are prohibited" - (cath "/j #team" - concurrentlyN_ - [ cath <## "#team: dan joined the group", - do - dan <## "#team: you joined the group" - dan - <### [ "#team: member alice (Alice) is connected", - "#team: member bob (Bob) is connected" - ], - do - alice <## "#team: cath added dan (Daniel) to the group (connecting...)" - alice <## "#team: new member dan is connected", - do - bob <## "#team: cath added dan (Daniel) to the group (connecting...)" - bob <## "#team: new member dan is connected" - ] - alice ##> "@dan hi" - alice <## "direct messages to indirect contact dan are prohibited" - bob ##> "@dan hi" - bob <## "direct messages to indirect contact dan are prohibited" - (dan "@alice hi" - dan <## "direct messages to indirect contact alice are prohibited" - dan ##> "@bob hi" - dan <## "direct messages to indirect contact bob are prohibited" - dan #> "@cath hi" - cath <# "dan> hi" - cath #> "@dan hi" - dan <# "cath> hi" + testChatCfg4 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile danProfile $ + \alice bob cath dan -> do + createGroup3 "team" alice bob cath + threadDelay 1000000 + alice ##> "/set direct #team off" + alice <## "updated group preferences:" + alice <## "Direct messages: off" + directProhibited bob + directProhibited cath + threadDelay 1000000 + -- still can send direct messages to direct contacts + alice #> "@bob hello again" + bob <# "alice> hello again" + alice #> "@cath hello again" + cath <# "alice> hello again" + bob ##> "@cath hello again" + bob <## "direct messages to indirect contact cath are prohibited" + (cath "/j #team" + concurrentlyN_ + [ cath <## "#team: dan joined the group", + do + dan <## "#team: you joined the group" + dan + <### [ "#team: member alice (Alice) is connected", + "#team: member bob (Bob) is connected" + ], + do + alice <## "#team: cath added dan (Daniel) to the group (connecting...)" + alice <## "#team: new member dan is connected", + do + bob <## "#team: cath added dan (Daniel) to the group (connecting...)" + bob <## "#team: new member dan is connected" + ] + alice ##> "@dan hi" + alice <## "direct messages to indirect contact dan are prohibited" + bob ##> "@dan hi" + bob <## "direct messages to indirect contact dan are prohibited" + (dan "@alice hi" + dan <## "direct messages to indirect contact alice are prohibited" + dan ##> "@bob hi" + dan <## "direct messages to indirect contact bob are prohibited" + dan #> "@cath hi" + cath <# "dan> hi" + cath #> "@dan hi" + dan <# "cath> hi" where directProhibited :: HasCallStack => TestCC -> IO () directProhibited cc = do diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 4c7ca8d0a4..c120d661ff 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -18,11 +18,13 @@ import Data.Maybe (fromMaybe) import Data.String import qualified Data.Text as T import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), InlineFilesConfig (..), defaultInlineFilesConfig) +import Simplex.Chat.Protocol import Simplex.Chat.Store.Profiles (getUserContactProfiles) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Store.SQLite (withTransaction) import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Version import System.Directory (doesFileExist) import System.Environment (lookupEnv) import System.FilePath (()) @@ -65,9 +67,9 @@ versionTestMatrix2 runTest = do it "v1 to v2" $ runTestCfg2 testCfg testCfgV1 runTest it "v2 to v1" $ runTestCfg2 testCfgV1 testCfg runTest -versionTestMatrix3 :: (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> SpecWith FilePath -versionTestMatrix3 runTest = do - it "v2" $ testChat3 aliceProfile bobProfile cathProfile runTest +-- versionTestMatrix3 :: (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> SpecWith FilePath +-- versionTestMatrix3 runTest = do +-- it "v2" $ testChat3 aliceProfile bobProfile cathProfile runTest -- it "v1" $ testChatCfg3 testCfgV1 aliceProfile bobProfile cathProfile runTest -- it "v1 to v2" $ runTestCfg3 testCfg testCfgV1 testCfgV1 runTest @@ -356,7 +358,7 @@ dropTime_ msg = case splitAt 6 msg of _ -> Nothing dropStrPrefix :: HasCallStack => String -> String -> String -dropStrPrefix pfx s = +dropStrPrefix pfx s = let (p, rest) = splitAt (length pfx) s in if p == pfx then rest else error $ "no prefix " <> pfx <> " in string : " <> s @@ -523,3 +525,10 @@ startFileTransferWithDest' cc1 cc2 fileName fileSize fileDest_ = do concurrently_ (cc2 <## ("started receiving file 1 (" <> fileName <> ") from " <> name1)) (cc1 <## ("started sending file 1 (" <> fileName <> ") to " <> name2)) + +currentChatVRangeInfo :: String +currentChatVRangeInfo = + "peer chat protocol version range: " <> vRangeStr supportedChatVRange + +vRangeStr :: VersionRange -> String +vRangeStr (VersionRange minVer maxVer) = "(" <> show minVer <> ", " <> show maxVer <> ")" diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 31c0803547..07d0d2fe97 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -1,22 +1,65 @@ {-# LANGUAGE CPP #-} +{-# LANGUAGE ScopedTypeVariables #-} + +{-# OPTIONS_GHC -fno-warn-orphans #-} module MobileTests where import ChatTests.Utils import Control.Monad.Except +import Crypto.Random (getRandomBytes) +import Data.Aeson (FromJSON (..)) +import qualified Data.Aeson as J +import Data.ByteString (ByteString) +import qualified Data.ByteString as B +import qualified Data.ByteString.Char8 as BS +import Data.ByteString.Internal (create, memcpy) +import qualified Data.ByteString.Lazy.Char8 as LB +import Data.Word (Word8, Word32) +import Foreign.C +import Foreign.Marshal.Alloc (mallocBytes) +import Foreign.Ptr +import Foreign.Storable (peek) +import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEncoding) import Simplex.Chat.Mobile +import Simplex.Chat.Mobile.File +import Simplex.Chat.Mobile.Shared +import Simplex.Chat.Mobile.WebRTC import Simplex.Chat.Store 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 (..)) +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 :: SpecWith FilePath +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 + 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) @@ -113,3 +156,117 @@ testChatApi tmp = do chatRecvMsgWait cc 10000 `shouldReturn` "" chatParseMarkdown "hello" `shouldBe` "{}" chatParseMarkdown "*hello*" `shouldBe` parsedMarkdown + +testMediaApi :: HasCallStack => FilePath -> IO () +testMediaApi _ = do + key :: ByteString <- getRandomBytes 32 + frame <- getRandomBytes 100 + let keyStr = strEncode key + reserved = B.replicate (C.authTagSize + C.gcmIVSize) 0 + frame' = frame <> reserved + Right encrypted <- runExceptT $ chatEncryptMedia keyStr frame' + encrypted `shouldNotBe` frame' + B.length encrypted `shouldBe` B.length frame' + runExceptT (chatDecryptMedia keyStr encrypted) `shouldReturn` Right frame' + +testMediaCApi :: HasCallStack => FilePath -> IO () +testMediaCApi _ = do + key :: ByteString <- getRandomBytes 32 + frame <- getRandomBytes 100 + let keyStr = strEncode key + reserved = B.replicate (C.authTagSize + C.gcmIVSize) 0 + frame' = frame <> reserved + encrypted <- test cChatEncryptMedia keyStr frame' + encrypted `shouldNotBe` frame' + test cChatDecryptMedia keyStr encrypted `shouldReturn` frame' + where + test :: HasCallStack => (CString -> Ptr Word8 -> CInt -> IO CString) -> ByteString -> ByteString -> IO ByteString + test f keyStr frame = do + let len = B.length frame + cLen = fromIntegral len + ptr <- mallocBytes len + putByteString ptr frame + cKeyStr <- newCAString $ BS.unpack keyStr + (f cKeyStr ptr cLen >>= peekCAString) `shouldReturn` "" + getByteString ptr cLen + +instance FromJSON WriteFileResult where parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "WF" + +instance FromJSON ReadFileResult where parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RF" + +testFileCApi :: FilePath -> FilePath -> IO () +testFileCApi fileName tmp = do + src <- B.readFile "./tests/fixtures/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@(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 + -- the returned pointer contains 0, buffer length as Word32, then buffer + ptr' <- cChatReadFile cPath cKey cNonce + peek ptr' `shouldReturn` (0 :: Word8) + sz :: Word32 <- peek (ptr' `plusPtr` 1) + let sz' = fromIntegral sz + contents <- create sz' $ \toPtr -> memcpy toPtr (ptr' `plusPtr` 5) sz' + contents `shouldBe` src + sz' `shouldBe` fromIntegral len + +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 + ptr <- cChatReadFile cPath cKey cNonce + peek ptr `shouldReturn` 1 + err <- peekCAString (ptr `plusPtr` 1) + 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 + +encodedCString :: StrEncoding a => a -> IO CString +encodedCString = newCAString . BS.unpack . strEncode diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 6f7e0b8cf4..3acc78e7d8 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -76,10 +76,10 @@ s ##==## msg = do s ==## msg (==#) :: MsgEncodingI e => ByteString -> ChatMsgEvent e -> Expectation -s ==# msg = s ==## ChatMessage Nothing msg +s ==# msg = s ==## ChatMessage chatInitialVRange Nothing msg (#==) :: MsgEncodingI e => ByteString -> ChatMsgEvent e -> Expectation -s #== msg = s ##== ChatMessage Nothing msg +s #== msg = s ##== ChatMessage chatInitialVRange Nothing msg (#==#) :: MsgEncodingI e => ByteString -> ChatMsgEvent e -> Expectation s #==# msg = do @@ -101,59 +101,66 @@ testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", descri decodeChatMessageTest :: Spec decodeChatMessageTest = describe "Chat message encoding/decoding" $ do it "x.msg.new simple text" $ - "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" #==# XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing)) it "x.msg.new simple text - timed message TTL" $ - "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"ttl\":3600}}" + "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"ttl\":3600}}" #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") Nothing (Just 3600) Nothing)) it "x.msg.new simple text - live message" $ - "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"live\":true}}" + "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"live\":true}}" #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") Nothing Nothing (Just True))) it "x.msg.new simple link" $ - "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"https://simplex.chat\",\"type\":\"link\",\"preview\":{\"description\":\"SimpleX Chat\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA\",\"title\":\"SimpleX Chat\",\"uri\":\"https://simplex.chat\"}}}}" + "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"https://simplex.chat\",\"type\":\"link\",\"preview\":{\"description\":\"SimpleX Chat\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA\",\"title\":\"SimpleX Chat\",\"uri\":\"https://simplex.chat\"}}}}" #==# XMsgNew (MCSimple (extMsgContent (MCLink "https://simplex.chat" $ LinkPreview {uri = "https://simplex.chat", title = "SimpleX Chat", description = "SimpleX Chat", image = ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA", content = Nothing}) Nothing)) it "x.msg.new simple image" $ - "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" + "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" #==# XMsgNew (MCSimple (extMsgContent (MCImage "" $ ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=") Nothing)) it "x.msg.new simple image with text" $ - "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"here's an image\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" + "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"here's an image\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" #==# XMsgNew (MCSimple (extMsgContent (MCImage "here's an image" $ ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=") Nothing)) - it "x.msg.new chat message " $ - "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" - ##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) + it "x.msg.new chat message" $ + "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) + it "x.msg.new chat message with chat version range" $ + "{\"v\":\"1-2\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new quote" $ - "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" + "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" ##==## ChatMessage + chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCQuote quotedMsg (extMsgContent (MCText "hello to you too") Nothing))) it "x.msg.new quote - timed message TTL" $ - "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}},\"ttl\":3600}}" + "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}},\"ttl\":3600}}" ##==## ChatMessage + chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") Nothing (Just 3600) Nothing))) it "x.msg.new quote - live message" $ - "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}},\"live\":true}}" + "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}},\"live\":true}}" ##==## ChatMessage + chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") Nothing Nothing (Just True)))) it "x.msg.new forward" $ - "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true}}" - ##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (extMsgContent (MCText "hello") Nothing)) + "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true}}" + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (extMsgContent (MCText "hello") Nothing)) it "x.msg.new forward - timed message TTL" $ - "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"ttl\":3600}}" - ##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") Nothing (Just 3600) Nothing)) + "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"ttl\":3600}}" + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") Nothing (Just 3600) Nothing)) it "x.msg.new forward - live message" $ - "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"live\":true}}" - ##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") Nothing Nothing (Just True))) + "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"live\":true}}" + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") Nothing Nothing (Just True))) it "x.msg.new simple text with file" $ - "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" + "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" #==# XMsgNew (MCSimple (extMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}))) it "x.msg.new simple file with file" $ - "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"\",\"type\":\"file\"},\"file\":{\"fileSize\":12345,\"fileName\":\"file.txt\"}}}" + "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"\",\"type\":\"file\"},\"file\":{\"fileSize\":12345,\"fileName\":\"file.txt\"}}}" #==# XMsgNew (MCSimple (extMsgContent (MCFile "") (Just FileInvitation {fileName = "file.txt", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}))) it "x.msg.new quote with file" $ - "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" + "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" ##==## ChatMessage + chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") ( XMsgNew ( MCQuote @@ -165,101 +172,113 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do ) ) it "x.msg.new forward with file" $ - "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" - ##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (extMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}))) + "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (extMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}))) it "x.msg.update" $ - "{\"event\":\"x.msg.update\",\"params\":{\"msgId\":\"AQIDBA==\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1\",\"event\":\"x.msg.update\",\"params\":{\"msgId\":\"AQIDBA==\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" #==# XMsgUpdate (SharedMsgId "\1\2\3\4") (MCText "hello") Nothing Nothing it "x.msg.del" $ - "{\"event\":\"x.msg.del\",\"params\":{\"msgId\":\"AQIDBA==\"}}" + "{\"v\":\"1\",\"event\":\"x.msg.del\",\"params\":{\"msgId\":\"AQIDBA==\"}}" #==# XMsgDel (SharedMsgId "\1\2\3\4") Nothing it "x.msg.deleted" $ - "{\"event\":\"x.msg.deleted\",\"params\":{}}" + "{\"v\":\"1\",\"event\":\"x.msg.deleted\",\"params\":{}}" #==# XMsgDeleted it "x.file" $ - "{\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" + "{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Just testConnReq, fileInline = Nothing, fileDescr = Nothing} it "x.file without file invitation" $ - "{\"event\":\"x.file\",\"params\":{\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" + "{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing} it "x.file.acpt" $ - "{\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}" + "{\"v\":\"1\",\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}" #==# XFileAcpt "photo.jpg" it "x.file.acpt.inv" $ - "{\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" + "{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" #==# XFileAcptInv (SharedMsgId "\1\2\3\4") (Just testConnReq) "photo.jpg" it "x.file.acpt.inv" $ - "{\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\"}}" + "{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\"}}" #==# XFileAcptInv (SharedMsgId "\1\2\3\4") Nothing "photo.jpg" it "x.file.cancel" $ - "{\"event\":\"x.file.cancel\",\"params\":{\"msgId\":\"AQIDBA==\"}}" + "{\"v\":\"1\",\"event\":\"x.file.cancel\",\"params\":{\"msgId\":\"AQIDBA==\"}}" #==# XFileCancel (SharedMsgId "\1\2\3\4") it "x.info" $ - "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" + "{\"v\":\"1\",\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XInfo testProfile it "x.info with empty full name" $ - "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" + "{\"v\":\"1\",\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XInfo Profile {displayName = "alice", fullName = "", image = Nothing, contactLink = Nothing, preferences = testChatPreferences} it "x.contact with xContactId" $ - "{\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" + "{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XContact testProfile (Just $ XContactId "\1\2\3\4") it "x.contact without XContactId" $ - "{\"event\":\"x.contact\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" + "{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XContact testProfile Nothing it "x.contact with content null" $ - "{\"event\":\"x.contact\",\"params\":{\"content\":null,\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" + "{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"content\":null,\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" ==# XContact testProfile Nothing it "x.contact with content (ignored)" $ - "{\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" + "{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" ==# XContact testProfile Nothing it "x.grp.inv" $ - "{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Nothing} it "x.grp.inv with group link id" $ - "{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Just $ GroupLinkId "\1\2\3\4"} it "x.grp.acpt without incognito profile" $ - "{\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" + "{\"v\":\"1\",\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpAcpt (MemberId "\1\2\3\4") it "x.grp.mem.new" $ - "{\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" - #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, profile = testProfile} + "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} + it "x.grp.mem.new with member chat version range" $ + "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-2\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} it "x.grp.mem.intro" $ - "{\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" - #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, profile = testProfile} + "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} + it "x.grp.mem.intro with member chat version range" $ + "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-2\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} it "x.grp.mem.inv" $ - "{\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" - #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = testConnReq} + "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" + #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} + it "x.grp.mem.inv w/t directConnReq" $ + "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" + #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.fwd" $ - "{\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" - #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = testConnReq} + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} + it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-2\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ - "{\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XGrpMemInfo (MemberId "\1\2\3\4") testProfile it "x.grp.mem.con" $ - "{\"event\":\"x.grp.mem.con\",\"params\":{\"memberId\":\"AQIDBA==\"}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.con\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpMemCon (MemberId "\1\2\3\4") it "x.grp.mem.con.all" $ - "{\"event\":\"x.grp.mem.con.all\",\"params\":{\"memberId\":\"AQIDBA==\"}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.con.all\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpMemConAll (MemberId "\1\2\3\4") it "x.grp.mem.del" $ - "{\"event\":\"x.grp.mem.del\",\"params\":{\"memberId\":\"AQIDBA==\"}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.del\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpMemDel (MemberId "\1\2\3\4") it "x.grp.leave" $ - "{\"event\":\"x.grp.leave\",\"params\":{}}" + "{\"v\":\"1\",\"event\":\"x.grp.leave\",\"params\":{}}" ==# XGrpLeave it "x.grp.del" $ - "{\"event\":\"x.grp.del\",\"params\":{}}" + "{\"v\":\"1\",\"event\":\"x.grp.del\",\"params\":{}}" ==# XGrpDel it "x.info.probe" $ - "{\"event\":\"x.info.probe\",\"params\":{\"probe\":\"AQIDBA==\"}}" + "{\"v\":\"1\",\"event\":\"x.info.probe\",\"params\":{\"probe\":\"AQIDBA==\"}}" #==# XInfoProbe (Probe "\1\2\3\4") it "x.info.probe.check" $ - "{\"event\":\"x.info.probe.check\",\"params\":{\"probeHash\":\"AQIDBA==\"}}" + "{\"v\":\"1\",\"event\":\"x.info.probe.check\",\"params\":{\"probeHash\":\"AQIDBA==\"}}" #==# XInfoProbeCheck (ProbeHash "\1\2\3\4") it "x.info.probe.ok" $ - "{\"event\":\"x.info.probe.ok\",\"params\":{\"probe\":\"AQIDBA==\"}}" + "{\"v\":\"1\",\"event\":\"x.info.probe.ok\",\"params\":{\"probe\":\"AQIDBA==\"}}" #==# XInfoProbeOk (Probe "\1\2\3\4") it "x.ok" $ - "{\"event\":\"x.ok\",\"params\":{}}" + "{\"v\":\"1\",\"event\":\"x.ok\",\"params\":{}}" ==# XOk diff --git a/website/langs/ar.json b/website/langs/ar.json index afa1076a6f..bf44575f28 100644 --- a/website/langs/ar.json +++ b/website/langs/ar.json @@ -157,10 +157,10 @@ "comparison-section-list-point-7": "شبكات P2P إما لديها سلطة مركزية أو أن الشبكة كلها يمكن عرضة للخطر", "see-here": "اقرأ هنا", "no-secure": "لا - آمن", - "comparison-section-list-point-5": "لا يحمي المعلومات الوصفية للمستخدمين", + "comparison-section-list-point-5": "لا يحمي خصوصية البيانات الوصفية للمستخدمين", "comparison-section-list-point-6": "على الرغم من أن الـP2P موزعة، إلا أنها ليست فدرالية - يعملون كشبكة واحدة", "comparison-section-list-point-1": "عادة ما يكون مكوناً من رقم الهاتف، أو اسم المستخدم في بعض الأحيان", - "comparison-section-list-point-4": "إذا خوادم المشغّل مُخترقة", + "comparison-section-list-point-4": "إذا خوادم المشغّل مُخترقة. تحقق من رمز الأمان في Signal وبعض التطبيقات الأخرى للتخفيف منه", "simplex-unique-card-3-p-1": "يخزن SimpleX جميع بيانات المستخدم على الأجهزة العميلة بتنسيق قاعدة بيانات محمولة مشفرة — يمكن نقله إلى جهاز آخر.", "simplex-unique-card-4-p-1": "شبكة SimpleX لا مركزية بالكامل ومستقلة عن أي عملة مشفرة أو أي منصة أخرى، بخلاف الإنترنت.", "simplex-unique-card-4-p-2": "يمكنك استخدام SimpleX مع خوادمك الخاصة أو مع الخوادم التي نوفرها — ولا يزال الاتصال ممكن بأي مستخدم.", @@ -240,5 +240,8 @@ "signing-key-fingerprint": "توقيع مفتاح البصمة (SHA-256)", "f-droid-org-repo": "مستودع F-Droid.org", "stable-versions-built-by-f-droid-org": "الإصدارات الثابتة التي تم إنشاؤها بواسطة F-Droid.org", - "releases-to-this-repo-are-done-1-2-days-later": "يتم إصدار الإصدارات إلى هذا المستودع بعد يوم أو يومين" -} \ No newline at end of file + "releases-to-this-repo-are-done-1-2-days-later": "يتم إصدار الإصدارات إلى هذا المستودع بعد يوم أو يومين", + "f-droid-page-simplex-chat-repo-section-text": "لإضافته إلى عميل F-Droid، امسح رمز QR أو استخدم عنوان URL هذا:", + "f-droid-page-f-droid-org-repo-section-text": "مستودعات SimpleX Chat و F-Droid.org مبنية على مفاتيح مختلفة. للتبديل، يرجى تصدير قاعدة بيانات الدردشة وإعادة تثبيت التطبيق.", + "comparison-section-list-point-4a": "مُرحلات SimpleX لا يمكنها أن تتنازل عن تشفير بين الطرفين. تحقق من رمز الأمان للتخفيف من الهجوم على القناة خارج النطاق" +} diff --git a/website/langs/de.json b/website/langs/de.json index ced2abffd3..6cb3f1d3f6 100644 --- a/website/langs/de.json +++ b/website/langs/de.json @@ -170,7 +170,7 @@ "no-federated": "Nein - föderiert", "comparison-section-list-point-2": "DNS-basierte Adressen", "comparison-section-list-point-3": "Öffentlicher Schlüssel oder eine andere weltweit eindeutige ID", - "comparison-section-list-point-4": "Wenn die Server des Betreibers kompromittiert werden", + "comparison-section-list-point-4": "Wenn die Server des Betreibers kompromittiert werden. In Signal und weiteren Apps kann der Securitycode überprüft werden, um dies zu entschärfen", "comparison-section-list-point-6": "P2P sind zwar verteilt, aber nicht föderiert - sie arbeiten als ein einziges Netzwerk", "comparison-section-list-point-7": "P2P-Netzwerke haben entweder eine zentrale Verwaltung oder das gesamte Netzwerk kann kompromittiert werden", "see-here": "Siehe hier", @@ -194,7 +194,7 @@ "comparison-section-list-point-1": "Normalerweise auf der Grundlage einer Telefonnummer, in einigen Fällen auf der Grundlage von Benutzernamen", "comparison-point-5-text": "Zentrale Komponente oder andere Netzwerk-weite Angriffe", "no-decentralized": "Nein - dezentralisiert", - "comparison-section-list-point-5": "Metadaten des Nutzers werden nicht geschützt", + "comparison-section-list-point-5": "Die Privatsphäre-Metadaten des Nutzers werden nicht geschützt", "simplex-network-overlay-card-1-li-1": "P2P-Netzwerke vertrauen auf Varianten von DHT, um Nachrichten zu routen. DHT-Designs müssen zwischen Zustellungsgarantie und Latenz ausgleichen. Verglichen mit P2P bietet SimpleX sowohl eine bessere Zustellungsgarantie, als auch eine niedrigere Latenz, weil eine Nachricht redundant und parallel über mehrere Server gesendet werden kann, wobei die durch den Empfänger ausgewählten Server genutzt werden. In P2P-Netzwerken werden Nachrichten sequentiell über O(log N) Knoten gesendet, wobei die Knoten durch einen Algorithmus ausgewählt werden.", "simplex-unique-overlay-card-3-p-4": "Zwischen dem gesendeten und empfangenen Serververkehr gibt es keine gemeinsamen Kennungen oder Chiffriertexte — sodass ein Beobachter nicht ohne weiteres feststellen kann, wer mit wem kommuniziert, selbst wenn TLS kompromittiert wurde.", "simplex-unique-overlay-card-4-p-3": "Wenn Sie darüber nachdenken, für die SimpleX-Plattform entwickeln zu wollen, z.B. einen Chatbot für SimpleX-App-Nutzer oder die Integration der SimpleX-Chat-Bibliothek in Ihre mobilen Apps, kontaktieren Sie uns bitte für eine weitere Beratung und Unterstützung.", @@ -242,5 +242,6 @@ "simplex-chat-via-f-droid": "SimpleX Chat per F-Droid", "simplex-chat-repo": "SimpleX Chat Repository", "stable-and-beta-versions-built-by-developers": "Von den Entwicklern erstellte stabile und Beta-Versionen", - "f-droid-page-simplex-chat-repo-section-text": "Um es Ihrem F-Droid-Client hinzuzufügen scannen Sie den QR-Code oder nutzen Sie diese URL:" -} \ No newline at end of file + "f-droid-page-simplex-chat-repo-section-text": "Um es Ihrem F-Droid-Client hinzuzufügen scannen Sie den QR-Code oder nutzen Sie diese URL:", + "comparison-section-list-point-4a": "SimpleX-Relais können die E2E-Verschlüsselung nicht kompromittieren. Überprüfen Sie den Sicherheitscode, um einen möglichen Angriff auf den Out-of-Band-Kanal zu entschärfen" +} diff --git a/website/langs/es.json b/website/langs/es.json index 4505d4b987..3f317ae4e6 100644 --- a/website/langs/es.json +++ b/website/langs/es.json @@ -179,8 +179,8 @@ "no-federated": "No - federado", "comparison-section-list-point-1": "Generalmente basado en un número de teléfono, en algunos casos en nombres de usuario", "comparison-section-list-point-2": "Direcciones basadas en DNS", - "comparison-section-list-point-4": "Si los servidores del operador se ven comprometidos", - "comparison-section-list-point-5": "No protege los metadatos del usuario", + "comparison-section-list-point-4": "Si los servidores del operador se ven comprometidos. Verifique el código de seguridad en Signal y alguna otra aplicación para mitigarlo", + "comparison-section-list-point-5": "No protege la privacidad de los metadatos del usuario", "comparison-section-list-point-3": "Clave pública o algun otro ID único a nivel global", "comparison-section-list-point-6": "A pesar de que las redes P2P son distribuidas, no son federadas - funcionan como una única red", "comparison-section-list-point-7": "Las redes P2P o bien tienen una autoridad central o toda la red puede verse comprometida", @@ -242,5 +242,6 @@ "stable-versions-built-by-f-droid-org": "Versión estable compilada por F-Droid.org", "f-droid-page-f-droid-org-repo-section-text": "Los repositorios de SimpleX Chat y F-Droid.org firman con distinto certificado. Para cambiar, por favor exportar la base de datos y reinstala la aplicación.", "signing-key-fingerprint": "Huella digital de la clave de firma (SHA-256)", - "releases-to-this-repo-are-done-1-2-days-later": "Las versiones aparecen 1-2 días más tarde en este repositorio" -} \ No newline at end of file + "releases-to-this-repo-are-done-1-2-days-later": "Las versiones aparecen 1-2 días más tarde en este repositorio", + "comparison-section-list-point-4a": "Los servidores de retransmisión no pueden comprometer la encriptación e2e. Para evitar posibles ataques, verifique el código de seguridad mediante un canal alternativo" +} diff --git a/website/langs/fi.json b/website/langs/fi.json new file mode 100644 index 0000000000..eaa774aa9d --- /dev/null +++ b/website/langs/fi.json @@ -0,0 +1,247 @@ +{ + "home": "Koti", + "developers": "Kehittäjät", + "reference": "Lisätietoja", + "blog": "Blogi", + "features": "Ominaisuudet", + "why-simplex": "Miksi SimpleX", + "simplex-privacy": "SimpleX yksityisyys", + "simplex-network": "SimpleX verkko", + "simplex-explained": "SimpleX selitetynä", + "simplex-explained-tab-1-text": "1. Mitä käyttäjät kokevat", + "simplex-explained-tab-2-text": "2. Miten se toimii", + "simplex-explained-tab-3-text": "3. Mitä palvelimet näkevät", + "simplex-explained-tab-1-p-2": "Kuinka se voi toimia yksisuuntaisten jonotusten kanssa ilman käyttäjäprofiilin tunnisteita?", + "simplex-explained-tab-2-p-1": "Jokaista yhteyttä varten käytetään kahta erillistä viestintäjonon mahdollistavaa palvelinta viestien lähettämiseen ja vastaanottamiseen.", + "simplex-explained-tab-2-p-2": "Palvelimet välittävät viestejä vain yhteen suuntaan eivätkä saa kokonaiskuvaa käyttäjän keskustelusta tai yhteyksistä.", + "simplex-explained-tab-3-p-2": "Käyttäjät voivat lisäksi parantaa metadata-yksityisyyttään käyttämällä Tor-verkkoa palvelimiin yhdistämiseen, mikä estää IP-osoitteen perusteella tapahtuvan yhteyksien tekemisen.", + "chat-bot-example": "Keskustelu­botti-esimerkki", + "smp-protocol": "SMP-protokolla", + "chat-protocol": "Keskustelu­protokolla", + "simplex-chat-protocol": "SimpleX Keskustelu­protokolla", + "terminal-cli": "Pääte CLI", + "terms-and-privacy-policy": "Käyttöehdot ja yksityisyys­käytäntö", + "hero-header": "Yksityisyys uudelleen määritelty", + "hero-subheader": "Ensimmäinen viestisovellus
ilman käyttäjätunnuksia", + "hero-overlay-1-textlink": "Miksi käyttäjätunnukset ovat huonoja yksityisyydelle?", + "hero-overlay-1-title": "Kuinka SimpleX toimii?", + "hero-overlay-2-title": "Miksi käyttäjätunnukset ovat huonoja yksityisyydelle?", + "feature-1-title": "Päästä päähän salattuja viestejä markdownin ja muokkaamisen kera", + "feature-2-title": "Päästä päähän salattuja
kuvia ja tiedostoja", + "feature-3-title": "Hajautetut salaiset ryhmät —
vain käyttäjät tietävät niiden olemassaolosta", + "feature-4-title": "Päästä päähän salattuja ääniviestejä", + "feature-5-title": "Katoavia viestejä", + "feature-8-title": "Incognito-tila —
ainutlaatuinen SimpleX Chatille", + "simplex-network-overlay-1-title": "Verrattuna P2P-viestintäprotokolliin", + "simplex-private-7-title": "Viestin eheys
vahvistus", + "simplex-private-9-title": "Yksisuuntaisia
viestijonoja", + "simplex-private-10-title": "Väliaikaiset nimettömät parittaiset tunnisteet", + "simplex-private-card-3-point-1": "Vain TLS 1.2/1.3 vahvoilla algoritmeilla käytetään asiakas-palvelin-yhteyksiin.", + "simplex-private-card-4-point-2": "Käyttääksesi SimpleX:ää Torin kautta, asenna Orbot-sovellus ja ota käyttöön SOCKS5-välityspalvelin (tai VPN iOS:lla).", + "simplex-private-card-7-point-1": "Viestien eheyden varmistamiseksi ne numeroituvat peräkkäin ja sisältävät edellisen viestin tiivisteen.", + "simplex-private-card-7-point-2": "Jos viestiä lisätään, poistetaan tai muutetaan, vastaanottaja saa ilmoituksen.", + "simplex-private-card-8-point-1": "SimpleX-palvelimet toimivat matalan viiveen sekoitussolmuina — saapuvilla ja lähtevillä viesteillä on erilainen järjestys.", + "simplex-private-card-10-point-1": "SimpleX käyttää väliaikaisia nimettömiä parittaisia osoitteita ja tunnistetietoja jokaiselle käyttäjäkontaktille tai ryhmän jäsenelle.", + "privacy-matters-2-title": "Vaalien manipulointi", + "privacy-matters-2-overlay-1-title": "Yksityisyys antaa sinulle valtaa", + "privacy-matters-2-overlay-1-linkText": "Yksityisyys antaa sinulle valtaa", + "privacy-matters-3-title": "Syyte viattomasta yhteydestä", + "privacy-matters-3-overlay-1-title": "Yksityisyys suojaa vapauttasi", + "simplex-unique-3-title": "Sinä hallitset tietojasi", + "hero-overlay-card-1-p-4": "Tämä ratkaisu estää kaikkien käyttäjien metadatan vuotamisen sovellustason tasolla. Lisätäksesi yksityisyyttä ja suojataksesi IP-osoitteesi, voit yhdistää viestintäpalvelimiin Tor-verkon kautta.", + "hero-overlay-card-1-p-5": "Vain asiakaslaitteet tallentavat käyttäjäprofiilit, yhteystiedot ja ryhmät; viestit lähetetään 2-kerroksisella päästä päähän salauksella.", + "hero-overlay-card-2-p-1": "Kun käyttäjillä on pysyvät tunnisteet, vaikka ne olisivat vain satunnaisia numeroita, kuten istunnon tunniste, on riski, että palveluntarjoaja tai hyökkääjä voi havaita miten käyttäjät ovat yhteydessä toisiinsa ja kuinka monta viestiä he lähettävät.", + "simplex-network-overlay-card-1-p-1": "P2P viestintäprotokollilla ja sovelluksilla on erilaisia ongelmia, jotka tekevät niistä vähemmän luotettavia kuin SimpleX, monimutkaisempia analysoida ja alttiita useille hyökkäystyypeille.", + "privacy-matters-overlay-card-1-p-1": "Monet suuret yritykset käyttävät tietoa siitä, keiden kanssa olet yhteydessä, arvioidakseen tulojasi, myydäkseen sinulle tarpeettomia tuotteita ja määrittääkseen hinnat.", + "privacy-matters-overlay-card-1-p-2": "Verkkokauppiaat tietävät, että alhaisempiin tuloluokkiin kuuluvat ihmiset todennäköisemmin tekevät kiireellisiä ostoksia, joten he voivat veloittaa korkeampia hintoja tai poistaa alennukset.", + "simplex-unique-overlay-card-3-p-1": "SimpleX Chat tallentaa kaikki käyttäjätiedot vain asiakaslaitteille käyttäen siirrettävää salattua tietokantamuotoa, joka voidaan viedä ja siirtää mihin tahansa tuettuun laitteeseen.", + "simplex-unique-overlay-card-3-p-2": "Päästä päähän salatut viestit säilytetään väliaikaisesti SimpleX-releay-palvelimilla, kunnes ne vastaanotetaan, minkä jälkeen ne poistetaan pysyvästi.", + "simplex-unique-card-1-p-1": "SimpleX suojaa profiilisi, yhteystietosi ja metatietosi yksityisyyden, piilottaen ne SimpleX-alustan palvelimilta ja kaikilta havainnoijilta.", + "simplex-unique-card-1-p-2": "Toisin kuin millään muulla olemassa olevalla viestintäalustalla, SimpleX:llä ei ole tunnisteita käyttäjille — ei edes satunnaisia numeroita.", + "simplex-unique-card-4-p-1": "SimpleX-verkko on täysin hajautettu ja riippumaton mistään kryptovaluutasta tai mistään muusta alustasta paitsi Internetistä.", + "simplex-unique-card-4-p-2": "Voit käyttää SimpleX:ää omien palvelimiesi kanssa tai meidän tarjoamillamme palvelimilla — ja silti yhdistyä mihin tahansa käyttäjään.", + "join": "Liity", + "hide-info": "Piilota tiedot", + "contact-hero-header": "Sait osoitteen yhdistämistä varten SimpleX Chatissa", + "invitation-hero-header": "Sait kertalinkini yhdistämistä varten SimpleX Chatissa", + "contact-hero-p-2": "Et ole vielä ladannut SimpleX Chat -sovellusta?", + "privacy-matters-section-subheader": "Metatietojesi yksityisyyden säilyttäminen — keneen puhut — suojaa sinua seuraavilta:", + "privacy-matters-section-label": "Varmista, ettei viestintäsovelluksesi pääse käsiksi tietoihisi!", + "simplex-private-section-header": "Mikä tekee SimpleX:stä yksityisen", + "simplex-network-1-header": "Toisin kuin P2P-verkot", + "simplex-network-1-overlay-linktext": "P2P-verkkojen ongelmia", + "protocol-1-text": "Signal, suuret alustat", + "protocol-2-text": "XMPP, Matrix", + "protocol-3-text": "P2P-protokollat", + "comparison-point-1-text": "Vaati globaalin identiteetin", + "comparison-point-2-text": "Mahdollisuus MITM-hyökkäykseen", + "comparison-point-3-text": "Riippuvuus DNS:stä", + "comparison-point-4-text": "Yksittäinen tai keskitetty verkko", + "yes": "Kyllä", + "no": "Ei", + "no-private": "Ei - yksityinen", + "no-secure": "Ei - turvallinen", + "no-resilient": "Ei - joustava", + "comparison-section-list-point-7": "P2P-verkoilla on joko keskitetty auktoriteetti tai koko verkko voidaan vaarantaa", + "see-here": "katso täältä", + "guide-dropdown-1": "Nopea aloitus", + "guide-dropdown-2": "Viestien lähettäminen", + "guide-dropdown-3": "Salaiset ryhmät", + "guide-dropdown-4": "Keskusteluprofiilit", + "guide-dropdown-7": "Yksityisyys ja turvallisuus", + "guide-dropdown-8": "Sovellusasetukset", + "guide-dropdown-9": "Yhteyksien luominen", + "docs-dropdown-5": "XFTP-palvelimen isännöiminen", + "docs-dropdown-6": "WebRTC-palvelimet", + "docs-dropdown-7": "Käännä SimpleX Chat", + "docs-dropdown-8": "SimpleX Hakupalvelu", + "on-this-page": "Tällä sivulla", + "back-to-top": "Takaisin ylös", + "glossary": "Sanasto", + "simplex-chat-repo": "SimpleX Chat -varasto", + "signing-key-fingerprint": "Allekirjoitusavaimen sormenjälki (SHA-256)", + "f-droid-org-repo": "F-Droid.org -varasto", + "stable-versions-built-by-f-droid-org": "Vakioversiot luotu F-Droid.org -varastoon", + "releases-to-this-repo-are-done-1-2-days-later": "Julkaisut tälle varastolle tehdään 1-2 päivää myöhemmin", + "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat ja F-Droid.org -varastot allekirjoittavat buildit eri avaimilla. Vaihtaaksesi, vienti keskustelutietokanta ja asenna sovellus uudelleen.", + "hero-overlay-2-textlink": "Kuinka SimpleX toimii?", + "hero-2-header": "Luo yksityinen yhteys", + "hero-2-header-desc": "Video näyttää, kuinka muodostat yhteyden ystävääsi heidän kertakäyttöiseen QR-koodiinsa, henkilökohtaisesti tai videolinkin kautta. Voit myös liittyä jakamalla kutsulinkin kautta.", + "feature-6-title": "Päästä päähän salattuja
puheluita ja videopuheluja", + "feature-7-title": "Siirrettävä salattu tietokanta — siirrä profiilisi toiselle laitteelle", + "simplex-explained-tab-1-p-1": "Voit luoda yhteyshenkilöitä ja ryhmiä sekä käydä kaksisuuntaisia keskusteluja kuten missä tahansa muussa viestisovelluksessa.", + "simplex-explained-tab-3-p-1": "Palvelimilla on erilliset anonyymit tunnistetiedot kullekin jonolle, eivätkä ne tiedä, mille käyttäjille ne kuuluvat.", + "donate": "Lahjoita", + "copyright-label": "© 2020-2023 SimpleX | Avoin projekti", + "hero-p-1": "Muissa sovelluksissa on käyttäjätunnuksia: Signal, Matrix, Session, Briar, Jami, Cwtch, jne.
SimpleX ei käytä niitä, ei edes satunnaisia numeroita.
Tämä parantaa yksityisyyttäsi radikaalisti.", + "simplex-private-1-title": "2 kerrosta päästä päähän salattua viestintää", + "simplex-private-2-title": "Lisäkerros palvelimen salaukselle", + "simplex-private-3-title": "Turvallinen tunnistettu
TLS-tiedonsiirto", + "simplex-private-4-title": "Vaihtoehtoinen pääsy Torin kautta", + "simplex-private-5-title": "Useita tasoja
sisältöpakkauksia", + "simplex-private-6-title": "Avainvaihto kanavan ulkopuolella
(out-of-band)", + "simplex-private-8-title": "Viestien sekoitus
korrelaation vähentämiseksi", + "simplex-private-card-1-point-1": "Kaksoisruuviprotokolla —
OTR-viestintä täydellisellä eteenpäinsalauksella ja murron palautuksella.", + "simplex-private-card-1-point-2": "NaCL-kryptolaatikko kussakin jonossa estämään liikenteen korrelaatiota viestijonojen välillä, jos TLS vaarantuu.", + "simplex-private-card-2-point-1": "Lisäkerros palvelimen salaukselle vastaanottajalle toimittaessa, jotta lähetetyn ja vastaanotetun palvelinliikenteen korrelaatio estyy, jos TLS vaarantuu.", + "simplex-private-card-3-point-2": "Palvelimen sormenjälki ja kanavan sitominen estävät välikäden hyökkäykset ja toistohyökkäykset.", + "simplex-private-card-3-point-3": "Yhteyden jatkaminen on estetty istuntohyökkäysten estämiseksi.", + "simplex-private-card-4-point-1": "Suojataksesi IP-osoitettasi, voit käyttää palvelimia Tor-verkon tai jonkin muun kuljetuskerroksen päällä.", + "simplex-private-card-5-point-1": "SimpleX käyttää sisältöpakkauksia jokaiselle salauskerrokselle estämään viestikoon hyökkäyksiä.", + "simplex-private-card-5-point-2": "Se saa erikokoiset viestit näyttämään samalta palvelimille ja verkon tarkkailijoille.", + "simplex-private-card-6-point-1": "Monet viestintäalustat ovat alttiita välikäden hyökkäyksille palvelimilta tai verkko-operaattoreilta.", + "simplex-private-card-6-point-2": "Estääkseen sen, SimpleX-sovellukset siirtävät yksittäiset avaimet kanavan ulkopuolella, kun jaat osoitteen linkkinä tai QR-koodina.", + "simplex-private-card-9-point-1": "Jokainen viestijono siirtää viestejä yhteen suuntaan, eri lähettävillä ja vastaanottavilla osoitteilla.", + "simplex-private-card-9-point-2": "Se vähentää hyökkäysvektoreita verrattuna perinteisiin viestivälittimiin ja saatavilla oleviin metatietoihin.", + "simplex-private-card-10-point-2": "Se mahdollistaa viestien toimittamisen ilman käyttäjäprofiilitunnisteita, tarjoten paremman metatietosuojan kuin vaihtoehdot.", + "privacy-matters-1-title": "Mainonnan ja hintasyrjinnän estäminen", + "privacy-matters-1-overlay-1-title": "Yksityisyys säästää rahaa", + "privacy-matters-1-overlay-1-linkText": "Yksityisyys säästää rahaa", + "privacy-matters-3-overlay-1-linkText": "Yksityisyys suojaa vapauttasi", + "simplex-unique-1-title": "Sinulla on täydellinen yksityisyys", + "simplex-unique-1-overlay-1-title": "Täysi yksityisyys identiteetistäsi, profiilistasi, kontakteistasi ja metatiedoista", + "simplex-unique-2-title": "Olet suojattu roskapostilta ja väärinkäytöksiltä", + "simplex-unique-2-overlay-1-title": "Paras suoja roskapostilta ja väärinkäytöksiltä", + "simplex-unique-3-overlay-1-title": "Omistusoikeus, hallinta ja tietojesi turvallisuus", + "simplex-unique-4-title": "Omistat SimpleX-verkon", + "simplex-unique-4-overlay-1-title": "Täysin hajautettu — käyttäjät omistavat SimpleX-verkon", + "hero-overlay-card-1-p-1": "Monet käyttäjät ovat kysyneet: jos SimpleX ei käytä käyttäjätunnisteita, miten se tietää minne viestit toimitetaan?", + "hero-overlay-card-1-p-3": "Määrität, minkä palvelimen(t) valitset viestien vastaanottamiseen, sekä kontaktisi — palvelimet, joita käytät viestien lähettämiseen heille. Jokainen keskustelu käyttää todennäköisesti kahta eri palvelinta.", + "hero-overlay-card-1-p-6": "Lue lisää SimpleX whitepaperista.", + "hero-overlay-card-1-p-2": "Viestien toimittamiseen SimpleX ei käytä muiden alustojen käyttäjätunnuksia, vaan sen sijaan se käyttää väliaikaisia nimettömiä parittaisia tunnisteita viestijonoille, jotka ovat erillisiä jokaiselle yhteydelle — pitkäaikaisia tunnisteita ei ole.", + "hero-overlay-card-2-p-3": "Jopa yksityisimmillä sovelluksilla, jotka käyttävät Tor v3 -palveluja, jos puhut kahdelle eri yhteyshenkilölle saman profiilin kautta, he voivat todistaa olevansa yhteydessä samaan henkilöön.", + "hero-overlay-card-2-p-4": "SimpleX suojaa näitä hyökkäyksiä vastaan, sillä siinä ei ole käyttäjätunnuksia toteutuksessaan. Ja jos käytät Incognito-tilaa, sinulla on eri näyttönimi jokaiselle yhteyshenkilölle, mikä estää yhteisten tietojen jakamisen heidän välillään.", + "hero-overlay-card-2-p-2": "Tämän tiedon avulla he voisivat yhdistää sen olemassa oleviin julkisiin sosiaalisiin verkostoihin ja määrittää joitakin todellisia identiteettejä.", + "simplex-network-overlay-card-1-li-1": "P2P-verkot luottavat jonkinlaiseen DHT-varianttiin viestien reitittämiseksi. DHT-suunnittelun on tasapainotettava toimituksen varmuutta ja latenssia. SimpleX:llä on parempi toimitusvarmuus ja pienempi latenssi kuin P2P-verkoilla, koska viesti voidaan toimittaa redundanssina useiden palvelimien kautta samanaikaisesti, käyttäen vastaanottajan valitsemia palvelimia. P2P-verkoissa viesti kulkee läpi O(log N) solmun sekvenssissä, käyttäen algoritmin valitsemia solmuja.", + "simplex-network-overlay-card-1-li-2": "SimpleX-toteutus, toisin kuin useimmat P2P-verkot, ei käytä globaaleja käyttäjätunnisteita millään tavalla, ei edes tilapäisiä, ja käyttää ainoastaan tilapäisiä parillisia tunnisteita, tarjoten paremman anonymiteetin ja metadatansuojan.", + "simplex-network-overlay-card-1-li-6": "P2P-verkot saattavat olla alttiita DRDoS-hyökkäyksille, kun asiakkaat voivat lähettää uudelleen ja voimistaa liikennettä, mikä johtaa verkko-laajuiseen palvelunestohyökkäykseen. SimpleX-asiakkaat välittävät liikennettä vain tunnetuilta yhteyksiltä eivätkä voi olla käytettävissä hyökkääjänä liikenteen voimistamiseen koko verkossa.", + "simplex-network-overlay-card-1-li-3": "P2P ei ratkaise välikäden hyökkäys -ongelmaa, ja useimmat olemassa olevat toteutukset eivät käytä kanavan ulkopuolisia viestejä alkuperäiseen avaimenvaihtoon. SimpleX käyttää kanavan ulkopuolisia viestejä tai, joissakin tapauksissa, jo valmiiksi turvallisia ja luotettuja yhteyksiä alkuperäiseen avaimenvaihtoon.", + "simplex-network-overlay-card-1-li-4": "P2P-toteutukset voivat estyä joidenkin Internet-palveluntarjoajien (kuten BitTorrent) toimesta. SimpleX on kuljetusprotokollasta riippumaton - se voi toimia yleisten verkkoprotokollien kautta, kuten esimerkiksi WebSockets.", + "simplex-network-overlay-card-1-li-5": "Kaikki tunnetut P2P-verkot saattavat olla alttiita Sybil-hyökkäykselle, koska jokainen solmu on löydettävissä, ja verkko toimii kokonaisuutena. Tunnetut keinot sen lieventämiseksi vaativat joko keskitetyn komponentin tai kalliin työn todistuksen. SimpleX-verkolla ei ole palvelimen löydettävyyttä, se on fragmentoitunut ja toimii useina eristettyinä aliverkkoina, mikä tekee verkko-laajuisista hyökkäyksistä mahdottomia.", + "privacy-matters-overlay-card-1-p-3": "Jotkut rahoitus- ja vakuutusyhtiöt käyttävät sosiaalisia verkostoja määrittääkseen korkoja ja vakuutusmaksuja. Se tekee usein alhaisempiin tuloihin kuuluvien ihmisten maksavan enemmän — sitä kutsutaan 'köyhyyslisäksi'.", + "privacy-matters-overlay-card-1-p-4": "SimpleX-alusta suojaa yhteyksiesi yksityisyyttä paremmin kuin mikään vaihtoehto, estäen täysin yhteysverkkosi tulemisen saataville mille tahansa yrityksille tai organisaatioille. Vaikka ihmiset käyttävät SimpleX Chatin tarjoamia palvelimia, emme tiedä käyttäjien määrää tai heidän yhteyksiään.", + "privacy-matters-overlay-card-2-p-3": "SimpleX on ensimmäinen alusta, jolla ei ole mitään käyttäjätunnisteita suunnittelussaan, suojaten siten yhteyksesi verkkoa paremmin kuin mikään tunnettu vaihtoehto.", + "privacy-matters-overlay-card-2-p-1": "Ei niin kauan sitten huomasimme merkittävien vaalien olevan manipuloitavissa kunnioitetun konsulttiyrityksen toimesta, joka käytti sosiaalisia verkostoja vääristämään käsitystämme todellisesta maailmasta ja manipuloimaan ääniämme.", + "privacy-matters-overlay-card-3-p-1": "Kaikkien pitäisi välittää viestinnän yksityisyydestä ja turvallisuudesta — harmittomat keskustelut voivat asettaa sinut vaaraan, vaikka sinulla ei olisi mitään piilotettavaa.", + "privacy-matters-overlay-card-2-p-2": "Ollaksesi objektiivinen ja tehdäksesi itsenäisiä päätöksiä, sinun on hallittava tietotilaasi. Se on mahdollista vain, jos käytät yksityistä viestintäalustaa, jolla ei ole pääsyä sosiaaliseen verkostoosi.", + "privacy-matters-overlay-card-3-p-2": "Yksi järkyttävimmistä tarinoista on Mohamedou Ould Salahi'n kokemus, joka on kuvattu hänen muistelmissaan ja esitetty The Mauritanian -elokuvassa. Hänet laitettiin Guantanamo-leirille ilman oikeudenkäyntiä ja häntä kidutettiin siellä 15 vuotta puhelun jälkeen sukulaiselleen Afganistanissa, epäiltynä osallisuudesta 9/11-iskuihin, vaikka hän oli asunut Saksassa edelliset 10 vuotta.", + "privacy-matters-overlay-card-3-p-3": "Tavalliset ihmiset pidätetään siitä, mitä he jakavat verkossa, jopa 'anonyymien' tiliensä kautta, jopa demokraattisissa maissa.", + "privacy-matters-overlay-card-3-p-4": "Ei riitä, että käytät päästä päähän salattua viestintäsovellusta, meidän kaikkien pitäisi käyttää viestintäsovelluksia, jotka suojelevat henkilökohtaisten verkostojemme yksityisyyttä — keiden kanssa olemme yhteydessä.", + "simplex-unique-overlay-card-1-p-1": "Toisin kuin muut viestintäalustat, SimpleX:llä ei ole mitään tunnisteita käyttäjille. Se ei luota puhelinnumeroihin, verkkotunnuksiin perustuviin osoitteisiin (kuten sähköposti tai XMPP), käyttäjänimiin, julkisiin avaimiin tai edes satunnaisiin numeroihin tunnistaakseen käyttäjänsä — emme tiedä kuinka monta ihmistä käyttää SimpleX-palvelimiamme.", + "simplex-unique-overlay-card-1-p-2": "Viestien toimittamiseksi SimpleX käyttää parittaisia nimettömiä osoitteita kaksisuuntaisille viestijonoille, jotka ovat erilliset vastaanotetuille ja lähetetyille viesteille, yleensä eri palvelimien kautta. SimpleX:n käyttö on kuin eri “kertakäyttöinen” sähköposti tai puhelin jokaiselle yhteydelle, eikä sinun tarvitse vaivautua niiden hallitsemiseen.", + "simplex-unique-overlay-card-1-p-3": "Tämä suunnittelu suojaa sitä, kenen kanssa kommunikoit, piilottamalla sen SimpleX-alustan palvelimilta ja kaikilta havainnoijilta. Piilottaaksesi IP-osoitteesi palvelimilta, voit yhdistää SimpleX-palvelimiin Tor-verkon kautta.", + "simplex-unique-overlay-card-2-p-1": "Koska sinulla ei ole tunnistetta SimpleX-alustalla, kukaan ei voi ottaa sinuun yhteyttä, ellei jaa kertakäyttöistä tai väliaikaista käyttäjäosoitetta, kuten QR-koodia tai linkkiä.", + "simplex-unique-overlay-card-2-p-2": "Jopa valinnaisen käyttäjäosoitteen kanssa, vaikka sitä voitaisiin käyttää roskapostiyhteyspyyntöjen lähettämiseen, voit vaihtaa sen tai poistaa sen kokonaan menettämättä mitään yhteyksiäsi.", + "simplex-unique-overlay-card-3-p-3": "Toisin kuin liitettyjen verkkojen palvelimet (sähköposti, XMPP tai Matrix), SimpleX-palvelimet eivät tallenna käyttäjätilejä, ne vain välittävät viestejä, suojaten molempien osapuolien yksityisyyttä.", + "simplex-unique-overlay-card-3-p-4": "Lähetetyssä ja vastaanotetussa palvelimen liikenteessä ei ole yhteisiä tunnisteita tai salaustekstiä — jos joku havaitsee sen, he eivät voi helposti selvittää kuka kommunikoi kenen kanssa, vaikka TLS olisi vaarantunut.", + "simplex-unique-overlay-card-4-p-3": "Jos harkitset kehittämistä SimpleX-alustalle, esimerkiksi chat-botin luomista SimpleX-sovelluksen käyttäjille tai SimpleX Chat -kirjaston integrointia mobiilisovelluksiisi, ole hyvä ja ota yhteyttä saadaksesi neuvoja ja tukea.", + "simplex-unique-overlay-card-4-p-1": "Voit käyttää SimpleX:ää omien palvelimiesi kanssa ja silti kommunikoida ihmisten kanssa, jotka käyttävät meille tarjottuja valmiiksi määritettyjä palvelimia.", + "simplex-unique-overlay-card-4-p-2": "SimpleX-alusta käyttää avoimeen protokollaan ja tarjoaa SDK:n chatbotien luomiseen, mahdollistaen palvelujen toteuttamisen, joiden kanssa käyttäjät voivat olla vuorovaikutuksessa SimpleX Chat -sovellusten kautta — odotamme innolla nähdä, millaisia SimpleX-palveluja voit rakentaa.", + "simplex-unique-card-3-p-1": "SimpleX tallentaa kaikki käyttäjätiedot asiakaslaitteille siirrettävässä salatussa tietokannan muodossa — se voidaan siirtää toiseen laitteeseen.", + "simplex-unique-card-3-p-2": "Päästä päähän salatut viestit säilytetään väliaikaisesti SimpleX-releay-palvelimilla, kunnes ne vastaanotetaan, minkä jälkeen ne poistetaan pysyvästi.", + "we-invite-you-to-join-the-conversation": "Kutsumme sinut mukaan keskusteluun", + "join-the-REDDIT-community": "Liity REDDIT-yhteisöön", + "simplex-unique-card-2-p-1": "Koska sinulla ei ole tunnistetta tai kiinteää osoitetta SimpleX-alustalla, kukaan ei voi ottaa sinuun yhteyttä, ellet jaa kertakäyttöistä tai väliaikaista käyttäjäosoitetta, kuten QR-koodia tai linkkiä.", + "join-us-on-GitHub": "Liity GitHubissa", + "donate-here-to-help-us": "Tue meitä täällä lahjoituksilla", + "sign-up-to-receive-our-updates": "Tilaa päivityksemme", + "enter-your-email-address": "Syötä sähköpostiosoitteesi", + "get-simplex": "Hanki SimpleX", + "why-simplex-is": "Miksi SimpleX on", + "unique": "ainutlaatuinen", + "learn-more": "Lue lisää", + "more-info": "Lisätietoja", + "contact-hero-subheader": "Skannaa QR-koodi SimpleX Chat -sovelluksella puhelimessasi tai tabletissasi.", + "contact-hero-p-1": "Tämän linkin julkiset avaimet ja viestijonon osoite eivät lähetä verkkoa pitkin, kun katsot tätä sivua — ne sisältyvät linkin URL:n hash-osaan.", + "contact-hero-p-3": "Käytä alla olevia linkkejä ladataksesi sovelluksen.", + "scan-qr-code-from-mobile-app": "Skannaa QR-koodi mobiilisovelluksesta", + "to-make-a-connection": "Yhteyden muodostaminen:", + "install-simplex-app": "Asenna SimpleX-sovellus", + "connect-in-app": "Yhdisty sovelluksessa", + "open-simplex-app": "Avaa Simplex-sovellus", + "tap-the-connect-button-in-the-app": "Napauta sovelluksessa olevaa ‘yhdistä’-painiketta", + "scan-the-qr-code-with-the-simplex-chat-app": "Skannaa QR-koodi SimpleX Chat -sovelluksella", + "scan-the-qr-code-with-the-simplex-chat-app-description": "Tämän linkin julkiset avaimet ja viestijonon osoite eivät lähetä verkkoa pitkin, kun katsot tätä sivua —
ne sisältyvät linkin URL:n hash-osaan.", + "installing-simplex-chat-to-terminal": "Asentaminen SimpleX Chat terminaaliin", + "see-simplex-chat": "Katso SimpleX Chat", + "use-this-command": "Käytä tätä komentoa:", + "github-repository": "GitHub-varasto", + "the-instructions--source-code": "ohjeet, miten ladata tai kääntää se lähdekoodista.", + "if-you-already-installed-simplex-chat-for-the-terminal": "Jos olet jo asentanut SimpleX Chatin terminaaliin", + "if-you-already-installed": "Jos olet jo asentanut", + "simplex-chat-for-the-terminal": "SimpleX Chatin terminaaliin", + "copy-the-command-below-text": "kopioi alla oleva komento ja käytä sitä keskustelussa:", + "privacy-matters-section-header": "Miksi yksityisyys on tärkeää", + "tap-to-close": "Napauta sulkeaksesi", + "simplex-network-section-header": "SimpleX Verkko", + "simplex-network-section-desc": "Simplex Chat tarjoaa parhaan yksityisyyden yhdistämällä P2P-verkkojen ja liitettävien verkkojen edut.", + "simplex-network-1-desc": "Kaikki viestit lähetetään palvelimien kautta, mikä sekä parantaa metatietojen yksityisyyttä että mahdollistaa luotettavan asynkronisen viestien toimituksen, samalla välttäen monia", + "simplex-network-2-header": "Toisin kuin liitettävät verkot", + "simplex-network-3-desc": "palvelimet tarjoavat yksisuuntaisia jonopalveluja yhdistääkseen käyttäjät, mutta niillä ei ole näkyvyyttä verkon yhteyskarttaan — ainoastaan käyttäjillä on.", + "comparison-section-header": "Vertailu muihin protokolliin", + "simplex-network-2-desc": "SimpleXin relea-palvelimet EIVÄT tallenna käyttäjäprofiileja, yhteystietoja ja toimitettuja viestejä, ne EIVÄT yhdisty toisiinsa, eikä ole OLE olemassa palvelinluetteloa.", + "simplex-network-3-header": "SimpleX-verkko", + "comparison-point-5-text": "Keskuskomponentti tai muu verkkoa koskeva hyökkäys", + "no-decentralized": "Ei - hajautettu", + "no-federated": "Ei - liitetty", + "comparison-section-list-point-1": "Yleensä pohjautuu puhelinnumeroon, joissain tapauksissa käyttäjänimiin", + "comparison-section-list-point-2": "DNS-pohjaiset osoitteet", + "comparison-section-list-point-3": "Julkinen avain tai jokin muu maailmanlaajuisesti uniikki tunniste", + "comparison-section-list-point-4a": "SimpleXin releapalvelimet eivät voi vaarantaa päästä päähän -salausta. Tarkista turvakoodi hyödyntääksesi hyökkäyssuojaa ylitys-kanavalla", + "comparison-section-list-point-5": "Ei suojaa käyttäjien metatietojen yksityisyyttä", + "comparison-section-list-point-6": "Vaikka P2P-verkot ovat hajautettuja, ne eivät ole liitettäviä - ne toimivat yhtenä verkostona", + "guide-dropdown-5": "Datan hallinta", + "guide-dropdown-6": "Ääni- ja videopuhelut", + "comparison-section-list-point-4": "Jos operaattorin palvelimet ovat vaarantuneet. Tarkista turvakoodi Signalissa ja joissakin muissa sovelluksissa suojautuaksesi hyökkäykseltä", + "guide": "Opas", + "docs-dropdown-1": "SimpleX-alusta", + "docs-dropdown-2": "Android-tiedostoihin pääseminen", + "docs-dropdown-3": "Keskustelutietokantaan pääseminen", + "docs-dropdown-4": "SMP-palvelimen isännöiminen", + "newer-version-of-eng-msg": "Tästä sivusta on uudempi versio englanniksi.", + "click-to-see": "Näytä napsauttamalla", + "menu": "Valikko", + "simplex-chat-via-f-droid": "SimpleX Chat F-Droidin kautta", + "stable-and-beta-versions-built-by-developers": "Kehittäjien luomat vakaat ja beta-versiot", + "f-droid-page-simplex-chat-repo-section-text": "Lisätäksesi sen F-Droid-asiakkaaseesi, skannaa QR-koodi tai käytä tätä URL-osoitetta:" +} diff --git a/website/langs/fr.json b/website/langs/fr.json index ab29ca7c61..5d261d69ab 100644 --- a/website/langs/fr.json +++ b/website/langs/fr.json @@ -205,8 +205,8 @@ "comparison-section-list-point-1": "Généralement basé sur un numéro de téléphone, dans certains cas sur des noms d'utilisateur", "comparison-section-list-point-2": "Adresses basées sur le DNS", "comparison-section-list-point-3": "Clé publique ou tout autre identifiant global unique", - "comparison-section-list-point-4": "Si les serveurs de l'opérateur sont compromis", - "comparison-section-list-point-5": "Ne protège pas les métadonnées des utilisateurs", + "comparison-section-list-point-4": "Si les serveurs de l'opérateur sont compromis. Vérifier les codes de sécurités sur Signal et d'autres applications pour limiter les risques", + "comparison-section-list-point-5": "Ne protège pas la confidentialité des métadonnées des utilisateurs", "comparison-section-list-point-6": "Bien que les P2P soient distribués, ils ne sont pas fédérés - ils fonctionnent comme un seul réseau", "comparison-section-list-point-7": "Les réseaux P2P ont soit une autorité centrale, soit l'ensemble du réseau peut être compromis", "voir-ici": "voir ici", @@ -240,8 +240,9 @@ "simplex-chat-via-f-droid": "SimpleX Chat via F-Droid", "simplex-chat-repo": "Dépot SimpleX Chat", "stable-and-beta-versions-built-by-developers": "Versions stables et bêta crées par les développeurs", - "f-droid-page-simplex-chat-repo-section-text": "Pour l'ajouter à votre client F-Droid scannez le code QR ou utilisez cette URL:", + "f-droid-page-simplex-chat-repo-section-text": "Pour l'ajouter à votre client F-Droid scannez le code QR ou utilisez cette URL :", "signing-key-fingerprint": "Empreinte de signature numérique (SHA-256)", "f-droid-org-repo": "Dépot F-Droid.org", - "stable-versions-built-by-f-droid-org": "Versions stables créées par F-Droid.org" -} \ No newline at end of file + "stable-versions-built-by-f-droid-org": "Versions stables créées par F-Droid.org", + "comparison-section-list-point-4a": "Les relais SimpleX ne peuvent pas compromettre le chiffrement e2e. Vérifier le code de sécurité pour limiter les attaques sur le canal hors bande" +} diff --git a/website/langs/he.json b/website/langs/he.json new file mode 100644 index 0000000000..c814e405c0 --- /dev/null +++ b/website/langs/he.json @@ -0,0 +1,78 @@ +{ + "home": "מסך הבית", + "developers": "מפתחים", + "reference": "הפניה", + "blog": "בלוג", + "features": "מאפיינים", + "why-simplex": "למה SimpleX", + "simplex-privacy": "פרטיות SimpleX", + "simplex-network": "רשת SimpleX", + "simplex-explained": "תיאור SimpleX", + "simplex-explained-tab-1-text": "1. חוויית משתמש", + "simplex-explained-tab-2-text": "2. איך זה עובד", + "simplex-chat-protocol": "פרוטוקול SimpleX Chat", + "terminal-cli": "ממשק שורת פקודה", + "terms-and-privacy-policy": "תנאים ומדיניות פרטיות", + "hero-header": "פרטיות מוגדרת מחדש", + "hero-subheader": "מערכת העברת ההודעות הראשונה
ללא מזהי שתמש", + "hero-overlay-1-textlink": "מדוע מזהי משתמש מזיקים לפרטיות?", + "hero-overlay-2-textlink": "איך SimpleX עובד?", + "hero-2-header": "יצירת חיבור פרטי", + "hero-2-header-desc": "הסרטון מראה כיצד אתם יוצרים קשר עם חברכם באמצעות קוד QR חד פעמי, באופן אישי או באמצעות קישור וידאו. באפשרותכם גם להתחבר על-ידי שיתוף קישור ההזמנה.", + "hero-overlay-1-title": "איך SimpleX עובד?", + "feature-1-title": "הודעות מוצפנות מקצה לקצה עם סימונים ואפשרויות עריכה", + "feature-2-title": "תמונות וקבצים
מוצפנים מקצה לקצה", + "feature-3-title": "קבוצות סודיות מבוזרות —
רק המשתמשים יודעים שהן קיימות", + "simplex-private-3-title": "תעבורת TLS
מאובטחת ומאומתת", + "simplex-private-card-1-point-2": "תיבת הצפנה NaCL בכל תור כדי למנוע קורלציית תעבורה בין תורי הודעות במקרה שאבטחת TLS נפגעה.", + "simplex-private-6-title": "החלפת מפתחות
מחוץ לרשת", + "simplex-private-7-title": "בדיקת תקינות
ההודעה", + "simplex-private-8-title": "ערבוב הודעות
לשם הפחתת קורלציה", + "simplex-private-9-title": "תורי הודעות
חד-כיווניים", + "simplex-private-10-title": "מזהים זמניים אנונימיים בזוגות", + "simplex-private-card-2-point-1": "שכבה נוספת של הצפנת שרת למסירה לנמען, כדי למנוע קורלציה בין תעבורת השרת המתקבלת ונשלחת במקרה שאבטחת TLS נפגעה.", + "simplex-private-card-3-point-1": "עבור חיבורי שרת-לקוח, נעשה שימוש רק ב-TLS 1.2/1.3 עם אלגוריתמים חזקים.", + "simplex-private-card-3-point-2": "טביעת אצבע של שרת ואיגוד ערוצים מונעים התקפת אדם בתווך (MITM) והתקפת שליחה מחדש (Replay attack).", + "simplex-private-card-5-point-1": "SimpleX משתמש בריפוד תוכן עבור כל שכבת הצפנה כדי לסכל התקפות בגודל הודעה.", + "simplex-private-card-5-point-2": "זה גורם להודעות בגדלים שונים להיראות זהים לשרתים ולמשקיפים ברשת.", + "simplex-private-card-6-point-1": "פלטפורמות תקשורת רבות חשופות להתקפות אדם בתווך (MITM) על ידי שרתים או ספקי רשת.", + "simplex-private-card-9-point-2": "זה מפחית את וקטורי ההתקפה, בהשוואה למתווכי הודעות מסורתיים, ואת המטא-נתונים הזמינים.", + "simplex-private-card-10-point-2": "זה מאפשר להעביר הודעות ללא מזהי פרופיל משתמש, ומספק פרטיות מטא-נתונים טובה יותר מאשר חלופות אחרות.", + "privacy-matters-1-title": "פרסום ואפליית מחירים", + "privacy-matters-1-overlay-1-linkText": "פרטיות חוסכת לכם כסף", + "privacy-matters-2-title": "מניפולציה בבחירות", + "privacy-matters-2-overlay-1-title": "פרטיות מעניקה לכם עוצמה", + "privacy-matters-3-overlay-1-title": "פרטיות מגנה על החופש שלכם", + "simplex-explained-tab-3-text": "3. מה השרתים רואים", + "simplex-explained-tab-2-p-1": "עבור כל חיבור, שני תורי העברת הודעות נפרדים משמשים לשליחה וקבלה של הודעות דרך שרתים שונים.", + "simplex-explained-tab-2-p-2": "שרתים מעבירים הודעות רק בכיוון אחד, מבלי לקבל את התמונה המלאה של השיחה או החיבורים של המשתמש.", + "simplex-explained-tab-3-p-1": "לשרתים יש אישורים אנונימיים נפרדים לכל תור, ואינם יודעים לאילו משתמשים הם שייכים.", + "simplex-explained-tab-1-p-2": "איך זה יכול לעבוד עם תורים חד-כיווניים וללא מזהי פרופיל משתמש?", + "simplex-explained-tab-3-p-2": "משתמשים יכולים לשפר עוד יותר את פרטיות המטא-נתונים על ידי שימוש ב- Tor כדי לגשת לשרתים, ולמנוע קורלציה לפי כתובת IP.", + "chat-bot-example": "דוגמה לצ'אט בוט", + "smp-protocol": "פרוטוקול SMP", + "chat-protocol": "פרוטוקול צ'אט", + "donate": "תרומה", + "copyright-label": "© 2020-2023 SimpleX | פרויקט קוד פתוח", + "hero-p-1": "לאפליקציות אחרות יש מזהי משתמש: Signal, Matrix, Session, Briar, Jami, Cwtch וכו'.
ל-SimpleX אין, אפילו לא מספרים אקראיים.
זה משפר באופן קיצוני את הפרטיות שלך.", + "hero-overlay-2-title": "מדוע מזהי משתמש מזיקים לפרטיות?", + "feature-6-title": "שיחות שמע ווידאו
מוצפנות מקצה לקצה", + "feature-4-title": "הודעות קוליות מוצפנות מקצה לקצה", + "feature-5-title": "הודעות נעלמות", + "feature-7-title": "מסד נתונים מוצפן נייד — העברת הפרופיל שלכם למכשיר אחר", + "feature-8-title": "מצב זהות נסתרת —
ייחודי ל-SimpleX Chat", + "simplex-private-4-title": "אופציונלי
גישה דרך Tor", + "simplex-network-overlay-1-title": "השוואה לפרוטוקולי העברת הודעות P2P", + "simplex-private-2-title": "שכבה נוספת של
הצפנת שרת", + "simplex-private-1-title": "2 שכבות של
הצפנה מקצה לקצה", + "simplex-private-5-title": "שכבות מרובות של
ריפוד תוכן", + "simplex-private-card-3-point-3": "חידוש החיבור מושבת כדי למנוע התקפות הפעלה.", + "simplex-private-card-4-point-1": "כדי להגן על כתובת ה-IP שלכם, אתם יכולים לגשת לשרתים דרך Tor או רשת שכבת-על אחרת של תעבורה.", + "simplex-private-card-4-point-2": "כדי להשתמש ב-SimpleX דרך Tor, התקן את אפליקציית Orbot והפעל את SOCKS5 proxy (או VPN ב-iOS).", + "simplex-private-card-7-point-2": "אם הודעה כלשהי תתווסף, תוסר או תשתנה, הנמען יקבל התראה.", + "simplex-private-card-9-point-1": "כל תור הודעות מעביר הודעות בכיוון אחד, עם כתובות השליחה והקבלה השונות.", + "privacy-matters-1-overlay-1-title": "פרטיות חוסכת לכם כסף", + "privacy-matters-2-overlay-1-linkText": "פרטיות מעניקה לכם עוצמה", + "privacy-matters-3-overlay-1-linkText": "פרטיות מגנה על החופש שלכם", + "simplex-explained-tab-1-p-1": "אתם יכולים ליצור אנשי קשר וקבוצות, ולנהל שיחות דו-כיווניות, כמו בכל תוכנה אחרת לשליחת הודעות." +} diff --git a/website/langs/it.json b/website/langs/it.json index 41ea654b24..d7ca0d9a34 100644 --- a/website/langs/it.json +++ b/website/langs/it.json @@ -207,9 +207,9 @@ "simplex-network-1-desc": "Tutti i messaggi vengono inviati tramite i server, garantendo una migliore privacy dei metadati e una consegna asincrona dei messaggi affidabile, evitando molti", "simplex-network-3-desc": "i server forniscono code unidirezionali per connettere gli utenti, ma non hanno visibilità del grafo delle connessioni di rete — solo gli utenti.", "comparison-point-5-text": "Componente centrale o altro attacco a livello di rete", - "comparison-section-list-point-5": "Non protegge i metadati degli utenti", + "comparison-section-list-point-5": "Non protegge la privacy dei metadati degli utenti", "comparison-section-list-point-3": "Chiave pubblica o altro ID univoco globale", - "comparison-section-list-point-4": "Se i server dell'operatore sono compromessi", + "comparison-section-list-point-4": "Se i server dell'operatore sono compromessi. Verifica il codice di sicurezza in Signal e alcune altre app per mitigarlo", "guide-dropdown-1": "Avvio rapido", "guide-dropdown-2": "Inviare messaggi", "guide-dropdown-3": "Gruppi segreti", @@ -242,5 +242,6 @@ "simplex-chat-via-f-droid": "SimpleX Chat via F-Droid", "simplex-chat-repo": "Repo di SimpleX Chat", "stable-and-beta-versions-built-by-developers": "Versioni stabili e beta compilate dagli sviluppatori", - "f-droid-page-simplex-chat-repo-section-text": "Per aggiungerlo al tuo client F-Droid scansiona il codice QR o usa questo URL:" -} \ No newline at end of file + "f-droid-page-simplex-chat-repo-section-text": "Per aggiungerlo al tuo client F-Droid scansiona il codice QR o usa questo URL:", + "comparison-section-list-point-4a": "I relay di SimpleX non possono compromettere la crittografia e2e. Verifica il codice di sicurezza per mitigare gli attacchi sul canale fuori banda" +} diff --git a/website/langs/ja.json b/website/langs/ja.json index 9fc7956590..f77f0619ce 100644 --- a/website/langs/ja.json +++ b/website/langs/ja.json @@ -26,5 +26,32 @@ "simplex-unique-card-1-p-1": "SimpleXは、SimpleXプラットフォームのサーバやその他の観察者から隠すことで、あなたのプロフィール、連絡先やメタデータのプライバシーを守ります。", "simplex-unique-overlay-card-4-p-3": "例えば、SimpleXアプリユーザへのチャットボットやSimpleX Chatライブラリーの携帯アプリへの統合など、SimpleXプラットフォームに関する開発を検討してくださっているようでしたら、どのようなアドバイスや支援のことでもご連絡ください 。", "simplex-unique-overlay-card-4-p-2": "SimpleXプラットフォームは、SimpleX Chatアプリを介してユーザが交流するサービスを実装させつつオープンプロトコルを使い、チャットボットを作成するためにSDKを提供します—私たちはあなた達がどのようなSimpleXのサービスを築くか本当に楽しみです。", - "simplex-unique-overlay-card-4-p-1": "あなたが、自分自身のサーバでSimpleXを使っても、私たちが提供する事前に構築されたサーバを使う方々と連絡を取ることができます。" + "simplex-unique-overlay-card-4-p-1": "あなたが、自分自身のサーバでSimpleXを使っても、私たちが提供する事前に構築されたサーバを使う方々と連絡を取ることができます。", + "reference": "参考文献", + "simplex-explained-tab-1-text": "1. ユーザーが経験すること", + "simplex-explained-tab-1-p-2": "ユーザー プロファイル識別子なしで単方向キューをどのように処理できるのでしょうか?", + "simplex-chat-protocol": "SimpleX チャットプロトコル", + "terminal-cli": "ターミナル CLI", + "terms-and-privacy-policy": "利用規約とプライバシーポリシー", + "hero-header": "プライバシーの基準を新境地に", + "hero-subheader": "
ユーザーIDを持たない最初のメッセンジャー", + "hero-overlay-1-textlink": "ユーザー ID がプライバシーに悪影響を与えるのはなぜですか?", + "hero-overlay-2-textlink": "SimpleXの仕組みは?", + "hero-2-header": "プライベートな接続をする", + "hero-2-header-desc": "このビデオでは、1回限りのQRコード、対面、またはビデオリンクを通じて友人と接続する方法を紹介しています。招待リンクを共有することでも接続できます。", + "simplex-network": "SimpleXネットワーク", + "simplex-explained": "SimpleXの説明", + "simplex-explained-tab-1-p-1": "他のメッセンジャーと同様に、連絡先やグループを作成し、双方向の会話を行うことができます。", + "simplex-explained-tab-2-text": "2. 仕組み", + "simplex-explained-tab-3-text": "3. サーバーが認識するもの", + "smp-protocol": "SMPプロトコル", + "simplex-explained-tab-2-p-1": "接続ごとに 2 つの個別のメッセージング キューを使用して、異なるサーバー経由でメッセージを送受信します。", + "simplex-explained-tab-2-p-2": "サーバーは、ユーザーの会話や接続の全体像を把握することなく、メッセージを一方向に渡すだけです。", + "simplex-explained-tab-3-p-1": "サーバーはキューごとに個別の匿名認証情報を持っており、どのユーザーに属しているかはわかりません。", + "simplex-explained-tab-3-p-2": "ユーザーは、Tor を使用してサーバーにアクセスし、IP アドレスによる相関を防ぐことで、メタデータのプライバシーをさらに向上させることができます。", + "chat-protocol": "チャットプロトコル", + "chat-bot-example": "チャットボットの例", + "donate": "寄付", + "copyright-label": "© 2020-2023 SimpleX | Open-Source Project", + "hero-p-1": "他のアプリにはユーザー ID があります: Signal、Matrix、Session、Briar、Jami、Cwtch など。
SimpleX にはありません。乱数さえもありません
これにより、プライバシーが大幅に向上します。" } diff --git a/website/langs/pl.json b/website/langs/pl.json index 3c3dd26e1d..0ca0363aeb 100644 --- a/website/langs/pl.json +++ b/website/langs/pl.json @@ -232,5 +232,15 @@ "menu": "Menu", "on-this-page": "Na tej stronie", "back-to-top": "Powrót do góry", - "glossary": "Słowniczek" + "glossary": "Słowniczek", + "f-droid-page-simplex-chat-repo-section-text": "Aby dodać do Twojego klienta F-Droid, zeskanuj kod QR lub użyj tego URL:", + "f-droid-page-f-droid-org-repo-section-text": "Repozytoria SimpleX Chat i F-Droid.org mają podpisane budowy z innymi kluczami. Aby zmienić, proszę wyeksportuj bazę czatu i przeinstaluj aplikację.", + "docs-dropdown-8": "Serwis katalogowy SimpleX", + "simplex-chat-via-f-droid": "SimpleX Chat na F-Droid", + "simplex-chat-repo": "Repo SimpleX", + "stable-and-beta-versions-built-by-developers": "Wersje stabilne i beta zbudowane przez deweloperów", + "signing-key-fingerprint": "Odcisk klucza podpisu (SHA-256)", + "f-droid-org-repo": "Repo F-Droid.org", + "stable-versions-built-by-f-droid-org": "Wersje stabilne zbudowane przez F-Droid.org", + "releases-to-this-repo-are-done-1-2-days-later": "Wydania na tym repo są 1-2 dni później" } diff --git a/website/src/.well-known/README.md b/website/src/.well-known/README.md index ec4c7f57e8..6346c85a76 100644 --- a/website/src/.well-known/README.md +++ b/website/src/.well-known/README.md @@ -12,4 +12,6 @@ File `assetlinks.json` includes certificate hashes for: ## iOS -`apple-app-site-association` currently does not work, as it needs to be served with `Content-type: application/json; charset=utf-8` and GitHub pages do not support adding this header to files without JSON extension. +`apple-app-site-association` needs to be served with `Content-type: application/json; charset=utf-8` and GitHub pages do not support adding this header to files without JSON extension. + +To workaround this (thanks to [StackOverflow - Serve json data from github pages](https://stackoverflow.com/questions/39199042/serve-json-data-from-github-pages)) we're creating directory named `apple-app-site-association` with `index.json` file that contains all the necessary configs. \ No newline at end of file diff --git a/website/src/.well-known/apple-app-site-association b/website/src/.well-known/apple-app-site-association/index.json similarity index 100% rename from website/src/.well-known/apple-app-site-association rename to website/src/.well-known/apple-app-site-association/index.json diff --git a/website/src/.well-known/nostr.json b/website/src/.well-known/nostr.json new file mode 100644 index 0000000000..8c409fb86c --- /dev/null +++ b/website/src/.well-known/nostr.json @@ -0,0 +1,18 @@ +{ + "names": { + "_": "c998a5739f04f7fff202c54962aa5782b34ecb10d6f915bdfdd7582963bf9171" + }, + "relays": { + "c998a5739f04f7fff202c54962aa5782b34ecb10d6f915bdfdd7582963bf9171": [ + "wss://nostr.orangepill.dev", + "wss://eden.nostr.land", + "wss://relay.damus.io", + "wss://relay.snort.social", + "wss://relay.current.fyi", + "wss://nos.lol", + "wss://relay.nostr.bg", + "wss://nostr-verified.wellorder.net", + "wss://nostr.milou.lol" + ] + } +} diff --git a/website/src/_includes/footer.html b/website/src/_includes/footer.html index d6b2372c3e..1c23806760 100644 --- a/website/src/_includes/footer.html +++ b/website/src/_includes/footer.html @@ -63,7 +63,7 @@ - + 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