mirror of
https://github.com/lux-tel/session-ios.git
synced 2026-10-30 20:38:32 +00:00
• Added the ability to set the custom file server via env variables for testing • Disable image editing on the share extension if there isn't enough RAM available to edit the image (ie. prevent crashes due to insufficient memory) • Tweaked image loading to avoid pre-decoding large static images to reduce memory spikes (was crashing the share extension on some devices) • Updated link previews to load images using the ImageDataManager • Fixed an issue where the file id on the message info screen would include extra info • Fixed an issue where sharing a file wouldn't show the preview correctly • Fixed an issue where the re-upload job could run when a display picture file was missing • Fixed an issue where deleting messages across all devices in Note to Self was using the wrong deletion function • Fixed an issue where an invalid database value could result in no attachments being able to be loaded in a conversation • Fixed an issue where images with orientation metadata would be incorrectly rotated and letterboxed
330 lines
11 KiB
Swift
330 lines
11 KiB
Swift
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
|
|
import UIKit
|
|
import UniformTypeIdentifiers
|
|
import SessionUIKit
|
|
import SessionMessagingKit
|
|
import SessionUtilitiesKit
|
|
|
|
// Used to represent undo/redo operations.
|
|
//
|
|
// 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.
|
|
private class ImageEditorOperation: NSObject {
|
|
|
|
let operationId: String
|
|
|
|
let contents: ImageEditorContents
|
|
|
|
required init(contents: ImageEditorContents) {
|
|
self.operationId = UUID().uuidString
|
|
self.contents = contents
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public protocol ImageEditorModelObserver: AnyObject {
|
|
// Used for large changes to the model, when the entire
|
|
// model should be reloaded.
|
|
func imageEditorModelDidChange(before: ImageEditorContents, after: ImageEditorContents)
|
|
|
|
// Used for small narrow changes to the model, usually
|
|
// to a single item.
|
|
func imageEditorModelDidChange(changedItemIds: [String])
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public class ImageEditorModel {
|
|
|
|
public static var isFeatureEnabled: Bool {
|
|
return true
|
|
}
|
|
|
|
private let dependencies: Dependencies
|
|
public let src: ImageDataManager.DataSource
|
|
public let srcMetadata: MediaUtils.MediaMetadata
|
|
public let srcImageSizePixels: CGSize
|
|
private var contents: ImageEditorContents
|
|
private var transform: ImageEditorTransform
|
|
private var undoStack = [ImageEditorOperation]()
|
|
private var redoStack = [ImageEditorOperation]()
|
|
|
|
// We don't want to allow editing of images if:
|
|
//
|
|
// * They are invalid.
|
|
// * We can't determine their size / aspect-ratio.
|
|
public required init(attachment: PendingAttachment, using dependencies: Dependencies) throws {
|
|
self.dependencies = dependencies
|
|
|
|
guard
|
|
let source: ImageDataManager.DataSource = attachment.visualMediaSource,
|
|
case .media(let metadata) = attachment.metadata
|
|
else {
|
|
Log.error("[ImageEditorModel] Couldn't extract media data.")
|
|
throw ImageEditorError.invalidInput
|
|
}
|
|
guard attachment.utType.isImage && attachment.duration == 0 else {
|
|
Log.error("[ImageEditorModel] Invalid MIME type: \(attachment.utType.preferredMIMEType ?? "unknown").")
|
|
throw ImageEditorError.invalidInput
|
|
}
|
|
|
|
let displaySize: CGSize = metadata.displaySize
|
|
|
|
guard displaySize.width > 0, displaySize.height > 0 else {
|
|
Log.error("[ImageEditorModel] Couldn't determine image size.")
|
|
throw ImageEditorError.invalidInput
|
|
}
|
|
|
|
self.src = source
|
|
self.srcMetadata = metadata
|
|
self.srcImageSizePixels = displaySize
|
|
|
|
self.contents = ImageEditorContents()
|
|
self.transform = ImageEditorTransform.defaultTransform(srcImageSizePixels: srcImageSizePixels)
|
|
}
|
|
|
|
public func currentTransform() -> ImageEditorTransform {
|
|
return transform
|
|
}
|
|
|
|
public func isDirty() -> Bool {
|
|
if itemCount() > 0 {
|
|
return true
|
|
}
|
|
return transform != ImageEditorTransform.defaultTransform(srcImageSizePixels: srcImageSizePixels)
|
|
}
|
|
|
|
public func itemCount() -> Int {
|
|
return contents.itemCount()
|
|
}
|
|
|
|
public func items() -> [ImageEditorItem] {
|
|
return contents.items()
|
|
}
|
|
|
|
public func itemIds() -> [String] {
|
|
return contents.itemIds()
|
|
}
|
|
|
|
public func has(itemForId itemId: String) -> Bool {
|
|
return item(forId: itemId) != nil
|
|
}
|
|
|
|
public func item(forId itemId: String) -> ImageEditorItem? {
|
|
return contents.item(forId: itemId)
|
|
}
|
|
|
|
public func canUndo() -> Bool {
|
|
return !undoStack.isEmpty
|
|
}
|
|
|
|
public func canRedo() -> Bool {
|
|
return !redoStack.isEmpty
|
|
}
|
|
|
|
public func currentUndoOperationId() -> String? {
|
|
guard let operation = undoStack.last else {
|
|
return nil
|
|
}
|
|
return operation.operationId
|
|
}
|
|
|
|
// MARK: - Observers
|
|
|
|
private var observers = [Weak<ImageEditorModelObserver>]()
|
|
|
|
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: -
|
|
|
|
public func undo() {
|
|
guard let undoOperation = undoStack.popLast() else {
|
|
Log.error("[ImageEditorModel] Cannot undo.")
|
|
return
|
|
}
|
|
|
|
let redoOperation = ImageEditorOperation(contents: contents)
|
|
redoStack.append(redoOperation)
|
|
|
|
let oldContents = self.contents
|
|
self.contents = undoOperation.contents
|
|
|
|
// We could diff here and yield a more narrow change event.
|
|
fireModelDidChange(before: oldContents, after: self.contents)
|
|
}
|
|
|
|
public func redo() {
|
|
guard let redoOperation = redoStack.popLast() else {
|
|
Log.error("[ImageEditorModel] Cannot redo.")
|
|
return
|
|
}
|
|
|
|
let undoOperation = ImageEditorOperation(contents: contents)
|
|
undoStack.append(undoOperation)
|
|
|
|
let oldContents = self.contents
|
|
self.contents = redoOperation.contents
|
|
|
|
// We could diff here and yield a more narrow change event.
|
|
fireModelDidChange(before: oldContents, after: self.contents)
|
|
}
|
|
|
|
public func append(item: ImageEditorItem) {
|
|
performAction({ (oldContents) in
|
|
let newContents = oldContents.clone()
|
|
newContents.append(item: item)
|
|
return newContents
|
|
}, changedItemIds: [item.itemId])
|
|
}
|
|
|
|
public func replace(item: ImageEditorItem, suppressUndo: Bool = false) {
|
|
performAction({ (oldContents) in
|
|
let newContents = oldContents.clone()
|
|
newContents.replace(item: item)
|
|
return newContents
|
|
}, changedItemIds: [item.itemId],
|
|
suppressUndo: suppressUndo)
|
|
}
|
|
|
|
public func remove(item: ImageEditorItem) {
|
|
performAction({ (oldContents) in
|
|
let newContents = oldContents.clone()
|
|
newContents.remove(item: item)
|
|
return newContents
|
|
}, changedItemIds: [item.itemId])
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// MARK: - Temp Files
|
|
|
|
private var temporaryFilePaths = [String]()
|
|
|
|
deinit {
|
|
Log.assertOnMainThread()
|
|
|
|
let temporaryFilePaths = self.temporaryFilePaths
|
|
|
|
DispatchQueue.global(qos: .background).async { [dependencies] in
|
|
for filePath in temporaryFilePaths {
|
|
do { try dependencies[singleton: .fileManager].removeItem(atPath: filePath) }
|
|
catch { Log.error("[ImageEditorModel] Could not delete temp file: \(filePath)") }
|
|
}
|
|
}
|
|
}
|
|
|
|
private func performAction(_ action: (ImageEditorContents) -> ImageEditorContents,
|
|
changedItemIds: [String]?,
|
|
suppressUndo: Bool = false) {
|
|
if !suppressUndo {
|
|
let undoOperation = ImageEditorOperation(contents: contents)
|
|
undoStack.append(undoOperation)
|
|
redoStack.removeAll()
|
|
}
|
|
|
|
let oldContents = self.contents
|
|
let newContents = action(oldContents)
|
|
contents = newContents
|
|
|
|
if let changedItemIds = changedItemIds {
|
|
fireModelDidChange(changedItemIds: changedItemIds)
|
|
} else {
|
|
fireModelDidChange(before: oldContents,
|
|
after: self.contents)
|
|
}
|
|
}
|
|
|
|
// MARK: - Utilities
|
|
|
|
// Returns nil on error.
|
|
@MainActor private class func crop(
|
|
imagePath: String,
|
|
unitCropRect: CGRect,
|
|
using dependencies: Dependencies
|
|
) -> UIImage? {
|
|
guard let srcImage = UIImage(contentsOfFile: imagePath) else {
|
|
Log.error("[ImageEditorModel] Could not load image")
|
|
return nil
|
|
}
|
|
let srcImageSize = srcImage.size
|
|
// Convert from unit coordinates to src image coordinates.
|
|
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))
|
|
|
|
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 {
|
|
Log.error("[ImageEditorModel] Invalid crop rectangle.")
|
|
return nil
|
|
}
|
|
guard cropRect.size.width > 0,
|
|
cropRect.size.height > 0 else {
|
|
// Not an error; indicates that the user tapped rather
|
|
// than dragged.
|
|
Log.warn("[ImageEditorModel] Empty crop rectangle.")
|
|
return nil
|
|
}
|
|
let hasAlpha: Bool = (MediaUtils.MediaMetadata(
|
|
from: imagePath,
|
|
utType: nil,
|
|
sourceFilename: nil,
|
|
using: dependencies
|
|
)?.hasAlpha == true)
|
|
|
|
UIGraphicsBeginImageContextWithOptions(cropRect.size, !hasAlpha, srcImage.scale)
|
|
defer { UIGraphicsEndImageContext() }
|
|
|
|
guard let context = UIGraphicsGetCurrentContext() else {
|
|
Log.error("[ImageEditorModel] context was unexpectedly nil")
|
|
return nil
|
|
}
|
|
context.interpolationQuality = .high
|
|
|
|
// Draw source image.
|
|
let dstFrame = CGRect(origin: cropRect.origin.inverted(), size: srcImageSize)
|
|
srcImage.draw(in: dstFrame)
|
|
|
|
let dstImage = UIGraphicsGetImageFromCurrentImageContext()
|
|
if dstImage == nil {
|
|
Log.error("[ImageEditorModel] could not generate dst image.")
|
|
}
|
|
return dstImage
|
|
}
|
|
}
|