From 12574bed96023344e2c76ab019f6c3c6c7a2dc76 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 24 Dec 2022 11:38:59 +0000 Subject: [PATCH] ios: move image utils to app (#1642) * ios: move image utils to app * name in comments --- apps/ios/Shared/Model/ImageUtils.swift | 189 ++++++++++++++++++ .../ios/SimpleX NSE/NotificationService.swift | 2 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 12 +- apps/ios/SimpleXChat/ChatTypes.swift | 2 +- apps/ios/SimpleXChat/FileUtils.swift | 181 +---------------- 5 files changed, 196 insertions(+), 190 deletions(-) create mode 100644 apps/ios/Shared/Model/ImageUtils.swift diff --git a/apps/ios/Shared/Model/ImageUtils.swift b/apps/ios/Shared/Model/ImageUtils.swift new file mode 100644 index 000000000..6de6dd024 --- /dev/null +++ b/apps/ios/Shared/Model/ImageUtils.swift @@ -0,0 +1,189 @@ +// +// ImageUtils.swift +// SimpleX (iOS) +// +// Created by Evgeny on 24/12/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import SimpleXChat +import SwiftUI + +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 + } + return nil +} + +func getLoadedImage(_ file: CIFile?) -> UIImage? { + let loadedFilePath = getLoadedFilePath(file) + if let loadedFilePath = loadedFilePath, let fileName = file?.filePath { + let filePath = getAppFilePath(fileName) + do { + let data = try Data(contentsOf: filePath) + let img = UIImage(data: data) + try img?.setGifFromData(data, levelOfIntegrity: 1.0) + return img + } catch { + return UIImage(contentsOfFile: loadedFilePath) + } + } + return nil +} + +func saveAnimImage(_ image: UIImage) -> String? { + let fileName = generateNewFileName("IMG", "gif") + guard let imageData = image.imageData else { return nil } + return saveFile(imageData, fileName) +} + +func saveImage(_ uiImage: UIImage) -> String? { + if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE) { + let ext = imageHasAlpha(uiImage) ? "png" : "jpg" + let fileName = generateNewFileName("IMG", ext) + return saveFile(imageDataResized, fileName) + } + return nil +} + +func cropToSquare(_ image: UIImage) -> UIImage { + let size = image.size + let side = min(size.width, size.height) + let newSize = CGSize(width: side, height: side) + var origin = CGPoint.zero + if size.width > side { + origin.x -= (size.width - side) / 2 + } else if size.height > side { + origin.y -= (size.height - side) / 2 + } + return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size)) +} + +func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64) -> Data? { + var img = image + let usePng = imageHasAlpha(image) + var data = usePng ? img.pngData() : img.jpegData(compressionQuality: 0.85) + var dataSize = data?.count ?? 0 + while dataSize != 0 && dataSize > maxDataSize { + let ratio = sqrt(Double(dataSize) / Double(maxDataSize)) + let clippedRatio = min(ratio, 2.0) + img = reduceSize(img, ratio: clippedRatio) + data = usePng ? img.pngData() : img.jpegData(compressionQuality: 0.85) + dataSize = data?.count ?? 0 + } + logger.debug("resizeImageToDataSize final \(dataSize)") + return data +} + +func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String? { + var img = image + var str = compressImageStr(img) + var dataSize = str?.count ?? 0 + while dataSize != 0 && dataSize > maxDataSize { + let ratio = sqrt(Double(dataSize) / Double(maxDataSize)) + let clippedRatio = min(ratio, 2.0) + img = reduceSize(img, ratio: clippedRatio) + str = compressImageStr(img) + dataSize = str?.count ?? 0 + } + logger.debug("resizeImageToStrSize final \(dataSize)") + return str +} + +func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85) -> String? { + let ext = imageHasAlpha(image) ? "png" : "jpg" + if let data = imageHasAlpha(image) ? image.pngData() : image.jpegData(compressionQuality: compressionQuality) { + return "data:image/\(ext);base64,\(data.base64EncodedString())" + } + return nil +} + +private func reduceSize(_ image: UIImage, ratio: CGFloat) -> UIImage { + let newSize = CGSize(width: floor(image.size.width / ratio), height: floor(image.size.height / ratio)) + let bounds = CGRect(origin: .zero, size: newSize) + return resizeImage(image, newBounds: bounds, drawIn: bounds) +} + +private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect) -> UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = 1.0 + format.opaque = !imageHasAlpha(image) + return UIGraphicsImageRenderer(bounds: newBounds, format: format).image { _ in + image.draw(in: drawIn) + } +} + +func imageHasAlpha(_ img: UIImage) -> Bool { + let alpha = img.cgImage?.alphaInfo + return alpha == .first || alpha == .last || alpha == .premultipliedFirst || alpha == .premultipliedLast || alpha == .alphaOnly +} + +func saveFileFromURL(_ url: URL) -> String? { + let savedFile: String? + if url.startAccessingSecurityScopedResource() { + do { + let fileData = try Data(contentsOf: url) + let fileName = uniqueCombine(url.lastPathComponent) + savedFile = saveFile(fileData, fileName) + } catch { + logger.error("FileUtils.saveFileFromURL error: \(error.localizedDescription)") + savedFile = nil + } + } else { + logger.error("FileUtils.saveFileFromURL startAccessingSecurityScopedResource returned false") + savedFile = nil + } + url.stopAccessingSecurityScopedResource() + return savedFile +} + +func generateNewFileName(_ prefix: String, _ ext: String) -> String { + let fileName = uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)") + return fileName +} + +private func uniqueCombine(_ fileName: String) -> String { + func tryCombine(_ fileName: String, _ n: Int) -> String { + let ns = fileName as NSString + let name = ns.deletingPathExtension + let ext = ns.pathExtension + let suffix = (n == 0) ? "" : "_\(n)" + let f = "\(name)\(suffix).\(ext)" + return (FileManager.default.fileExists(atPath: getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f + } + return tryCombine(fileName, 0) +} + +private var tsFormatter: DateFormatter? + +private func getTimestamp() -> String { + var df: DateFormatter + if let tsFormatter = tsFormatter { + df = tsFormatter + } else { + df = DateFormatter() + df.dateFormat = "yyyyMMdd_HHmmss" + df.locale = Locale(identifier: "US") + tsFormatter = df + } + return df.string(from: Date()) +} + +func dropImagePrefix(_ s: String) -> String { + dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,") +} + +private func dropPrefix(_ s: String, _ prefix: String) -> String { + s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s +} diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index c2d81ded4..7d6c6c485 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -218,7 +218,7 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, UNMutableNotification var cItem = aChatItem.chatItem if case .image = cItem.content.msgContent { if let file = cItem.file, - file.fileSize <= MAX_IMAGE_SIZE, + file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV, privacyAcceptImagesGroupDefault.get() { let inline = privacyTransferImagesInlineGroupDefault.get() cItem = apiReceiveFile(fileId: file.fileId, inline: inline)?.chatItem ?? cItem diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index d44cc6aa1..4c5b691e3 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -100,6 +100,7 @@ 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E027A867BA00ACCCDD /* UserProfile.swift */; }; 5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E327A8683A00ACCCDD /* UserAddress.swift */; }; 5CB9250D27A9432000ACCCDD /* ChatListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */; }; + 5CBD285A295711D700EC2CF4 /* ImageUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBD2859295711D700EC2CF4 /* ImageUtils.swift */; }; 5CBE6C12294487F7002D9531 /* VerifyCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */; }; 5CBE6C142944CC12002D9531 /* ScanCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBE6C132944CC12002D9531 /* ScanCodeView.swift */; }; 5CC1C99227A6C7F5000D9FF6 /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */; }; @@ -159,7 +160,6 @@ 64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */; }; D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; }; D77B92DC2952372200A5A1CC /* SwiftyGif in Frameworks */ = {isa = PBXBuildFile; productRef = D77B92DB2952372200A5A1CC /* SwiftyGif */; }; - D77B92DE29523E1700A5A1CC /* SwiftyGif in Frameworks */ = {isa = PBXBuildFile; productRef = D77B92DD29523E1700A5A1CC /* SwiftyGif */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -324,6 +324,7 @@ 5CBD285629565CAE00EC2CF4 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; 5CBD285729565D2600EC2CF4 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = "fr.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5CBD285829565D2600EC2CF4 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = ""; }; + 5CBD2859295711D700EC2CF4 /* ImageUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageUtils.swift; sourceTree = ""; }; 5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerifyCodeView.swift; sourceTree = ""; }; 5CBE6C132944CC12002D9531 /* ScanCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanCodeView.swift; sourceTree = ""; }; 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCode.swift; sourceTree = ""; }; @@ -417,7 +418,6 @@ files = ( 5C70311F2955080A00150A12 /* libHSsimplex-chat-4.4.0-AHPp9UIBWT5C2IlT3cD6QO.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - D77B92DE29523E1700A5A1CC /* SwiftyGif in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, 5C70311D2955080A00150A12 /* libgmp.a in Frameworks */, 5C70311E2955080A00150A12 /* libffi.a in Frameworks */, @@ -509,6 +509,7 @@ 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */, 5CB346E82869E8BA001FD2EF /* PushEnvironment.swift */, 5C93293E2928E0FD0090FFF9 /* AudioRecPlay.swift */, + 5CBD2859295711D700EC2CF4 /* ImageUtils.swift */, ); path = Model; sourceTree = ""; @@ -839,7 +840,6 @@ ); name = SimpleXChat; packageProductDependencies = ( - D77B92DD29523E1700A5A1CC /* SwiftyGif */, ); productName = SimpleXChat; productReference = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; @@ -960,6 +960,7 @@ 6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */, 5C93293129239BED0090FFF9 /* SMPServerView.swift in Sources */, 5C9CC7AD28C55D7800BEF955 /* DatabaseEncryptionView.swift in Sources */, + 5CBD285A295711D700EC2CF4 /* ImageUtils.swift in Sources */, 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */, 5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */, 5C029EAA283942EA004A9677 /* CallController.swift in Sources */, @@ -1645,11 +1646,6 @@ package = D77B92DA2952372200A5A1CC /* XCRemoteSwiftPackageReference "SwiftyGif" */; productName = SwiftyGif; }; - D77B92DD29523E1700A5A1CC /* SwiftyGif */ = { - isa = XCSwiftPackageProductDependency; - package = D77B92DA2952372200A5A1CC /* XCRemoteSwiftPackageReference "SwiftyGif" */; - productName = SwiftyGif; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 5CA059BE279559F40002BEB4 /* Project object */; diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 4438e0544..98cc0bbaf 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2070,7 +2070,7 @@ public struct CIFile: Decodable { CIFile(fileId: fileId, fileName: fileName, fileSize: fileSize, filePath: filePath, fileStatus: fileStatus) } - var loaded: Bool { + public var loaded: Bool { get { switch self.fileStatus { case .sndStored: return true diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index 99722b28b..7df65f244 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -7,9 +7,7 @@ // import Foundation -import SwiftUI import OSLog -import SwiftyGif let logger = Logger() @@ -168,88 +166,7 @@ public func getAppFilePath(_ fileName: String) -> URL { getAppFilesDirectory().appendingPathComponent(fileName) } -public func getLoadedFileName(_ file: CIFile?) -> String? { - if let file = file, - file.loaded, - let fileName = file.filePath { - return fileName - } - return nil -} - -public func getLoadedFilePath(_ file: CIFile?) -> String? { - if let fileName = getLoadedFileName(file) { - return getAppFilePath(fileName).path - } - return nil -} - -public func getLoadedImage(_ file: CIFile?) -> UIImage? { - let loadedFilePath = getLoadedFilePath(file) - if let loadedFilePath = loadedFilePath, let fileName = file?.filePath { - let filePath = getAppFilePath(fileName) - do { - let data = try Data(contentsOf: filePath) - let img = UIImage(data: data) - try img?.setGifFromData(data, levelOfIntegrity: 1.0) - return img - } catch { - return UIImage(contentsOfFile: loadedFilePath) - } - } - return nil -} - -public func saveFileFromURL(_ url: URL) -> String? { - let savedFile: String? - if url.startAccessingSecurityScopedResource() { - do { - let fileData = try Data(contentsOf: url) - let fileName = uniqueCombine(url.lastPathComponent) - savedFile = saveFile(fileData, fileName) - } catch { - logger.error("FileUtils.saveFileFromURL error: \(error.localizedDescription)") - savedFile = nil - } - } else { - logger.error("FileUtils.saveFileFromURL startAccessingSecurityScopedResource returned false") - savedFile = nil - } - url.stopAccessingSecurityScopedResource() - return savedFile -} - -public func saveImage(_ uiImage: UIImage) -> String? { - if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE) { - let ext = uiImage.hasAlpha() ? "png" : "jpg" - let fileName = generateNewFileName("IMG", ext) - return saveFile(imageDataResized, fileName) - } - return nil -} - -public func saveAnimImage(_ image: UIImage) -> String? { - let fileName = generateNewFileName("IMG", "gif") - guard let imageData = image.imageData else { return nil } - return saveFile(imageData, fileName) -} - -public func generateNewFileName(_ prefix: String, _ ext: String) -> String { - let timestamp = Date().getFormattedDate("yyyyMMdd_HHmmss") - let fileName = uniqueCombine("\(prefix)_\(timestamp).\(ext)") - return fileName -} - -extension Date { - func getFormattedDate(_ format: String) -> String { - let df = DateFormatter() - df.dateFormat = format - df.locale = Locale(identifier: "US") - return df.string(from: self) - } -} - -private func saveFile(_ data: Data, _ fileName: String) -> String? { +public func saveFile(_ data: Data, _ fileName: String) -> String? { let filePath = getAppFilePath(fileName) do { try data.write(to: filePath) @@ -260,18 +177,6 @@ private func saveFile(_ data: Data, _ fileName: String) -> String? { } } -private func uniqueCombine(_ fileName: String) -> String { - func tryCombine(_ fileName: String, _ n: Int) -> String { - let ns = fileName as NSString - let name = ns.deletingPathExtension - let ext = ns.pathExtension - let suffix = (n == 0) ? "" : "_\(n)" - let f = "\(name)\(suffix).\(ext)" - return (FileManager.default.fileExists(atPath: getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f - } - return tryCombine(fileName, 0) -} - public func removeFile(_ fileName: String) { do { try FileManager.default.removeItem(atPath: getAppFilePath(fileName).path) @@ -279,87 +184,3 @@ public func removeFile(_ fileName: String) { logger.error("FileUtils.removeFile error: \(error.localizedDescription)") } } - -// image utils - -public func dropImagePrefix(_ s: String) -> String { - dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,") -} - -private func dropPrefix(_ s: String, _ prefix: String) -> String { - s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s -} - -public func cropToSquare(_ image: UIImage) -> UIImage { - let size = image.size - let side = min(size.width, size.height) - let newSize = CGSize(width: side, height: side) - var origin = CGPoint.zero - if size.width > side { - origin.x -= (size.width - side) / 2 - } else if size.height > side { - origin.y -= (size.height - side) / 2 - } - return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size)) -} - -func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64) -> Data? { - var img = image - let usePng = image.hasAlpha() - var data = usePng ? img.pngData() : img.jpegData(compressionQuality: 0.85) - var dataSize = data?.count ?? 0 - while dataSize != 0 && dataSize > maxDataSize { - let ratio = sqrt(Double(dataSize) / Double(maxDataSize)) - let clippedRatio = min(ratio, 2.0) - img = reduceSize(img, ratio: clippedRatio) - data = usePng ? img.pngData() : img.jpegData(compressionQuality: 0.85) - dataSize = data?.count ?? 0 - } - logger.debug("resizeImageToDataSize final \(dataSize)") - return data -} - -public func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String? { - var img = image - var str = compressImageStr(img) - var dataSize = str?.count ?? 0 - while dataSize != 0 && dataSize > maxDataSize { - let ratio = sqrt(Double(dataSize) / Double(maxDataSize)) - let clippedRatio = min(ratio, 2.0) - img = reduceSize(img, ratio: clippedRatio) - str = compressImageStr(img) - dataSize = str?.count ?? 0 - } - logger.debug("resizeImageToStrSize final \(dataSize)") - return str -} - -func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85) -> String? { - let ext = image.hasAlpha() ? "png" : "jpg" - if let data = image.hasAlpha() ? image.pngData() : image.jpegData(compressionQuality: compressionQuality) { - return "data:image/\(ext);base64,\(data.base64EncodedString())" - } - return nil -} - -private func reduceSize(_ image: UIImage, ratio: CGFloat) -> UIImage { - let newSize = CGSize(width: floor(image.size.width / ratio), height: floor(image.size.height / ratio)) - let bounds = CGRect(origin: .zero, size: newSize) - return resizeImage(image, newBounds: bounds, drawIn: bounds) -} - -private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect) -> UIImage { - let format = UIGraphicsImageRendererFormat() - format.scale = 1.0 - format.opaque = !image.hasAlpha() - return UIGraphicsImageRenderer(bounds: newBounds, format: format).image { _ in - image.draw(in: drawIn) - } -} - -extension UIImage { - func hasAlpha() -> Bool { - let alpha = cgImage?.alphaInfo - return alpha == .first || alpha == .last || alpha == .premultipliedFirst || alpha == .premultipliedLast || alpha == .alphaOnly - } -}