Skip to content

Commit

Permalink
Merge pull request #195 from cocoatype/185-save-photo-in-place-via-done
Browse files Browse the repository at this point in the history
Save photo in place via done alert
  • Loading branch information
Arclite authored Jul 15, 2024
2 parents 5665a47 + b9b73b8 commit 0bfd8d0
Show file tree
Hide file tree
Showing 39 changed files with 530 additions and 399 deletions.
65 changes: 65 additions & 0 deletions Action/Sources/ActionHostingController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Created by Geoff Pado on 7/1/19.
// Copyright © 2019 Cocoatype, LLC. All rights reserved.

import Editing
import ErrorHandling
import MobileCoreServices
import SwiftUI
import UIKit

class ActionHostingController: UIHostingController<ActionView> {
init() {
super.init(rootView: ActionView())
}

override func loadView() {
view = UIView()
view.backgroundColor = .clear
view.isOpaque = false
}

override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
loadImageFromExtensionContext()
}

private func loadImageFromExtensionContext() {
print(#function)
let imageTypeIdentifier = (kUTTypeImage as String)

let imageProvider = extensionContext?
.inputItems
.compactMap { $0 as? NSExtensionItem }
.flatMap { $0.attachments ?? [] }
.first(where: { $0.hasItemConformingToTypeIdentifier(imageTypeIdentifier) })

imageProvider?.loadItem(forTypeIdentifier: imageTypeIdentifier, options: nil) { [weak self] item, error in
do {
guard let imageURL = (item as? URL) else { throw (error ?? ActionError.imageURLNotFound) }

let imageData = try Data(contentsOf: imageURL)
let imageDataString = imageData.base64EncodedString()
guard let callbackURL = URL(string: "highlighter://x-callback-url/open?imageData=\(imageDataString)") else { throw ActionError.callbackURLConstructionFailed }
self?.chain(selector: #selector(Self.openURL(_:)), object: callbackURL)
self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
} catch {
ErrorHandler().log(error)
}
}
}

// just to provide the selector
@objc private func openURL(_ url: URL) {}

@available(*, unavailable)
required init(coder: NSCoder) {
let className = String(describing: type(of: self))
fatalError("\(className) does not implement init(coder:)")
}
}

enum ActionError: Error {
case callbackURLConstructionFailed
case imageURLNotFound
case invalidImageData
}
65 changes: 9 additions & 56 deletions Action/Sources/ActionViewController.swift
Original file line number Diff line number Diff line change
@@ -1,65 +1,18 @@
// Created by Geoff Pado on 7/1/19.
// Copyright © 2019 Cocoatype, LLC. All rights reserved.
// Created by Geoff Pado on 7/12/24.
// Copyright © 2024 Cocoatype, LLC. All rights reserved.

import Editing
import ErrorHandling
import MobileCoreServices
import SwiftUI
import DesignSystem
import UIKit

class ActionViewController: UIHostingController<ActionView> {
init() {
super.init(rootView: ActionView())
class ActionViewController: UIViewController {
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
embed(ActionHostingController())
}

override func loadView() {
view = UIView()
view.backgroundColor = .clear
view.isOpaque = false
}

override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
loadImageFromExtensionContext()
}

private func loadImageFromExtensionContext() {
print(#function)
let imageTypeIdentifier = (kUTTypeImage as String)

let imageProvider = extensionContext?
.inputItems
.compactMap { $0 as? NSExtensionItem }
.flatMap { $0.attachments ?? [] }
.first(where: { $0.hasItemConformingToTypeIdentifier(imageTypeIdentifier) })

imageProvider?.loadItem(forTypeIdentifier: imageTypeIdentifier, options: nil) { [weak self] item, error in
do {
guard let imageURL = (item as? URL) else { throw (error ?? ActionError.imageURLNotFound) }

let imageData = try Data(contentsOf: imageURL)
let imageDataString = imageData.base64EncodedString()
guard let callbackURL = URL(string: "highlighter://x-callback-url/open?imageData=\(imageDataString)") else { throw ActionError.callbackURLConstructionFailed }
self?.chain(selector: #selector(ActionViewController.openURL(_:)), object: callbackURL)
self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
} catch {
ErrorHandler().log(error)
}
}
}

// just to provide the selector
@objc private func openURL(_ url: URL) {}

@available(*, unavailable)
required init(coder: NSCoder) {
let className = String(describing: type(of: self))
fatalError("\(className) does not implement init(coder:)")
let typeName = NSStringFromClass(type(of: self))
fatalError("\(typeName) does not implement init(coder:)")
}
}

enum ActionError: Error {
case callbackURLConstructionFailed
case imageURLNotFound
case invalidImageData
}
2 changes: 1 addition & 1 deletion Automator/Sources/RedactOperation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ class RedactOperation: Operation, @unchecked Sendable {
let redactions = matchingObservations.map { Redaction($0, color: .black) }

guard let inputImage = input.image else { throw RedactActionExportError.noImageForInput }
let redactedImage = try await PhotoExportRenderer(image: inputImage, redactions: redactions).render()
let redactedImage = try await PhotoRenderer(image: inputImage, redactions: redactions).render()
let writeURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString, conformingTo: input.fileType ?? .png)

os_log("export representations: %{public}@", String(describing: redactedImage.representations))
Expand Down
20 changes: 20 additions & 0 deletions Modules/Capabilities/Exporting/Sources/CopyExporter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Created by Geoff Pado on 7/12/24.
// Copyright © 2024 Cocoatype, LLC. All rights reserved.

import Foundation
import Photos
import Redactions

public class CopyExporter: NSObject {
private let preparedURL: URL

public init(preparedURL: URL) {
self.preparedURL = preparedURL
}

public func export() async throws {
try await PHPhotoLibrary.shared().performChanges { [preparedURL] in
PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: preparedURL)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Created by Geoff Pado on 7/1/24.
// Copyright © 2024 Cocoatype, LLC. All rights reserved.

import Photos
import Redactions
import Rendering
import UIKit
import UniformTypeIdentifiers

public class ExportingActivityController: UIActivityViewController {
public init(image: UIImage, asset: PHAsset?, redactions: [Redaction]) async throws {
let preparer = ExportingPreparer(image: image, asset: asset, redactions: redactions)
let temporaryURL = try await preparer.preparedURL
super.init(activityItems: [temporaryURL], applicationActivities: Self.applicationActivities(asset: asset, redactions: redactions))
excludedActivityTypes = [.saveToCameraRoll]
}

static func applicationActivities(asset: PHAsset?, redactions: [Redaction]) -> [UIActivity] {
if let asset {
[SaveActivity(asset: asset, redactions: redactions), SaveCopyActivity()]
} else {
[SaveCopyActivity()]
}
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// Created by Geoff Pado on 7/1/24.
// Copyright © 2024 Cocoatype, LLC. All rights reserved.

enum SaveActivityError: Error {
enum ExportingError: Error {
case missingBundleVersion
case noActivityURL
case noDataGenerated
case noInputProvided
case failedImageDecode
case failedImageEncode
Expand Down
48 changes: 48 additions & 0 deletions Modules/Capabilities/Exporting/Sources/ExportingPreparer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Created by Geoff Pado on 7/12/24.
// Copyright © 2024 Cocoatype, LLC. All rights reserved.

import Photos
import Redactions
import Rendering
import UIKit
import UniformTypeIdentifiers

public class ExportingPreparer: NSObject {
private let image: UIImage
private let asset: PHAsset?
private let redactions: [Redaction]

public init(image: UIImage, asset: PHAsset?, redactions: [Redaction]) {
self.image = image
self.asset = asset
self.redactions = redactions
}

public var preparedURL: URL {
get async throws {
let exportedImage = try await PhotoRenderer.render(image, redactions: redactions)

let representedURLName = "\(ExportingStrings.PhotoEditingExporter.defaultImageName).\(imageType.preferredFilenameExtension ?? "png")"
let temporaryURL = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent(representedURLName)

let data: Data?

switch imageType {
case .jpeg:
data = exportedImage.jpegData(compressionQuality: 0.9)
default:
data = exportedImage.pngData()
}

guard let data else { throw ExportingError.noDataGenerated }
try data.write(to: temporaryURL)

return temporaryURL
}
}

var imageType: UTType {
asset?.imageType ?? image.imageType ?? .png
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Created by Geoff Pado on 7/12/24.
// Copyright © 2024 Cocoatype, LLC. All rights reserved.

import Photos

extension PHAsset {
var imageType: UTType? {
let resources = PHAssetResource.assetResources(for: self)

guard let resource = resources.first(where: { $0.type == .photo })
else { return nil }

return UTType(resource.uniformTypeIdentifier)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Created by Geoff Pado on 7/12/24.
// Copyright © 2024 Cocoatype, LLC. All rights reserved.

import Foundation
import Photos
import UniformTypeIdentifiers

extension PHContentEditingOutput {
var renderingInformation: (URL, UTType) {
guard #available(iOS 17, *),
let type = defaultRenderedContentType,
let typeURL = try? renderedContentURL(for: type)
else { return (renderedContentURL, .jpeg) }

return (typeURL, type)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ extension UIImage {
if #available(iOS 17, *) {
return heicData()
} else {
ErrorHandler().log(SaveActivityError.unexpectedHEIC)
ErrorHandler().log(ExportingError.unexpectedHEIC)
return jpegData(compressionQuality: 0.75)
}
default:
ErrorHandler().log(SaveActivityError.unexpectedEncodeType(type.identifier))
ErrorHandler().log(ExportingError.unexpectedEncodeType(type.identifier))
return jpegData(compressionQuality: 0.75)
}
}
Expand Down
50 changes: 50 additions & 0 deletions Modules/Capabilities/Exporting/Sources/InPlaceExporter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Created by Geoff Pado on 7/12/24.
// Copyright © 2024 Cocoatype, LLC. All rights reserved.

import ErrorHandling
import Foundation
import Photos
import Redactions
import UIKit

public class InPlaceExporter: NSObject {
private let preparedURL: URL
private let asset: PHAsset
private let redactions: [Redaction]

public init(preparedURL: URL, asset: PHAsset, redactions: [Redaction]) {
self.preparedURL = preparedURL
self.asset = asset
self.redactions = redactions
}

public func export() async throws {
return try await withCheckedThrowingContinuation { continuation in
asset.requestContentEditingInput(with: nil) { [weak self] contentEditingInput, _ in
Task { [weak self] in
do {
guard let self,
let input = contentEditingInput
else { throw ExportingError.noInputProvided }

let factory = OutputFactory(preparedURL: preparedURL, redactions: redactions)
let output = try factory.output(from: input)

try await PHPhotoLibrary.shared().performChanges { [asset] in
let changeRequest = PHAssetChangeRequest(for: asset)
changeRequest.contentEditingOutput = output
print("Change request created with contentEditingOutput: \(output)")
}

print("Changes successfully committed to the photo library.")
continuation.resume()
} catch {
ErrorHandler().log(error)
print("Error during performChanges: \(error)")
continuation.resume(throwing: error)
}
}
}
}
}
}
30 changes: 30 additions & 0 deletions Modules/Capabilities/Exporting/Sources/OutputFactory.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Created by Geoff Pado on 7/12/24.
// Copyright © 2024 Cocoatype, LLC. All rights reserved.

import Photos
import Redactions
import UIKit

public class OutputFactory {
private let preparedURL: URL
private let redactions: [Redaction]

public init(preparedURL: URL, redactions: [Redaction]) {
self.preparedURL = preparedURL
self.redactions = redactions
}

public func output(from input: PHContentEditingInput) throws -> PHContentEditingOutput {
let output = PHContentEditingOutput(contentEditingInput: input)
guard let formatVersion = Bundle.main.infoDictionary?["CFBundleVersion"] as? String else { throw ExportingError.missingBundleVersion }
let data = try JSONEncoder().encode(SaveActivityAdjustmentData(redactions: redactions))
output.adjustmentData = PHAdjustmentData(formatIdentifier: SaveActivityAdjustmentData.formatIdentifier, formatVersion: formatVersion, data: data)
let (renderedContentURL, renderType) = output.renderingInformation

guard let image = UIImage(contentsOfFile: preparedURL.path) else { throw ExportingError.failedImageDecode }
guard let data = image.encoded(as: renderType) else { throw ExportingError.failedImageEncode }

try data.write(to: renderedContentURL)
return output
}
}
Loading

0 comments on commit 0bfd8d0

Please sign in to comment.