diff --git a/apps/ios/Shared/Model/ImageUtils.swift b/apps/ios/Shared/Model/ImageUtils.swift index 41d741e7e..6437597b1 100644 --- a/apps/ios/Shared/Model/ImageUtils.swift +++ b/apps/ios/Shared/Model/ImageUtils.swift @@ -158,7 +158,8 @@ func imageHasAlpha(_ img: UIImage) -> Bool { return false } -func saveFileFromURL(_ url: URL, encrypted: Bool) -> CryptoFile? { +func saveFileFromURL(_ url: URL) -> CryptoFile? { + let encrypted = privacyEncryptLocalFilesGroupDefault.get() let savedFile: CryptoFile? if url.startAccessingSecurityScopedResource() { do { @@ -185,10 +186,19 @@ func saveFileFromURL(_ url: URL, encrypted: Bool) -> CryptoFile? { func moveTempFileFromURL(_ url: URL) -> CryptoFile? { do { + let encrypted = privacyEncryptLocalFilesGroupDefault.get() let fileName = uniqueCombine(url.lastPathComponent) - try FileManager.default.moveItem(at: url, to: getAppFilePath(fileName)) + let savedFile: CryptoFile? + if encrypted { + let cfArgs = try encryptCryptoFile(fromPath: url.path, toPath: getAppFilePath(fileName).path) + try FileManager.default.removeItem(atPath: url.path) + savedFile = CryptoFile(filePath: fileName, cryptoArgs: cfArgs) + } else { + try FileManager.default.moveItem(at: url, to: getAppFilePath(fileName)) + savedFile = CryptoFile.plain(fileName) + } ChatModel.shared.filesToDelete.remove(url) - return CryptoFile.plain(fileName) + return savedFile } catch { logger.error("ImageUtils.moveTempFileFromURL error: \(error.localizedDescription)") return nil diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index f2aa6cbd9..9f3e96888 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -855,8 +855,8 @@ 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, encrypted: Bool, auto: Bool = false) async { - if let chatItem = await apiReceiveFile(fileId: fileId, encrypted: encrypted, auto: auto) { +func receiveFile(user: any UserLike, fileId: Int64, auto: Bool = false) async { + if let chatItem = await apiReceiveFile(fileId: fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get(), auto: auto) { await chatItemSimpleUpdate(user, chatItem) } } @@ -1516,7 +1516,7 @@ func processReceivedMsg(_ res: ChatResponse) async { } if let file = cItem.autoReceiveFile() { Task { - await receiveFile(user: user, fileId: file.fileId, encrypted: cItem.encryptLocalFile, auto: true) + await receiveFile(user: user, fileId: file.fileId, auto: true) } } if cItem.showNotification { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 4ae2296f4..b6a070278 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -85,8 +85,7 @@ struct CIFileView: View { Task { logger.debug("CIFileView fileAction - in .rcvInvitation, in Task") if let user = m.currentUser { - let encrypted = privacyEncryptLocalFilesGroupDefault.get() - await receiveFile(user: user, fileId: file.fileId, encrypted: encrypted) + await receiveFile(user: user, fileId: file.fileId) } } } else { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index 9ae52ae01..2e20e56b7 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -38,7 +38,7 @@ struct CIImageView: View { case .rcvInvitation: Task { if let user = m.currentUser { - await receiveFile(user: user, fileId: file.fileId, encrypted: chatItem.encryptLocalFile) + await receiveFile(user: user, fileId: file.fileId) } } case .rcvAccepted: diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index be8b25a0f..e0d2bed47 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -26,6 +26,8 @@ struct CIVideoView: View { @State private var player: AVPlayer? @State private var fullPlayer: AVPlayer? @State private var url: URL? + @State private var urlDecrypted: URL? + @State private var decryptionInProgress: Bool = false @State private var showFullScreenPlayer = false @State private var timeObserver: Any? = nil @State private var fullScreenTimeObserver: Any? = nil @@ -39,8 +41,12 @@ struct CIVideoView: View { self._videoWidth = videoWidth self.scrollProxy = scrollProxy if let url = getLoadedVideo(chatItem.file) { - self._player = State(initialValue: VideoPlayerView.getOrCreatePlayer(url, false)) - self._fullPlayer = State(initialValue: AVPlayer(url: url)) + let decrypted = chatItem.file?.fileSource?.cryptoArgs == nil ? url : chatItem.file?.fileSource?.decryptedGet() + self._urlDecrypted = State(initialValue: decrypted) + if let decrypted = decrypted { + self._player = State(initialValue: VideoPlayerView.getOrCreatePlayer(decrypted, false)) + self._fullPlayer = State(initialValue: AVPlayer(url: decrypted)) + } self._url = State(initialValue: url) } if let data = Data(base64Encoded: dropImagePrefix(image)), @@ -53,8 +59,10 @@ struct CIVideoView: View { let file = chatItem.file ZStack { ZStack(alignment: .topLeading) { - if let file = file, let preview = preview, let player = player, let url = url { - videoView(player, url, file, preview, duration) + if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted { + videoView(player, decrypted, file, preview, duration) + } else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil { + videoViewEncrypted(file, defaultPreview, duration) } else if let data = Data(base64Encoded: dropImagePrefix(image)), let uiImage = UIImage(data: data) { imageView(uiImage) @@ -62,7 +70,7 @@ struct CIVideoView: View { if let file = file { switch file.fileStatus { case .rcvInvitation: - receiveFileIfValidSize(file: file, encrypted: false, receiveFile: receiveFile) + receiveFileIfValidSize(file: file, receiveFile: receiveFile) case .rcvAccepted: switch file.fileProtocol { case .xftp: @@ -88,7 +96,7 @@ struct CIVideoView: View { } if let file = file, case .rcvInvitation = file.fileStatus { Button { - receiveFileIfValidSize(file: file, encrypted: false, receiveFile: receiveFile) + receiveFileIfValidSize(file: file, receiveFile: receiveFile) } label: { playPauseIcon("play.fill") } @@ -96,6 +104,40 @@ struct CIVideoView: View { } } + private func videoViewEncrypted(_ file: CIFile, _ defaultPreview: UIImage, _ duration: Int) -> some View { + return ZStack(alignment: .topTrailing) { + ZStack(alignment: .center) { + let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete + imageView(defaultPreview) + .fullScreenCover(isPresented: $showFullScreenPlayer) { + if let decrypted = urlDecrypted { + fullScreenPlayer(decrypted) + } + } + .onTapGesture { + decrypt(file: file) { + showFullScreenPlayer = urlDecrypted != nil + } + } + if !decryptionInProgress { + Button { + decrypt(file: file) { + if let decrypted = urlDecrypted { + videoPlaying = true + player?.play() + } + } + } label: { + playPauseIcon(canBePlayed ? "play.fill" : "play.slash") + } + .disabled(!canBePlayed) + } else { + videoDecryptionProgress() + } + } + } + } + private func videoView(_ player: AVPlayer, _ url: URL, _ file: CIFile, _ preview: UIImage, _ duration: Int) -> some View { let w = preview.size.width <= preview.size.height ? maxWidth * 0.75 : maxWidth DispatchQueue.main.async { videoWidth = w } @@ -159,6 +201,16 @@ struct CIVideoView: View { .clipShape(Circle()) } + private func videoDecryptionProgress(_ color: Color = .white) -> some View { + ProgressView() + .progressViewStyle(.circular) + .frame(width: 12, height: 12) + .tint(color) + .frame(width: 40, height: 40) + .background(Color.black.opacity(0.35)) + .clipShape(Circle()) + } + private func durationProgress() -> some View { HStack { Text("\(durationText(videoPlaying ? progress : duration))") @@ -257,10 +309,10 @@ struct CIVideoView: View { } // TODO encrypt: where file size is checked? - private func receiveFileIfValidSize(file: CIFile, encrypted: Bool, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) { + private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64, Bool) async -> Void) { Task { if let user = m.currentUser { - await receiveFile(user, file.fileId, encrypted, false) + await receiveFile(user, file.fileId, false) } } } @@ -323,6 +375,22 @@ struct CIVideoView: View { } } + private func decrypt(file: CIFile, completed: (() -> Void)? = nil) { + if decryptionInProgress { return } + decryptionInProgress = true + Task { + urlDecrypted = await file.fileSource?.decryptedGetOrCreate(&ChatModel.shared.filesToDelete) + await MainActor.run { + if let decrypted = urlDecrypted { + player = VideoPlayerView.getOrCreatePlayer(decrypted, false) + fullPlayer = AVPlayer(url: decrypted) + } + decryptionInProgress = true + completed?() + } + } + } + private func addObserver(_ player: AVPlayer, _ url: URL) { timeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.01, preferredTimescale: CMTimeScale(NSEC_PER_SEC)), queue: .main) { time in if let item = player.currentItem { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index 2e54ba414..3aecb65eb 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -221,7 +221,7 @@ struct VoiceMessagePlayer: View { Button { Task { if let user = chatModel.currentUser { - await receiveFile(user: user, fileId: recordingFile.fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get()) + await receiveFile(user: user, fileId: recordingFile.fileId) } } } label: { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index d089c7d6f..1fd006d49 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -689,7 +689,7 @@ struct ComposeView: View { 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, encrypted: privacyEncryptLocalFilesGroupDefault.get()) { + if let savedFile = saveFileFromURL(file) { sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl) } } diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 1c34796ec..c6b149bf7 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -631,7 +631,7 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? { ntfBadgeCountGroupDefault.set(max(0, ntfBadgeCountGroupDefault.get() - 1)) } if let file = cItem.autoReceiveFile() { - cItem = autoReceiveFile(file, encrypted: cItem.encryptLocalFile) ?? cItem + cItem = autoReceiveFile(file) ?? cItem } let ntf: NSENotification = cInfo.ntfsEnabled ? .nse(createMessageReceivedNtf(user, cInfo, cItem)) : .empty return cItem.showNotification ? (aChatItem.chatId, ntf) : nil @@ -775,7 +775,8 @@ func apiSetFileToReceive(fileId: Int64, encrypted: Bool) { logger.error("setFileToReceive error: \(responseError(r))") } -func autoReceiveFile(_ file: CIFile, encrypted: Bool) -> ChatItem? { +func autoReceiveFile(_ file: CIFile) -> ChatItem? { + let encrypted = privacyEncryptLocalFilesGroupDefault.get() switch file.fileProtocol { case .smp: return apiReceiveFile(fileId: file.fileId, encrypted: encrypted)?.chatItem diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index b04891e39..b7be43ef7 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2242,11 +2242,6 @@ public struct ChatItem: Identifiable, Decodable { return fileSource.cryptoArgs != nil } - public var encryptLocalFile: Bool { - content.msgContent?.isVideo == false && - privacyEncryptLocalFilesGroupDefault.get() - } - public var memberDisplayName: String? { get { if case let .groupRcv(groupMember) = chatDir { @@ -2910,6 +2905,39 @@ public struct CryptoFile: Codable { public static func plain(_ f: String) -> CryptoFile { CryptoFile(filePath: f, cryptoArgs: nil) } + + private func decryptToTmpFile(_ filesToDelete: inout Set) async -> URL? { + if let cfArgs = cryptoArgs { + let url = getAppFilePath(filePath) + let tempUrl = getTempFilesDirectory().appendingPathComponent(filePath) + _ = filesToDelete.insert(tempUrl) + do { + try decryptCryptoFile(fromPath: url.path, cryptoArgs: cfArgs, toPath: tempUrl.path) + return tempUrl + } catch { + logger.error("Error decrypting file: \(error.localizedDescription)") + } + } + return nil + } + + public func decryptedGet() -> URL? { + let decrypted = CryptoFile.decryptedUrls[filePath] + return if let decrypted = decrypted, FileManager.default.fileExists(atPath: decrypted.path) { decrypted } else { nil } + } + + public func decryptedGetOrCreate(_ filesToDelete: inout Set) async -> URL? { + if let decrypted = decryptedGet() { + return decrypted + } else if let decrypted = await decryptToTmpFile(&filesToDelete) { + CryptoFile.decryptedUrls[filePath] = decrypted + return decrypted + } else { + return nil + } + } + + static var decryptedUrls = Dictionary() } public struct CryptoFileArgs: Codable {