// // Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import CoreGraphics import LibSignalClient import SignalServiceKit #if USE_DEBUG_UI enum DebugUIError: Error { case downloadFailed case imageGenerationFailed case fileCreationFailed case invalidMimeType case unknownFileExtension } class DebugUIMessagesAssetLoader { typealias Completion = (Result) -> Void let filename: String let mimeType: String private(set) var prepare: ((@escaping Completion) -> Void)? private(set) var filePath: String? // MARK: - Public static let jpegInstance: DebugUIMessagesAssetLoader = .fakeAssetLoaderWithUrl( "https://s3.amazonaws.com/ows-data/example_attachment_media/random-jpg.JPG", mimeType: MimeType.imageJpeg.rawValue ) static let gifInstance: DebugUIMessagesAssetLoader = .fakeAssetLoaderWithUrl( "https://s3.amazonaws.com/ows-data/example_attachment_media/random-gif.gif", mimeType: MimeType.imageGif.rawValue ) static let largeGifInstance: DebugUIMessagesAssetLoader = .fakeAssetLoaderWithUrl( "https://i.giphy.com/media/LTw0F3GAdaao8/source.gif", mimeType: MimeType.imageGif.rawValue ) static let mp3Instance: DebugUIMessagesAssetLoader = .fakeAssetLoaderWithUrl( "https://s3.amazonaws.com/ows-data/example_attachment_media/random-mp3.mp3", mimeType: "audio/mp3" ) static let mp4Instance: DebugUIMessagesAssetLoader = .fakeAssetLoaderWithUrl( "https://s3.amazonaws.com/ows-data/example_attachment_media/random-mp4.mp4", mimeType: "video/mp4" ) static let compactPortraitPngInstance: DebugUIMessagesAssetLoader = .fakePngAssetLoaderWithImageSize( .init(width: 60, height: 100), backgroundColor: .blue, textColor: .white, label: "P" ) static let compactLandscapePngInstance: DebugUIMessagesAssetLoader = .fakePngAssetLoaderWithImageSize( .init(width: 100, height: 60), backgroundColor: .green, textColor: .white, label: "L" ) static let tallPortraitPngInstance: DebugUIMessagesAssetLoader = .fakePngAssetLoaderWithImageSize( .init(width: 10, height: 100), backgroundColor: .yellow, textColor: .white, label: "P" ) static let wideLandscapePngInstance: DebugUIMessagesAssetLoader = .fakePngAssetLoaderWithImageSize( .init(width: 100, height: 10), backgroundColor: .purple, textColor: .white, label: "L" ) static let largePngInstance: DebugUIMessagesAssetLoader = .fakePngAssetLoaderWithImageSize( .square(4000), backgroundColor: .brown, textColor: .white, label: "B" ) static let tinyPngInstance: DebugUIMessagesAssetLoader = .fakePngAssetLoaderWithImageSize( .square(2), backgroundColor: .cyan, textColor: .white, label: "T" ) static func pngInstance(size: CGSize, backgroundColor: UIColor, textColor: UIColor, label: String) -> DebugUIMessagesAssetLoader { return .fakePngAssetLoaderWithImageSize(size, backgroundColor: backgroundColor, textColor: textColor, label: label) } static let mediumFilesizePngInstance: DebugUIMessagesAssetLoader = .fakeNoisePngAssetLoaderWithImageSize(1000) static let tinyPdfInstance: DebugUIMessagesAssetLoader = .fakeRandomAssetLoaderWithDataLength(256, mimeType: MimeType.applicationPdf.rawValue)! static let largePdfInstance: DebugUIMessagesAssetLoader = .fakeRandomAssetLoaderWithDataLength(4 * 1024 * 1024, mimeType: MimeType.applicationPdf.rawValue)! static let missingPngInstance: DebugUIMessagesAssetLoader = .fakeMissingAssetLoaderWithMimeType(MimeType.imagePng.rawValue)! static let missingPdfInstance: DebugUIMessagesAssetLoader = .fakeMissingAssetLoaderWithMimeType(MimeType.applicationPdf.rawValue)! static let oversizeTextInstance: DebugUIMessagesAssetLoader = .fakeOversizeTextAssetLoader(text: nil) static func oversizeTextInstance(text: String) -> DebugUIMessagesAssetLoader { return .fakeOversizeTextAssetLoader(text: text) } static func prepareAssetLoaders( _ assetLoaders: [DebugUIMessagesAssetLoader], completion: @escaping Completion ) { var promises = [AnyPromise]() assetLoaders.forEach { assetLoader in // Use chained promises to make the code more readable. let promise = AnyPromise { future in assetLoader.prepare!({ result in switch result { case .success: future.resolve(value: Void()) case .failure(let error): future.reject(error: error) } }) } promises.append(promise) } AnyPromise.when(resolved: promises) .done { _ in completion(.success(())) }.catch { error in completion(.failure(error)) } } // MARK: - Private private init(filename: String, mimeType: String) { self.filename = filename self.mimeType = mimeType } // MARK: - private static func fakeAssetLoaderWithUrl(_ urlString: String, mimeType: String) -> DebugUIMessagesAssetLoader! { guard let url = URL(string: urlString), !mimeType.isEmpty else { return nil } let assetLoader = DebugUIMessagesAssetLoader(filename: url.lastPathComponent, mimeType: mimeType) assetLoader.prepare = { [weak assetLoader] completion in assetLoader?.ensureURLAssetLoaded(url, completion: completion) } return assetLoader } private func ensureURLAssetLoaded(_ url: URL, completion: @escaping Completion) { guard filePath == nil else { completion(.success(())) return } // Use a predictable file path so that we reuse the cache between app launches. let temporaryDirectory = OWSTemporaryDirectory() let cacheDirectory = temporaryDirectory.appendingPathComponent("cached_random_files") OWSFileSystem.ensureDirectoryExists(cacheDirectory) let filePath = cacheDirectory.appendingPathComponent(filename) guard !FileManager.default.fileExists(atPath: filePath) else { self.filePath = filePath completion(.success(())) return } let urlSession = OWSURLSession(securityPolicy: OWSURLSession.defaultSecurityPolicy, configuration: .ephemeral) urlSession.dataTaskPromise(url.absoluteString, method: .get) .done { response in guard let data = response.responseBodyData, data.count > 0 else { owsFailDebug("Error write url response [\(url)]: \(filePath)") completion(.failure(DebugUIError.downloadFailed)) return } do { let fileUrl = URL(fileURLWithPath: filePath) try data.write(to: fileUrl, options: .atomic) owsAssertDebug(FileManager.default.fileExists(atPath: filePath)) completion(.success(())) } catch { owsFailDebug("Error downloading [\(url)]: \(error)") completion(.failure(error)) } } .catch { error in owsFailDebug("Error downloading url[\(url)]: \(error)") completion(.failure(error)) } } // MARK: - private static func fakePngAssetLoaderWithImageSize( _ imageSize: CGSize, backgroundColor: UIColor, textColor: UIColor, label: String ) -> DebugUIMessagesAssetLoader { owsAssertDebug(imageSize.isNonEmpty) owsAssertDebug(!label.isEmpty) let assetLoader = DebugUIMessagesAssetLoader(filename: "image.png", mimeType: MimeType.imagePng.rawValue) assetLoader.prepare = { [weak assetLoader] completion in assetLoader?.ensurePngAssetLoaded( imageSize: imageSize, backgroundColor: backgroundColor, textColor: textColor, label: label, completion: completion ) } return assetLoader } private func ensurePngAssetLoaded( imageSize: CGSize, backgroundColor: UIColor, textColor: UIColor, label: String, completion: Completion ) { owsAssertDebug(imageSize.isNonEmpty) owsAssertDebug(!label.isEmpty) guard filePath == nil else { completion(.success(())) return } let filePath = OWSFileSystem.temporaryFilePath(fileExtension: "png") guard let image = DebugUIMessagesAssetLoader.createRandomPngWithSize( imageSize, backgroundColor: backgroundColor, textColor: textColor, label: label), let pngData = image.pngData() else { completion(.failure(DebugUIError.imageGenerationFailed)) return } do { try pngData.write(to: URL(fileURLWithPath: filePath), options: .atomic) self.filePath = filePath completion(.success(())) } catch { owsFailDebug("Error: \(error)") completion(.failure(error)) } } private static func fakeNoisePngAssetLoaderWithImageSize(_ imageSize: UInt) -> DebugUIMessagesAssetLoader { owsAssertDebug(imageSize > 0) let assetLoader = DebugUIMessagesAssetLoader(filename: "image.png", mimeType: MimeType.imagePng.rawValue) assetLoader.prepare = { [weak assetLoader] completion in assetLoader?.ensureNoisePngAssetLoaded(imageSize: imageSize, completion: completion) } return assetLoader } private func ensureNoisePngAssetLoaded(imageSize: UInt, completion: Completion) { owsAssertDebug(imageSize > 0) guard filePath == nil else { completion(.success(())) return } let filePath = OWSFileSystem.temporaryFilePath(fileExtension: "png") guard let image = DebugUIMessagesAssetLoader.buildNoiseImageWithSize(imageSize), let pngData = image.pngData() else { completion(.failure(DebugUIError.imageGenerationFailed)) return } do { try pngData.write(to: URL(fileURLWithPath: filePath), options: .atomic) self.filePath = filePath completion(.success(())) } catch { owsFailDebug("Error: \(error)") completion(.failure(error)) } } private static func buildNoiseImageWithSize(_ size: UInt) -> UIImage? { let backgroundColor = UIColor(rgbHex: 0xaca6633) return imageWithSize(size, backgroundColor: backgroundColor) { context in for x in 0.. Void ) -> UIImage? { owsAssertDebug(imageSize > 0) let rect = CGRect(origin: .zero, size: .square(CGFloat(imageSize))) UIGraphicsBeginImageContextWithOptions(rect.size, false, 0) guard let context = UIGraphicsGetCurrentContext() else { return nil } context.setFillColor(backgroundColor.cgColor) context.fill([rect]) context.saveGState() drawBlock(context) context.restoreGState() defer { UIGraphicsEndImageContext() } let image = UIGraphicsGetImageFromCurrentImageContext() return image } private static func createRandomPngWithSize( _ imageSize: CGSize, backgroundColor: UIColor, textColor: UIColor, label: String ) -> UIImage? { owsAssertDebug(imageSize.isNonEmpty) owsAssertDebug(!label.isEmpty) let size = imageSize.applying(.scale(1 / UIScreen.main.scale)).roundedForScreenScale() UIGraphicsBeginImageContextWithOptions(size, false, 0) guard let context = UIGraphicsGetCurrentContext() else { return nil } let imageRect = CGRect(origin: .zero, size: size) context.setFillColor(backgroundColor.cgColor) context.fill([imageRect]) let labelAsNSString = label as NSString let smallDimension = min(size.width, size.height) let font = UIFont.boldSystemFont(ofSize: smallDimension * 0.5) let textAttributes: [ NSAttributedString.Key: Any ] = [ .font: font, .foregroundColor: textColor ] var textRect = labelAsNSString.boundingRect( with: imageRect.size, options: [ .usesLineFragmentOrigin, .usesFontLeading ], attributes: textAttributes, context: nil ) textRect.origin.x = 0.5 * (imageRect.width - textRect.width) textRect.origin.y = 0.5 * (imageRect.height - textRect.height) labelAsNSString.draw(at: textRect.origin, withAttributes: textAttributes) defer { UIGraphicsEndImageContext() } let image = UIGraphicsGetImageFromCurrentImageContext() return image } // MARK: - private static func fakeRandomAssetLoaderWithDataLength(_ dataLength: UInt, mimeType: String) -> DebugUIMessagesAssetLoader? { owsAssertDebug(dataLength > 0) owsAssertDebug(!mimeType.isEmpty) guard let fileExtension = MimeTypeUtil.fileExtensionForMimeType(mimeType) else { owsFailDebug("Invalid mime type: \(mimeType)") return nil } let assetLoader = DebugUIMessagesAssetLoader(filename: "attachment.\(fileExtension)", mimeType: mimeType) assetLoader.prepare = { [weak assetLoader] completion in assetLoader?.ensureRandomAssetLoaded(dataLength, completion: completion) } return assetLoader } private func ensureRandomAssetLoaded(_ dataLength: UInt, completion: Completion) { owsAssertDebug(dataLength > 0) guard filePath == nil else { completion(.success(())) return } guard let fileExtension = MimeTypeUtil.fileExtensionForMimeType(mimeType) else { completion(.failure(DebugUIError.invalidMimeType)) return } let data = Randomness.generateRandomBytes(dataLength) owsAssertDebug(data.count > 0) let filePath = OWSFileSystem.temporaryFilePath(fileExtension: fileExtension) do { try data.write(to: URL(fileURLWithPath: filePath), options: .atomic) self.filePath = filePath completion(.success(())) } catch { completion(.failure(error)) } } // MARK: - private static func fakeMissingAssetLoaderWithMimeType(_ mimeType: String) -> DebugUIMessagesAssetLoader? { guard let fileExtension = MimeTypeUtil.fileExtensionForMimeType(mimeType) else { owsFailDebug("Invalid mime type: \(mimeType)") return nil } let assetLoader = DebugUIMessagesAssetLoader(filename: "attachment.\(fileExtension)", mimeType: mimeType) assetLoader.prepare = { [weak assetLoader] completion in assetLoader?.ensureMissingAssetLoaded(completion: completion) } return assetLoader } private func ensureMissingAssetLoaded(completion: Completion) { guard filePath == nil else { completion(.success(())) return } guard let fileExtension = MimeTypeUtil.fileExtensionForMimeType(mimeType) else { completion(.failure(DebugUIError.invalidMimeType)) return } let filePath = OWSFileSystem.temporaryFilePath(fileExtension: fileExtension) guard FileManager.default.createFile(atPath: filePath, contents: nil) else { completion(.failure(DebugUIError.fileCreationFailed)) return } self.filePath = filePath completion(.success(())) } // MARK: - private static var largeTextSnippet: String { let snippet = """ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse rutrum, nulla vitae pretium hendrerit, tellus turpis pharetra libero, vitae sodales tortor ante vel sem. Fusce sed nisl a lorem gravida tincidunt. Suspendisse efficitur non quam ac sodales. Aenean ut velit maximus, posuere sem a, accumsan nunc. Donec ullamcorper turpis lorem. Quisque dignissim purus eu placerat ultricies. Proin at urna eget mi semper congue. Aenean non elementum ex. Praesent pharetra quam at sem vestibulum, vestibulum ornare dolor elementum. Vestibulum massa tortor, scelerisque sit amet pulvinar a, rhoncus vitae nisl. Sed mi nunc, tempus at varius in, malesuada vitae dui. Vivamus efficitur pulvinar erat vitae congue. Proin vehicula turpis non felis congue facilisis. Nullam aliquet dapibus ligula ac mollis. Etiam sit amet posuere lorem, in rhoncus nisi.\n\n """ return (0..<32).reduce(into: "") { result, _ in result += snippet } } private static func fakeOversizeTextAssetLoader(text: String?) -> DebugUIMessagesAssetLoader { let assetLoader = DebugUIMessagesAssetLoader(filename: "attachment.txt", mimeType: MimeType.textXSignalPlain.rawValue) assetLoader.prepare = { [weak assetLoader] completion in assetLoader?.ensureOversizeTextAssetLoaded(text: text, completion: completion) } return assetLoader } private func ensureOversizeTextAssetLoaded(text: String?, completion: Completion) { guard filePath == nil else { completion(.success(())) return } let largeText = text ?? DebugUIMessagesAssetLoader.largeTextSnippet let textData = largeText.data(using: .utf8)! let filePath = OWSFileSystem.temporaryFilePath(fileExtension: "txt") do { try textData.write(to: URL(fileURLWithPath: filePath), options: .atomic) self.filePath = filePath completion(.success(())) } catch { completion(.failure(error)) } } } #endif