mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
	
	
		
			361 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Swift
		
	
		
		
			
		
	
	
			361 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Swift
		
	
| 
											7 years ago
										 | // | ||
| 
											7 years ago
										 | //  Copyright (c) 2019 Open Whisper Systems. All rights reserved. | ||
| 
											7 years ago
										 | // | ||
|  | 
 | ||
|  | import UIKit | ||
|  | 
 | ||
| 
											7 years ago
										 | // Used to represent undo/redo operations. | ||
| 
											7 years ago
										 | // | ||
|  | // Because the image editor's "contents" and "items" | ||
|  | // are immutable, these operations simply take a | ||
|  | // snapshot of the current contents which can be used | ||
|  | // (multiple times) to preserve/restore editor state. | ||
| 
											7 years ago
										 | private class ImageEditorOperation: NSObject { | ||
|  | 
 | ||
| 
											7 years ago
										 |     let operationId: String | ||
|  | 
 | ||
| 
											7 years ago
										 |     let contents: ImageEditorContents | ||
|  | 
 | ||
|  |     required init(contents: ImageEditorContents) { | ||
| 
											7 years ago
										 |         self.operationId = UUID().uuidString | ||
| 
											7 years ago
										 |         self.contents = contents | ||
| 
											7 years ago
										 |     } | ||
|  | } | ||
|  | 
 | ||
| 
											7 years ago
										 | // MARK: - | ||
|  | 
 | ||
| 
											7 years ago
										 | @objc | ||
| 
											7 years ago
										 | public protocol ImageEditorModelObserver: class { | ||
| 
											7 years ago
										 |     // Used for large changes to the model, when the entire | ||
|  |     // model should be reloaded. | ||
| 
											7 years ago
										 |     func imageEditorModelDidChange(before: ImageEditorContents, | ||
|  |                                    after: ImageEditorContents) | ||
| 
											7 years ago
										 | 
 | ||
|  |     // Used for small narrow changes to the model, usually | ||
|  |     // to a single item. | ||
| 
											7 years ago
										 |     func imageEditorModelDidChange(changedItemIds: [String]) | ||
| 
											7 years ago
										 | } | ||
|  | 
 | ||
|  | // MARK: - | ||
|  | 
 | ||
| 
											7 years ago
										 | @objc | ||
|  | public class ImageEditorModel: NSObject { | ||
| 
											7 years ago
										 | 
 | ||
|  |     @objc | ||
|  |     public static var isFeatureEnabled: Bool { | ||
| 
											7 years ago
										 |         return true | ||
| 
											7 years ago
										 |     } | ||
|  | 
 | ||
| 
											7 years ago
										 |     @objc | ||
|  |     public let srcImagePath: String | ||
|  | 
 | ||
|  |     @objc | ||
| 
											7 years ago
										 |     public let srcImageSizePixels: CGSize | ||
| 
											7 years ago
										 | 
 | ||
| 
											7 years ago
										 |     private var contents: ImageEditorContents | ||
| 
											7 years ago
										 | 
 | ||
| 
											7 years ago
										 |     private var transform: ImageEditorTransform | ||
|  | 
 | ||
| 
											7 years ago
										 |     private var undoStack = [ImageEditorOperation]() | ||
|  |     private var redoStack = [ImageEditorOperation]() | ||
|  | 
 | ||
| 
											7 years ago
										 |     // We don't want to allow editing of images if: | ||
|  |     // | ||
|  |     // * They are invalid. | ||
|  |     // * We can't determine their size / aspect-ratio. | ||
| 
											7 years ago
										 |     @objc | ||
|  |     public required init(srcImagePath: String) throws { | ||
|  |         self.srcImagePath = srcImagePath | ||
|  | 
 | ||
|  |         let srcFileName = (srcImagePath as NSString).lastPathComponent | ||
|  |         let srcFileExtension = (srcFileName as NSString).pathExtension | ||
|  |         guard let mimeType = MIMETypeUtil.mimeType(forFileExtension: srcFileExtension) else { | ||
|  |             Logger.error("Couldn't determine MIME type for file.") | ||
|  |             throw ImageEditorError.invalidInput | ||
|  |         } | ||
| 
											7 years ago
										 |         guard MIMETypeUtil.isImage(mimeType), | ||
|  |             !MIMETypeUtil.isAnimated(mimeType) else { | ||
| 
											7 years ago
										 |                 Logger.error("Invalid MIME type: \(mimeType).") | ||
|  |                 throw ImageEditorError.invalidInput | ||
| 
											7 years ago
										 |         } | ||
|  | 
 | ||
| 
											7 years ago
										 |         let srcImageSizePixels = NSData.imageSize(forFilePath: srcImagePath, mimeType: mimeType) | ||
|  |         guard srcImageSizePixels.width > 0, srcImageSizePixels.height > 0 else { | ||
| 
											7 years ago
										 |             Logger.error("Couldn't determine image size.") | ||
|  |             throw ImageEditorError.invalidInput | ||
|  |         } | ||
| 
											7 years ago
										 |         self.srcImageSizePixels = srcImageSizePixels | ||
| 
											7 years ago
										 | 
 | ||
| 
											7 years ago
										 |         self.contents = ImageEditorContents() | ||
|  |         self.transform = ImageEditorTransform.defaultTransform(srcImageSizePixels: srcImageSizePixels) | ||
| 
											7 years ago
										 | 
 | ||
| 
											7 years ago
										 |         super.init() | ||
|  |     } | ||
| 
											7 years ago
										 | 
 | ||
| 
											7 years ago
										 |     public func currentTransform() -> ImageEditorTransform { | ||
|  |         return transform | ||
|  |     } | ||
|  | 
 | ||
| 
											7 years ago
										 |     @objc | ||
| 
											7 years ago
										 |     public func isDirty() -> Bool { | ||
|  |         if itemCount() > 0 { | ||
|  |             return true | ||
|  |         } | ||
|  |         return transform != ImageEditorTransform.defaultTransform(srcImageSizePixels: srcImageSizePixels) | ||
| 
											7 years ago
										 |     } | ||
|  | 
 | ||
| 
											7 years ago
										 |     @objc | ||
|  |     public func itemCount() -> Int { | ||
|  |         return contents.itemCount() | ||
|  |     } | ||
|  | 
 | ||
| 
											7 years ago
										 |     @objc | ||
|  |     public func items() -> [ImageEditorItem] { | ||
|  |         return contents.items() | ||
|  |     } | ||
|  | 
 | ||
| 
											7 years ago
										 |     @objc | ||
|  |     public func itemIds() -> [String] { | ||
|  |         return contents.itemIds() | ||
|  |     } | ||
|  | 
 | ||
| 
											7 years ago
										 |     @objc | ||
|  |     public func has(itemForId itemId: String) -> Bool { | ||
|  |         return item(forId: itemId) != nil | ||
|  |     } | ||
|  | 
 | ||
| 
											7 years ago
										 |     @objc | ||
|  |     public func item(forId itemId: String) -> ImageEditorItem? { | ||
|  |         return contents.item(forId: itemId) | ||
|  |     } | ||
|  | 
 | ||
| 
											7 years ago
										 |     @objc | ||
|  |     public func canUndo() -> Bool { | ||
|  |         return !undoStack.isEmpty | ||
|  |     } | ||
|  | 
 | ||
|  |     @objc | ||
|  |     public func canRedo() -> Bool { | ||
|  |         return !redoStack.isEmpty | ||
|  |     } | ||
|  | 
 | ||
| 
											7 years ago
										 |     @objc | ||
|  |     public func currentUndoOperationId() -> String? { | ||
|  |         guard let operation = undoStack.last else { | ||
|  |             return nil | ||
|  |         } | ||
|  |         return operation.operationId | ||
|  |     } | ||
|  | 
 | ||
| 
											7 years ago
										 |     // MARK: - Observers | ||
|  | 
 | ||
|  |     private var observers = [Weak<ImageEditorModelObserver>]() | ||
|  | 
 | ||
|  |     @objc | ||
|  |     public func add(observer: ImageEditorModelObserver) { | ||
|  |         observers.append(Weak(value: observer)) | ||
|  |     } | ||
|  | 
 | ||
|  |     private func fireModelDidChange(before: ImageEditorContents, | ||
|  |                                     after: ImageEditorContents) { | ||
|  |         // We could diff here and yield a more narrow change event. | ||
|  |         for weakObserver in observers { | ||
|  |             guard let observer = weakObserver.value else { | ||
|  |                 continue | ||
|  |             } | ||
|  |             observer.imageEditorModelDidChange(before: before, | ||
|  |                                                after: after) | ||
|  |         } | ||
|  |     } | ||
|  | 
 | ||
|  |     private func fireModelDidChange(changedItemIds: [String]) { | ||
|  |         // We could diff here and yield a more narrow change event. | ||
|  |         for weakObserver in observers { | ||
|  |             guard let observer = weakObserver.value else { | ||
|  |                 continue | ||
|  |             } | ||
|  |             observer.imageEditorModelDidChange(changedItemIds: changedItemIds) | ||
|  |         } | ||
|  |     } | ||
|  | 
 | ||
|  |     // MARK: - | ||
|  | 
 | ||
| 
											7 years ago
										 |     @objc | ||
|  |     public func undo() { | ||
|  |         guard let undoOperation = undoStack.popLast() else { | ||
|  |             owsFailDebug("Cannot undo.") | ||
|  |             return | ||
|  |         } | ||
|  | 
 | ||
|  |         let redoOperation = ImageEditorOperation(contents: contents) | ||
|  |         redoStack.append(redoOperation) | ||
|  | 
 | ||
| 
											7 years ago
										 |         let oldContents = self.contents | ||
| 
											7 years ago
										 |         self.contents = undoOperation.contents | ||
| 
											7 years ago
										 | 
 | ||
| 
											7 years ago
										 |         // We could diff here and yield a more narrow change event. | ||
| 
											7 years ago
										 |         fireModelDidChange(before: oldContents, after: self.contents) | ||
| 
											7 years ago
										 |     } | ||
|  | 
 | ||
|  |     @objc | ||
|  |     public func redo() { | ||
|  |         guard let redoOperation = redoStack.popLast() else { | ||
|  |             owsFailDebug("Cannot redo.") | ||
|  |             return | ||
|  |         } | ||
|  | 
 | ||
|  |         let undoOperation = ImageEditorOperation(contents: contents) | ||
|  |         undoStack.append(undoOperation) | ||
|  | 
 | ||
| 
											7 years ago
										 |         let oldContents = self.contents | ||
| 
											7 years ago
										 |         self.contents = redoOperation.contents | ||
| 
											7 years ago
										 | 
 | ||
| 
											7 years ago
										 |         // We could diff here and yield a more narrow change event. | ||
| 
											7 years ago
										 |         fireModelDidChange(before: oldContents, after: self.contents) | ||
| 
											7 years ago
										 |     } | ||
|  | 
 | ||
|  |     @objc | ||
|  |     public func append(item: ImageEditorItem) { | ||
| 
											7 years ago
										 |         performAction({ (oldContents) in | ||
|  |             let newContents = oldContents.clone() | ||
| 
											7 years ago
										 |             newContents.append(item: item) | ||
| 
											7 years ago
										 |             return newContents | ||
| 
											7 years ago
										 |         }, changedItemIds: [item.itemId]) | ||
| 
											7 years ago
										 |     } | ||
|  | 
 | ||
|  |     @objc | ||
| 
											7 years ago
										 |     public func replace(item: ImageEditorItem, | ||
| 
											7 years ago
										 |                         suppressUndo: Bool = false) { | ||
| 
											7 years ago
										 |         performAction({ (oldContents) in | ||
|  |             let newContents = oldContents.clone() | ||
| 
											7 years ago
										 |             newContents.replace(item: item) | ||
| 
											7 years ago
										 |             return newContents | ||
| 
											7 years ago
										 |         }, changedItemIds: [item.itemId], | ||
|  |            suppressUndo: suppressUndo) | ||
| 
											7 years ago
										 |     } | ||
|  | 
 | ||
|  |     @objc | ||
|  |     public func remove(item: ImageEditorItem) { | ||
| 
											7 years ago
										 |         performAction({ (oldContents) in | ||
|  |             let newContents = oldContents.clone() | ||
| 
											7 years ago
										 |             newContents.remove(item: item) | ||
| 
											7 years ago
										 |             return newContents | ||
| 
											7 years ago
										 |         }, changedItemIds: [item.itemId]) | ||
| 
											7 years ago
										 |     } | ||
|  | 
 | ||
| 
											7 years ago
										 |     @objc | ||
|  |     public func replace(transform: ImageEditorTransform) { | ||
|  |         self.transform = transform | ||
|  | 
 | ||
|  |         // The contents haven't changed, but this event prods the | ||
|  |         // observers to reload everything, which is necessary if | ||
|  |         // the transform changes. | ||
|  |         fireModelDidChange(before: self.contents, after: self.contents) | ||
|  |     } | ||
|  | 
 | ||
| 
											7 years ago
										 |     // MARK: - Temp Files | ||
|  | 
 | ||
|  |     private var temporaryFilePaths = [String]() | ||
|  | 
 | ||
|  |     @objc | ||
|  |     public func temporaryFilePath(withFileExtension fileExtension: String) -> String { | ||
|  |         AssertIsOnMainThread() | ||
|  | 
 | ||
|  |         let filePath = OWSFileSystem.temporaryFilePath(withFileExtension: fileExtension) | ||
|  |         temporaryFilePaths.append(filePath) | ||
|  |         return filePath | ||
|  |     } | ||
|  | 
 | ||
|  |     deinit { | ||
|  |         AssertIsOnMainThread() | ||
|  | 
 | ||
|  |         let temporaryFilePaths = self.temporaryFilePaths | ||
|  | 
 | ||
|  |         DispatchQueue.global(qos: .background).async { | ||
|  |             for filePath in temporaryFilePaths { | ||
|  |                 guard OWSFileSystem.deleteFile(filePath) else { | ||
|  |                     Logger.error("Could not delete temp file: \(filePath)") | ||
|  |                     continue | ||
|  |                 } | ||
|  |             } | ||
|  |         } | ||
|  |     } | ||
|  | 
 | ||
| 
											7 years ago
										 |     private func performAction(_ action: (ImageEditorContents) -> ImageEditorContents, | ||
|  |                                changedItemIds: [String]?, | ||
| 
											7 years ago
										 |                                suppressUndo: Bool = false) { | ||
|  |         if !suppressUndo { | ||
| 
											7 years ago
										 |             let undoOperation = ImageEditorOperation(contents: contents) | ||
|  |             undoStack.append(undoOperation) | ||
|  |             redoStack.removeAll() | ||
|  |         } | ||
| 
											7 years ago
										 | 
 | ||
| 
											7 years ago
										 |         let oldContents = self.contents | ||
|  |         let newContents = action(oldContents) | ||
| 
											7 years ago
										 |         contents = newContents | ||
| 
											7 years ago
										 | 
 | ||
| 
											7 years ago
										 |         if let changedItemIds = changedItemIds { | ||
| 
											7 years ago
										 |             fireModelDidChange(changedItemIds: changedItemIds) | ||
| 
											7 years ago
										 |         } else { | ||
| 
											7 years ago
										 |             fireModelDidChange(before: oldContents, | ||
|  |                                after: self.contents) | ||
| 
											7 years ago
										 |         } | ||
|  |     } | ||
|  | 
 | ||
|  |     // MARK: - Utilities | ||
|  | 
 | ||
|  |     // Returns nil on error. | ||
|  |     private class func crop(imagePath: String, | ||
|  |                             unitCropRect: CGRect) -> UIImage? { | ||
|  |         // TODO: Do we want to render off the main thread? | ||
|  |         AssertIsOnMainThread() | ||
|  | 
 | ||
|  |         guard let srcImage = UIImage(contentsOfFile: imagePath) else { | ||
|  |             owsFailDebug("Could not load image") | ||
|  |             return nil | ||
|  |         } | ||
|  |         let srcImageSize = srcImage.size | ||
|  |         // Convert from unit coordinates to src image coordinates. | ||
| 
											7 years ago
										 |         let cropRect = CGRect(x: round(unitCropRect.origin.x * srcImageSize.width), | ||
|  |                               y: round(unitCropRect.origin.y * srcImageSize.height), | ||
|  |                               width: round(unitCropRect.size.width * srcImageSize.width), | ||
|  |                               height: round(unitCropRect.size.height * srcImageSize.height)) | ||
| 
											7 years ago
										 | 
 | ||
|  |         guard cropRect.origin.x >= 0, | ||
|  |             cropRect.origin.y >= 0, | ||
|  |             cropRect.origin.x + cropRect.size.width <= srcImageSize.width, | ||
|  |             cropRect.origin.y + cropRect.size.height <= srcImageSize.height else { | ||
|  |                 owsFailDebug("Invalid crop rectangle.") | ||
|  |                 return nil | ||
|  |         } | ||
|  |         guard cropRect.size.width > 0, | ||
|  |             cropRect.size.height > 0 else { | ||
| 
											7 years ago
										 |                 // Not an error; indicates that the user tapped rather | ||
|  |                 // than dragged. | ||
|  |                 Logger.warn("Empty crop rectangle.") | ||
| 
											7 years ago
										 |                 return nil | ||
|  |         } | ||
|  | 
 | ||
|  |         let hasAlpha = NSData.hasAlpha(forValidImageFilePath: imagePath) | ||
|  | 
 | ||
|  |         UIGraphicsBeginImageContextWithOptions(cropRect.size, !hasAlpha, srcImage.scale) | ||
|  |         defer { UIGraphicsEndImageContext() } | ||
|  | 
 | ||
|  |         guard let context = UIGraphicsGetCurrentContext() else { | ||
| 
											7 years ago
										 |             owsFailDebug("context was unexpectedly nil") | ||
| 
											7 years ago
										 |             return nil | ||
|  |         } | ||
|  |         context.interpolationQuality = .high | ||
|  | 
 | ||
|  |         // Draw source image. | ||
|  |         let dstFrame = CGRect(origin: CGPointInvert(cropRect.origin), size: srcImageSize) | ||
|  |         srcImage.draw(in: dstFrame) | ||
|  | 
 | ||
|  |         let dstImage = UIGraphicsGetImageFromCurrentImageContext() | ||
|  |         if dstImage == nil { | ||
|  |             owsFailDebug("could not generate dst image.") | ||
|  |         } | ||
|  |         return dstImage | ||
| 
											7 years ago
										 |     } | ||
| 
											7 years ago
										 | } |