ios: local video encryption (#3682)

* ios: local video encryption

* main thread

* new progress view

* simplify

* rename

---------

Co-authored-by: Avently <avently@local>
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
This commit is contained in:
Stanislav Dmitrenko 2024-01-16 18:49:44 +07:00 committed by GitHub
parent d5cf9fbf5b
commit 88640b85c4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 132 additions and 26 deletions

View File

@ -158,7 +158,8 @@ func imageHasAlpha(_ img: UIImage) -> Bool {
return false return false
} }
func saveFileFromURL(_ url: URL, encrypted: Bool) -> CryptoFile? { func saveFileFromURL(_ url: URL) -> CryptoFile? {
let encrypted = privacyEncryptLocalFilesGroupDefault.get()
let savedFile: CryptoFile? let savedFile: CryptoFile?
if url.startAccessingSecurityScopedResource() { if url.startAccessingSecurityScopedResource() {
do { do {
@ -185,10 +186,19 @@ func saveFileFromURL(_ url: URL, encrypted: Bool) -> CryptoFile? {
func moveTempFileFromURL(_ url: URL) -> CryptoFile? { func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
do { do {
let encrypted = privacyEncryptLocalFilesGroupDefault.get()
let fileName = uniqueCombine(url.lastPathComponent) 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) ChatModel.shared.filesToDelete.remove(url)
return CryptoFile.plain(fileName) return savedFile
} catch { } catch {
logger.error("ImageUtils.moveTempFileFromURL error: \(error.localizedDescription)") logger.error("ImageUtils.moveTempFileFromURL error: \(error.localizedDescription)")
return nil return nil

View File

@ -855,8 +855,8 @@ func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws {
try await sendCommandOkResp(.apiChatUnread(type: type, id: id, unreadChat: unreadChat)) try await sendCommandOkResp(.apiChatUnread(type: type, id: id, unreadChat: unreadChat))
} }
func receiveFile(user: any UserLike, fileId: Int64, encrypted: Bool, auto: Bool = false) async { func receiveFile(user: any UserLike, fileId: Int64, auto: Bool = false) async {
if let chatItem = await apiReceiveFile(fileId: fileId, encrypted: encrypted, auto: auto) { if let chatItem = await apiReceiveFile(fileId: fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get(), auto: auto) {
await chatItemSimpleUpdate(user, chatItem) await chatItemSimpleUpdate(user, chatItem)
} }
} }
@ -1516,7 +1516,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
} }
if let file = cItem.autoReceiveFile() { if let file = cItem.autoReceiveFile() {
Task { 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 { if cItem.showNotification {

View File

@ -85,8 +85,7 @@ struct CIFileView: View {
Task { Task {
logger.debug("CIFileView fileAction - in .rcvInvitation, in Task") logger.debug("CIFileView fileAction - in .rcvInvitation, in Task")
if let user = m.currentUser { if let user = m.currentUser {
let encrypted = privacyEncryptLocalFilesGroupDefault.get() await receiveFile(user: user, fileId: file.fileId)
await receiveFile(user: user, fileId: file.fileId, encrypted: encrypted)
} }
} }
} else { } else {

View File

@ -38,7 +38,7 @@ struct CIImageView: View {
case .rcvInvitation: case .rcvInvitation:
Task { Task {
if let user = m.currentUser { if let user = m.currentUser {
await receiveFile(user: user, fileId: file.fileId, encrypted: chatItem.encryptLocalFile) await receiveFile(user: user, fileId: file.fileId)
} }
} }
case .rcvAccepted: case .rcvAccepted:

View File

@ -26,6 +26,8 @@ struct CIVideoView: View {
@State private var player: AVPlayer? @State private var player: AVPlayer?
@State private var fullPlayer: AVPlayer? @State private var fullPlayer: AVPlayer?
@State private var url: URL? @State private var url: URL?
@State private var urlDecrypted: URL?
@State private var decryptionInProgress: Bool = false
@State private var showFullScreenPlayer = false @State private var showFullScreenPlayer = false
@State private var timeObserver: Any? = nil @State private var timeObserver: Any? = nil
@State private var fullScreenTimeObserver: Any? = nil @State private var fullScreenTimeObserver: Any? = nil
@ -39,8 +41,12 @@ struct CIVideoView: View {
self._videoWidth = videoWidth self._videoWidth = videoWidth
self.scrollProxy = scrollProxy self.scrollProxy = scrollProxy
if let url = getLoadedVideo(chatItem.file) { if let url = getLoadedVideo(chatItem.file) {
self._player = State(initialValue: VideoPlayerView.getOrCreatePlayer(url, false)) let decrypted = chatItem.file?.fileSource?.cryptoArgs == nil ? url : chatItem.file?.fileSource?.decryptedGet()
self._fullPlayer = State(initialValue: AVPlayer(url: url)) 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) self._url = State(initialValue: url)
} }
if let data = Data(base64Encoded: dropImagePrefix(image)), if let data = Data(base64Encoded: dropImagePrefix(image)),
@ -53,8 +59,10 @@ struct CIVideoView: View {
let file = chatItem.file let file = chatItem.file
ZStack { ZStack {
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
if let file = file, let preview = preview, let player = player, let url = url { if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
videoView(player, url, file, preview, duration) 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)), } else if let data = Data(base64Encoded: dropImagePrefix(image)),
let uiImage = UIImage(data: data) { let uiImage = UIImage(data: data) {
imageView(uiImage) imageView(uiImage)
@ -62,7 +70,7 @@ struct CIVideoView: View {
if let file = file { if let file = file {
switch file.fileStatus { switch file.fileStatus {
case .rcvInvitation: case .rcvInvitation:
receiveFileIfValidSize(file: file, encrypted: false, receiveFile: receiveFile) receiveFileIfValidSize(file: file, receiveFile: receiveFile)
case .rcvAccepted: case .rcvAccepted:
switch file.fileProtocol { switch file.fileProtocol {
case .xftp: case .xftp:
@ -88,7 +96,7 @@ struct CIVideoView: View {
} }
if let file = file, case .rcvInvitation = file.fileStatus { if let file = file, case .rcvInvitation = file.fileStatus {
Button { Button {
receiveFileIfValidSize(file: file, encrypted: false, receiveFile: receiveFile) receiveFileIfValidSize(file: file, receiveFile: receiveFile)
} label: { } label: {
playPauseIcon("play.fill") 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 { 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 let w = preview.size.width <= preview.size.height ? maxWidth * 0.75 : maxWidth
DispatchQueue.main.async { videoWidth = w } DispatchQueue.main.async { videoWidth = w }
@ -159,6 +201,16 @@ struct CIVideoView: View {
.clipShape(Circle()) .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 { private func durationProgress() -> some View {
HStack { HStack {
Text("\(durationText(videoPlaying ? progress : duration))") Text("\(durationText(videoPlaying ? progress : duration))")
@ -257,10 +309,10 @@ struct CIVideoView: View {
} }
// TODO encrypt: where file size is checked? // 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 { Task {
if let user = m.currentUser { 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) { private func addObserver(_ player: AVPlayer, _ url: URL) {
timeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.01, preferredTimescale: CMTimeScale(NSEC_PER_SEC)), queue: .main) { time in timeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.01, preferredTimescale: CMTimeScale(NSEC_PER_SEC)), queue: .main) { time in
if let item = player.currentItem { if let item = player.currentItem {

View File

@ -221,7 +221,7 @@ struct VoiceMessagePlayer: View {
Button { Button {
Task { Task {
if let user = chatModel.currentUser { if let user = chatModel.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get()) await receiveFile(user: user, fileId: recordingFile.fileId)
} }
} }
} label: { } label: {

View File

@ -689,7 +689,7 @@ struct ComposeView: View {
let file = voiceCryptoFile(recordingFileName) let file = voiceCryptoFile(recordingFileName)
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl) sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl)
case let .filePreview(_, file): 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) sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl)
} }
} }

View File

@ -631,7 +631,7 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? {
ntfBadgeCountGroupDefault.set(max(0, ntfBadgeCountGroupDefault.get() - 1)) ntfBadgeCountGroupDefault.set(max(0, ntfBadgeCountGroupDefault.get() - 1))
} }
if let file = cItem.autoReceiveFile() { 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 let ntf: NSENotification = cInfo.ntfsEnabled ? .nse(createMessageReceivedNtf(user, cInfo, cItem)) : .empty
return cItem.showNotification ? (aChatItem.chatId, ntf) : nil return cItem.showNotification ? (aChatItem.chatId, ntf) : nil
@ -775,7 +775,8 @@ func apiSetFileToReceive(fileId: Int64, encrypted: Bool) {
logger.error("setFileToReceive error: \(responseError(r))") 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 { switch file.fileProtocol {
case .smp: case .smp:
return apiReceiveFile(fileId: file.fileId, encrypted: encrypted)?.chatItem return apiReceiveFile(fileId: file.fileId, encrypted: encrypted)?.chatItem

View File

@ -2242,11 +2242,6 @@ public struct ChatItem: Identifiable, Decodable {
return fileSource.cryptoArgs != nil return fileSource.cryptoArgs != nil
} }
public var encryptLocalFile: Bool {
content.msgContent?.isVideo == false &&
privacyEncryptLocalFilesGroupDefault.get()
}
public var memberDisplayName: String? { public var memberDisplayName: String? {
get { get {
if case let .groupRcv(groupMember) = chatDir { if case let .groupRcv(groupMember) = chatDir {
@ -2910,6 +2905,39 @@ public struct CryptoFile: Codable {
public static func plain(_ f: String) -> CryptoFile { public static func plain(_ f: String) -> CryptoFile {
CryptoFile(filePath: f, cryptoArgs: nil) CryptoFile(filePath: f, cryptoArgs: nil)
} }
private func decryptToTmpFile(_ filesToDelete: inout Set<URL>) 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<URL>) 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<String, URL>()
} }
public struct CryptoFileArgs: Codable { public struct CryptoFileArgs: Codable {