mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 15:07:32 +00:00
517 lines
20 KiB
Swift
517 lines
20 KiB
Swift
//
|
|
// ImageUtils.swift
|
|
// SimpleX (iOS)
|
|
//
|
|
// Created by Evgeny on 24/12/2022.
|
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftUI
|
|
import AVKit
|
|
import SwiftyGif
|
|
import LinkPresentation
|
|
|
|
public func getLoadedFileSource(_ file: CIFile?) -> CryptoFile? {
|
|
if let file = file, file.loaded {
|
|
return file.fileSource
|
|
}
|
|
return nil
|
|
}
|
|
|
|
public func getLoadedImage(_ file: CIFile?) -> UIImage? {
|
|
if let fileSource = getLoadedFileSource(file) {
|
|
let filePath = getAppFilePath(fileSource.filePath)
|
|
do {
|
|
let data = try getFileData(filePath, fileSource.cryptoArgs)
|
|
guard isImageFile(data) else { return nil }
|
|
let img = UIImage(data: data)
|
|
do {
|
|
try img?.setGifFromData(data, levelOfIntegrity: 1.0)
|
|
return img
|
|
} catch {
|
|
return UIImage(data: data)
|
|
}
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
public enum ImageFileType: CaseIterable {
|
|
case jpeg
|
|
case png
|
|
case gif
|
|
case webp
|
|
|
|
var signatures: [[UInt8?]] {
|
|
switch self {
|
|
case .jpeg: [[0xFF, 0xD8, 0xFF]]
|
|
case .png: [[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]]
|
|
case .gif: [ascii("GIF87a"), ascii("GIF89a")]
|
|
case .webp: [ascii("RIFF") + [nil, nil, nil, nil] + ascii("WEBP")]
|
|
}
|
|
}
|
|
|
|
public func matches(_ data: Data) -> Bool {
|
|
signatures.contains { signature in
|
|
data.count >= signature.count && zip(signature, data).allSatisfy { b, d in b == nil || b == d }
|
|
}
|
|
}
|
|
}
|
|
|
|
public func isImageFile(_ data: Data) -> Bool {
|
|
ImageFileType.allCases.contains { $0.matches(data) }
|
|
}
|
|
|
|
private func ascii(_ s: String) -> [UInt8?] {
|
|
s.utf8.map { $0 }
|
|
}
|
|
|
|
public 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)
|
|
}
|
|
}
|
|
|
|
public func getLoadedVideo(_ file: CIFile?) -> URL? {
|
|
if let fileSource = getLoadedFileSource(file) {
|
|
let filePath = getAppFilePath(fileSource.filePath)
|
|
if FileManager.default.fileExists(atPath: filePath.path) {
|
|
return filePath
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
public func saveAnimImage(_ image: UIImage) -> CryptoFile? {
|
|
let fileName = generateNewFileName("IMG", "gif")
|
|
guard let imageData = image.imageData else { return nil }
|
|
return saveFile(imageData, fileName, encrypted: privacyEncryptLocalFilesGroupDefault.get())
|
|
}
|
|
|
|
public 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, encrypted: privacyEncryptLocalFilesGroupDefault.get())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
public func saveImageFile(_ url: URL) -> CryptoFile? {
|
|
var cPath = url.path.cString(using: .utf8)!
|
|
guard let data = Data(base64Encoded: fromCString(chat_resize_image_file(&cPath, Int32(MAX_IMAGE_SIZE), -1)!)), !data.isEmpty else { return nil }
|
|
let ext = data.starts(with: [0x89, 0x50, 0x4E, 0x47]) ? "png" : "jpg"
|
|
return saveFile(data, generateNewFileName("IMG", ext), encrypted: privacyEncryptLocalFilesGroupDefault.get())
|
|
}
|
|
|
|
public func resizeImageFileToStrSize(_ url: URL, maxDataSize: Int64) -> String? {
|
|
var cPath = url.path.cString(using: .utf8)!
|
|
let str = fromCString(chat_resize_image_file_uri(&cPath, Int32(maxDataSize))!)
|
|
return str.isEmpty ? nil : str
|
|
}
|
|
|
|
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), hasAlpha: imageHasAlpha(image))
|
|
}
|
|
|
|
public func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64, hasAlpha: Bool) -> Data? {
|
|
let data = resizeImageWithCore(image, hasAlpha: hasAlpha) {
|
|
chat_resize_image($0, $1, Int32(maxDataSize), hasAlpha ? 1 : 0)
|
|
}.flatMap { Data(base64Encoded: $0) }
|
|
logger.debug("resizeImageToDataSize final \(data?.count ?? 0)")
|
|
return data
|
|
}
|
|
|
|
public func resizeImageToStrSizeSync(_ image: UIImage, maxDataSize: Int64) -> String? {
|
|
let str = resizeImageWithCore(image, hasAlpha: imageHasAlpha(image)) {
|
|
chat_resize_image_uri($0, $1, Int32(maxDataSize))
|
|
}
|
|
logger.debug("resizeImageToStrSize final \(str?.count ?? 0)")
|
|
return str
|
|
}
|
|
|
|
public func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) async -> String? {
|
|
resizeImageToStrSizeSync(image, maxDataSize: maxDataSize)
|
|
}
|
|
|
|
private func resizeImageWithCore(_ image: UIImage, hasAlpha: Bool, _ resize: (UnsafeMutablePointer<CChar>, Int32) -> UnsafeMutablePointer<CChar>?) -> String? {
|
|
let bounds = CGRect(origin: .zero, size: image.size)
|
|
let img = image.imageOrientation == .up ? image : resizeImage(image, newBounds: bounds, drawIn: bounds, hasAlpha: hasAlpha)
|
|
guard var data = hasAlpha ? img.pngData() : img.jpegData(compressionQuality: 0.85) else { return nil }
|
|
let str = data.withUnsafeMutableBytes { fromCString(resize($0.baseAddress!.assumingMemoryBound(to: CChar.self), Int32($0.count))!) }
|
|
return str.isEmpty ? nil : str
|
|
}
|
|
|
|
public func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect, hasAlpha: Bool) -> UIImage {
|
|
let format = UIGraphicsImageRendererFormat()
|
|
format.scale = 1.0
|
|
format.opaque = !hasAlpha
|
|
return UIGraphicsImageRenderer(bounds: newBounds, format: format).image { _ in
|
|
image.draw(in: drawIn)
|
|
}
|
|
}
|
|
|
|
public func imageHasAlpha(_ img: UIImage) -> Bool {
|
|
if let cgImage = img.cgImage {
|
|
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
|
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)
|
|
if let context = CGContext(data: nil, width: cgImage.width, height: cgImage.height, bitsPerComponent: 8, bytesPerRow: cgImage.width * 4, space: colorSpace, bitmapInfo: bitmapInfo.rawValue) {
|
|
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height))
|
|
if let data = context.data {
|
|
let data = data.assumingMemoryBound(to: UInt8.self)
|
|
let size = cgImage.width * cgImage.height * 4
|
|
var i = 0
|
|
while i < size {
|
|
if data[i] < 255 { return true }
|
|
i += 4
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/// Reduces image size, while consuming less RAM
|
|
///
|
|
/// Used by ShareExtension to downsize large images
|
|
/// before passing them to regular image processing pipeline
|
|
/// to avoid exceeding 120MB memory
|
|
///
|
|
/// - Parameters:
|
|
/// - url: Location of the image data
|
|
/// - size: Maximum dimension (width or height)
|
|
/// - Returns: Downsampled image or `nil`, if the image can't be located
|
|
public func downsampleImage(at url: URL, to size: Int64) -> UIImage? {
|
|
autoreleasepool {
|
|
if let source = CGImageSourceCreateWithURL(url as CFURL, nil) {
|
|
CGImageSourceCreateThumbnailAtIndex(
|
|
source,
|
|
0,
|
|
[
|
|
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
|
kCGImageSourceShouldCacheImmediately: true,
|
|
kCGImageSourceCreateThumbnailWithTransform: true,
|
|
kCGImageSourceThumbnailMaxPixelSize: String(size) as CFString
|
|
] as CFDictionary
|
|
)
|
|
.map { UIImage(cgImage: $0) }
|
|
} else { nil }
|
|
}
|
|
}
|
|
|
|
public func saveFileFromURL(_ url: URL) -> CryptoFile? {
|
|
let encrypted = privacyEncryptLocalFilesGroupDefault.get()
|
|
let savedFile: CryptoFile?
|
|
if url.startAccessingSecurityScopedResource() {
|
|
do {
|
|
let fileName = uniqueCombine(url.lastPathComponent)
|
|
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
|
|
}
|
|
} else {
|
|
logger.error("FileUtils.saveFileFromURL startAccessingSecurityScopedResource returned false")
|
|
savedFile = nil
|
|
}
|
|
url.stopAccessingSecurityScopedResource()
|
|
return savedFile
|
|
}
|
|
|
|
public func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
|
|
do {
|
|
let encrypted = privacyEncryptLocalFilesGroupDefault.get()
|
|
let fileName = uniqueCombine(url.lastPathComponent)
|
|
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)
|
|
}
|
|
return savedFile
|
|
} catch {
|
|
logger.error("ImageUtils.moveTempFileFromURL error: \(error.localizedDescription)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
public func saveWallpaperFile(url: URL) -> String? {
|
|
let destFile = URL(fileURLWithPath: generateNewFileName(getWallpaperDirectory().path + "/" + "wallpaper", "jpg", fullPath: true))
|
|
do {
|
|
try FileManager.default.copyItem(atPath: url.path, toPath: destFile.path)
|
|
return destFile.lastPathComponent
|
|
} catch {
|
|
logger.error("FileUtils.saveWallpaperFile error: \(error.localizedDescription)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
public func saveWallpaperFile(image: UIImage) -> String? {
|
|
let hasAlpha = imageHasAlpha(image)
|
|
let destFile = URL(fileURLWithPath: generateNewFileName(getWallpaperDirectory().path + "/" + "wallpaper", hasAlpha ? "png" : "jpg", fullPath: true))
|
|
guard let dataResized = resizeImageToDataSize(image, maxDataSize: 5_000_000, hasAlpha: hasAlpha) else { return nil }
|
|
do {
|
|
try dataResized.write(to: destFile)
|
|
return destFile.lastPathComponent
|
|
} catch {
|
|
logger.error("FileUtils.saveWallpaperFile error: \(error.localizedDescription)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
public func removeWallpaperFile(fileName: String? = nil) {
|
|
do {
|
|
try FileManager.default.contentsOfDirectory(at: URL(fileURLWithPath: getWallpaperDirectory().path), includingPropertiesForKeys: nil, options: []).forEach { url in
|
|
if url.lastPathComponent == fileName {
|
|
try FileManager.default.removeItem(at: url)
|
|
}
|
|
}
|
|
} catch {
|
|
logger.error("FileUtils.removeWallpaperFile error: \(error)")
|
|
}
|
|
if let fileName {
|
|
WallpaperType.cachedImages.removeValue(forKey: fileName)
|
|
}
|
|
}
|
|
|
|
public func removeWallpaperFilesFromTheme(_ theme: ThemeModeOverrides?) {
|
|
if let theme {
|
|
removeWallpaperFile(fileName: theme.light?.wallpaper?.imageFile)
|
|
removeWallpaperFile(fileName: theme.dark?.wallpaper?.imageFile)
|
|
}
|
|
}
|
|
|
|
public func generateNewFileName(_ prefix: String, _ ext: String, fullPath: Bool = false) -> String {
|
|
uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)", fullPath: fullPath)
|
|
}
|
|
|
|
private func uniqueCombine(_ fileName: String, fullPath: Bool = false) -> 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 = ext.isEmpty ? "\(name)\(suffix)" : "\(name)\(suffix).\(ext)"
|
|
return (FileManager.default.fileExists(atPath: fullPath ? f : 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")
|
|
df.timeZone = TimeZone(secondsFromGMT: 0)
|
|
tsFormatter = df
|
|
}
|
|
return df.string(from: Date())
|
|
}
|
|
|
|
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 makeVideoQualityLower(_ input: URL, outputUrl: URL) async -> Bool {
|
|
let asset: AVURLAsset = AVURLAsset(url: input, options: nil)
|
|
if let s = AVAssetExportSession(asset: asset, presetName: AVAssetExportPreset640x480) {
|
|
s.outputURL = outputUrl
|
|
s.outputFileType = .mp4
|
|
s.metadataItemFilter = AVMetadataItemFilter.forSharing()
|
|
await s.export()
|
|
if let err = s.error {
|
|
logger.error("Failed to export video with error: \(err)")
|
|
}
|
|
return s.status == .completed
|
|
}
|
|
return false
|
|
}
|
|
|
|
extension AVAsset {
|
|
public func generatePreview() -> (UIImage, Int)? {
|
|
let generator = AVAssetImageGenerator(asset: self)
|
|
generator.appliesPreferredTrackTransform = true
|
|
var actualTime = CMTimeMake(value: 0, timescale: 0)
|
|
if let image = try? generator.copyCGImage(at: CMTimeMakeWithSeconds(0.0, preferredTimescale: 1), actualTime: &actualTime) {
|
|
return (UIImage(cgImage: image), Int(duration.seconds))
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
extension UIImage {
|
|
public func replaceColor(_ from: UIColor, _ to: UIColor) -> UIImage {
|
|
if let cgImage = cgImage {
|
|
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
|
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)
|
|
if let context = CGContext(data: nil, width: cgImage.width, height: cgImage.height, bitsPerComponent: 8, bytesPerRow: cgImage.width * 4, space: colorSpace, bitmapInfo: bitmapInfo.rawValue) {
|
|
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height))
|
|
if let data = context.data {
|
|
var fromAlpha: CGFloat = 0
|
|
var fromRed: CGFloat = 0
|
|
var fromGreen: CGFloat = 0
|
|
var fromBlue: CGFloat = 0
|
|
var toAlpha: CGFloat = 0
|
|
var toRed: CGFloat = 0
|
|
var toGreen: CGFloat = 0
|
|
var toBlue: CGFloat = 0
|
|
from.getRed(&fromRed, green: &fromGreen, blue: &fromBlue, alpha: &fromAlpha)
|
|
to.getRed(&toRed, green: &toGreen, blue: &toBlue, alpha: &toAlpha)
|
|
let fAlpha = UInt8(UInt8(fromAlpha * 255))
|
|
let fRed = UInt8(fromRed * 255)
|
|
let fGreen = UInt8(fromGreen * 255)
|
|
let fBlue = UInt8(fromBlue * 255)
|
|
let tAlpha = UInt8(toAlpha * 255)
|
|
let tRed = UInt8(toRed * 255)
|
|
let tGreen = UInt8(toGreen * 255)
|
|
let tBlue = UInt8(toBlue * 255)
|
|
let data = data.assumingMemoryBound(to: UInt8.self)
|
|
let size = cgImage.width * cgImage.height * 4
|
|
var i = 0
|
|
while i < size {
|
|
if data[i] == fAlpha && data[i + 1] == fRed && data[i + 2] == fGreen && data[i + 3] == fBlue {
|
|
data[i + 0] = tAlpha
|
|
data[i + 1] = tRed
|
|
data[i + 2] = tGreen
|
|
data[i + 3] = tBlue
|
|
}
|
|
i += 4
|
|
}
|
|
}
|
|
if let img = context.makeImage() {
|
|
return UIImage(cgImage: img)
|
|
}
|
|
}
|
|
}
|
|
return self
|
|
}
|
|
}
|
|
|
|
// Max image height/width ratio for chat item display, taller images are cropped
|
|
public func heightRatio(_ size: CGSize) -> CGFloat {
|
|
size.width > 0 ? min(size.height / size.width, 2.33) : 1
|
|
}
|
|
|
|
public func imageFromBase64(_ base64Encoded: String?) -> UIImage? {
|
|
if let base64Encoded {
|
|
if let img = imageCache.object(forKey: base64Encoded as NSString) {
|
|
return img
|
|
} else if let data = Data(base64Encoded: dropImagePrefix(base64Encoded)),
|
|
let img = UIImage(data: data) {
|
|
imageCacheQueue.async {
|
|
imageCache.setObject(img, forKey: base64Encoded as NSString)
|
|
}
|
|
return img
|
|
} else {
|
|
return nil
|
|
}
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private let imageCacheQueue = DispatchQueue.global(qos: .background)
|
|
|
|
private var imageCache: NSCache<NSString, UIImage> = {
|
|
var cache = NSCache<NSString, UIImage>()
|
|
cache.countLimit = 1000
|
|
return cache
|
|
}()
|
|
|
|
public func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) {
|
|
logger.debug("getLinkMetadata: fetching URL preview")
|
|
LPMetadataProvider().startFetchingMetadata(for: url){ metadata, error in
|
|
if let e = error {
|
|
logger.error("Error retrieving link metadata: \(e.localizedDescription)")
|
|
}
|
|
if let metadata = metadata,
|
|
let imageProvider = metadata.imageProvider,
|
|
imageProvider.canLoadObject(ofClass: UIImage.self) {
|
|
imageProvider.loadObject(ofClass: UIImage.self){ object, error in
|
|
var linkPreview: LinkPreview? = nil
|
|
if let error = error {
|
|
logger.error("Couldn't load image preview from link metadata with error: \(error.localizedDescription)")
|
|
} else {
|
|
if let image = object as? UIImage,
|
|
let resized = resizeImageToStrSizeSync(image, maxDataSize: 14000),
|
|
let title = metadata.title,
|
|
let uri = metadata.originalURL {
|
|
linkPreview = LinkPreview(uri: uri.absoluteString, title: title, image: resized)
|
|
}
|
|
}
|
|
cb(linkPreview)
|
|
}
|
|
} else {
|
|
logger.error("Could not load link preview image")
|
|
cb(nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
public func getLinkPreview(for url: URL) async -> LinkPreview? {
|
|
await withCheckedContinuation { cont in
|
|
getLinkPreview(url: url) { cont.resume(returning: $0) }
|
|
}
|
|
}
|
|
|
|
private let squareToCircleRatio = 0.935
|
|
|
|
private let radiusFactor = (1 - squareToCircleRatio) / 50
|
|
|
|
@ViewBuilder public func clipProfileImage(_ img: Image, size: CGFloat, radius: Double, blurred: Bool = false) -> some View {
|
|
if radius >= 50 {
|
|
blurredFrame(img, size, blurred).clipShape(Circle())
|
|
} else if radius <= 0 {
|
|
let sz = size * squareToCircleRatio
|
|
blurredFrame(img, sz, blurred).padding((size - sz) / 2)
|
|
} else {
|
|
let sz = size * (squareToCircleRatio + radius * radiusFactor)
|
|
blurredFrame(img, sz, blurred)
|
|
.clipShape(RoundedRectangle(cornerRadius: sz * radius / 100, style: .continuous))
|
|
.padding((size - sz) / 2)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder private func blurredFrame(_ img: Image, _ size: CGFloat, _ blurred: Bool) -> some View {
|
|
let v = img.resizable().frame(width: size, height: size)
|
|
if blurred {
|
|
v.blur(radius: size / 4)
|
|
} else {
|
|
v
|
|
}
|
|
}
|