From b0e0c6e8c22253fffad0cba5b8c3aac2fb5c9e5f Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Mon, 17 Dec 2018 11:48:48 -0500 Subject: [PATCH] Replace edited attachments when sending. --- .../AttachmentApprovalViewController.swift | 58 +++++++++++++++- .../Views/ImageEditor/ImageEditor.swift | 8 +-- .../Views/ImageEditor/ImageEditorView.swift | 69 +++++++++++++++++-- SignalServiceKit/src/Util/NSData+Image.h | 4 ++ SignalServiceKit/src/Util/NSData+Image.m | 31 ++++++++- 5 files changed, 158 insertions(+), 12 deletions(-) diff --git a/SignalMessaging/ViewControllers/AttachmentApprovalViewController.swift b/SignalMessaging/ViewControllers/AttachmentApprovalViewController.swift index f141170bc..d178e85c9 100644 --- a/SignalMessaging/ViewControllers/AttachmentApprovalViewController.swift +++ b/SignalMessaging/ViewControllers/AttachmentApprovalViewController.swift @@ -581,7 +581,63 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC } var attachments: [SignalAttachment] { - return attachmentItems.map { $0.attachment } + return attachmentItems.map { self.attachment(forAttachmentItem: $0) } + } + + // For any attachments edited with the image editor, returns a + // new SignalAttachment that reflects those changes. Otherwise, + // returns the original attachment. + // + // If any errors occurs in the export process, we fail over to + // sending the original attachment. This seems better than trying + // to involve the user in resolving the issue. + func attachment(forAttachmentItem attachmentItem: SignalAttachmentItem) -> SignalAttachment { + guard let imageEditorModel = attachmentItem.imageEditorModel else { + // Image was not edited. + return attachmentItem.attachment + } + guard imageEditorModel.itemCount() > 0 else { + // Image editor has no changes. + return attachmentItem.attachment + } + guard let dstImage = ImageEditorView.renderForOutput(model: imageEditorModel) else { + owsFailDebug("Could not render for output.") + return attachmentItem.attachment + } + var dataUTI = kUTTypeImage as String + guard let dstData: Data = { + let isLossy: Bool = attachmentItem.attachment.mimeType.caseInsensitiveCompare(OWSMimeTypeImageJpeg) == .orderedSame + if isLossy { + dataUTI = kUTTypeJPEG as String + return UIImageJPEGRepresentation(dstImage, 0.9) + } else { + dataUTI = kUTTypePNG as String + return UIImagePNGRepresentation(dstImage) + } + }() else { + owsFailDebug("Could not export for output.") + return attachmentItem.attachment + } + guard let dataSource = DataSourceValue.dataSource(with: dstData, utiType: dataUTI) else { + owsFailDebug("Could not prepare data source for output.") + return attachmentItem.attachment + } + + // Rewrite the filename's extension to reflect the output file format. + var filename: String? = attachmentItem.attachment.sourceFilename + if let sourceFilename = attachmentItem.attachment.sourceFilename { + if let fileExtension: String = MIMETypeUtil.fileExtension(forUTIType: dataUTI) { + filename = (sourceFilename as NSString).deletingPathExtension.appendingFileExtension(fileExtension) + } + } + dataSource.sourceFilename = filename + + let dstAttachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: dataUTI, imageQuality: .original) + if let attachmentError = dstAttachment.error { + owsFailDebug("Could not prepare attachment for output: \(attachmentError).") + return attachmentItem.attachment + } + return dstAttachment } func attachmentItem(before currentItem: SignalAttachmentItem) -> SignalAttachmentItem? { diff --git a/SignalMessaging/Views/ImageEditor/ImageEditor.swift b/SignalMessaging/Views/ImageEditor/ImageEditor.swift index 6165f1e81..d1e3c7768 100644 --- a/SignalMessaging/Views/ImageEditor/ImageEditor.swift +++ b/SignalMessaging/Views/ImageEditor/ImageEditor.swift @@ -246,7 +246,7 @@ public class ImageEditorModel: NSObject { public let srcImagePath: String @objc - public let srcImageSize: CGSize + public let srcImageSizePixels: CGSize private var contents = ImageEditorContents() @@ -273,12 +273,12 @@ public class ImageEditorModel: NSObject { throw ImageEditorError.invalidInput } - let srcImageSize = NSData.imageSize(forFilePath: srcImagePath, mimeType: mimeType) - guard srcImageSize.width > 0, srcImageSize.height > 0 else { + let srcImageSizePixels = NSData.imageSize(forFilePath: srcImagePath, mimeType: mimeType) + guard srcImageSizePixels.width > 0, srcImageSizePixels.height > 0 else { Logger.error("Couldn't determine image size.") throw ImageEditorError.invalidInput } - self.srcImageSize = srcImageSize + self.srcImageSizePixels = srcImageSizePixels super.init() } diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorView.swift b/SignalMessaging/Views/ImageEditor/ImageEditorView.swift index 9bb3fb389..bfbcaaa4b 100644 --- a/SignalMessaging/Views/ImageEditor/ImageEditorView.swift +++ b/SignalMessaging/Views/ImageEditor/ImageEditorView.swift @@ -98,7 +98,8 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { CATransaction.setDisableActions(true) for item in model.items() { - guard let layer = layerForItem(item: item) else { + guard let layer = ImageEditorView.layerForItem(item: item, + viewSize: bounds.size) else { Logger.error("Couldn't create layer for item.") continue } @@ -109,7 +110,8 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { CATransaction.commit() } - private func layerForItem(item: ImageEditorItem) -> CALayer? { + private class func layerForItem(item: ImageEditorItem, + viewSize: CGSize) -> CALayer? { AssertIsOnMainThread() switch item.itemType { @@ -121,14 +123,14 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { owsFailDebug("Item has unexpected type: \(type(of: item)).") return nil } - return strokeLayerForItem(item: strokeItem) + return strokeLayerForItem(item: strokeItem, viewSize: viewSize) } } - private func strokeLayerForItem(item: ImageEditorStrokeItem) -> CALayer? { + private class func strokeLayerForItem(item: ImageEditorStrokeItem, + viewSize: CGSize) -> CALayer? { AssertIsOnMainThread() - let viewSize = bounds.size let strokeWidth = ImageEditorStrokeItem.strokeWidth(forUnitStrokeWidth: item.unitStrokeWidth, dstSize: viewSize) let unitSamples = item.unitSamples @@ -139,7 +141,7 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { let shapeLayer = CAShapeLayer() shapeLayer.lineWidth = strokeWidth shapeLayer.strokeColor = item.color.cgColor - shapeLayer.frame = self.bounds + shapeLayer.frame = CGRect(origin: .zero, size: viewSize) let transformSampleToPoint = { (unitSample: CGPoint) -> CGPoint in return CGPoint(x: viewSize.width * unitSample.x, @@ -209,4 +211,59 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { return shapeLayer } + + // MARK: - Actions + + // Returns nil on error. + @objc + public class func renderForOutput(model: ImageEditorModel) -> UIImage? { + // TODO: Do we want to render off the main thread? + AssertIsOnMainThread() + + // Render output at same size as source image. + let dstSizePixels = model.srcImageSizePixels + + let hasAlpha = NSData.hasAlpha(forValidImageFilePath: model.srcImagePath) + + guard let srcImage = UIImage(contentsOfFile: model.srcImagePath) else { + owsFailDebug("Could not load src image.") + return nil + } + + let dstScale: CGFloat = 1.0 // The size is specified in pixels, not in points. + UIGraphicsBeginImageContextWithOptions(dstSizePixels, !hasAlpha, dstScale) + + guard let context = UIGraphicsGetCurrentContext() else { + owsFailDebug("Could not create output context.") + return nil + } + context.interpolationQuality = .high + + // Draw source image. + let dstFrame = CGRect(origin: .zero, size: model.srcImageSizePixels) + srcImage.draw(in: dstFrame) + + for item in model.items() { + guard let layer = layerForItem(item: item, + viewSize: dstSizePixels) else { + Logger.error("Couldn't create layer for item.") + continue + } + // This might be superfluous, but ensure that the layer renders + // at "point=pixel" scale. + layer.contentsScale = 1.0 + + // TODO: + Logger.verbose("layer.contentsScale: \(layer.contentsScale)") + + layer.render(in: context) + } + + let scaledImage = UIGraphicsGetImageFromCurrentImageContext() + if scaledImage == nil { + owsFailDebug("could not generate dst image.") + } + UIGraphicsEndImageContext() + return scaledImage + } } diff --git a/SignalServiceKit/src/Util/NSData+Image.h b/SignalServiceKit/src/Util/NSData+Image.h index fccd91df9..4e353a0cd 100644 --- a/SignalServiceKit/src/Util/NSData+Image.h +++ b/SignalServiceKit/src/Util/NSData+Image.h @@ -11,7 +11,11 @@ - (BOOL)ows_isValidImage; - (BOOL)ows_isValidImageWithMimeType:(nullable NSString *)mimeType; +// Returns the image size in pixels. +// // Returns CGSizeZero on error. + (CGSize)imageSizeForFilePath:(NSString *)filePath mimeType:(NSString *)mimeType; ++ (BOOL)hasAlphaForValidImageFilePath:(NSString *)filePath; + @end diff --git a/SignalServiceKit/src/Util/NSData+Image.m b/SignalServiceKit/src/Util/NSData+Image.m index 87169f266..b87fb3d90 100644 --- a/SignalServiceKit/src/Util/NSData+Image.m +++ b/SignalServiceKit/src/Util/NSData+Image.m @@ -2,8 +2,8 @@ // Copyright (c) 2018 Open Whisper Systems. All rights reserved. // -#import "MIMETypeUtil.h" #import "NSData+Image.h" +#import "MIMETypeUtil.h" #import "OWSFileSystem.h" #import #import @@ -370,4 +370,33 @@ typedef NS_ENUM(NSInteger, ImageFormat) { } } ++ (BOOL)hasAlphaForValidImageFilePath:(NSString *)filePath +{ + NSURL *url = [NSURL fileURLWithPath:filePath]; + + // With CGImageSource we avoid loading the whole image into memory. + CGImageSourceRef source = CGImageSourceCreateWithURL((CFURLRef)url, NULL); + if (!source) { + OWSFailDebug(@"Could not load image: %@", url); + return NO; + } + + NSDictionary *options = @{ + (NSString *)kCGImageSourceShouldCache : @(NO), + }; + NSDictionary *properties + = (__bridge_transfer NSDictionary *)CGImageSourceCopyPropertiesAtIndex(source, 0, (CFDictionaryRef)options); + BOOL result = NO; + if (properties) { + NSNumber *hasAlpha = properties[(NSString *)kCGImagePropertyHasAlpha]; + if (hasAlpha) { + result = hasAlpha.boolValue; + } else { + OWSFailDebug(@"Could not determine transparency of image: %@", url); + } + } + CFRelease(source); + return result; +} + @end