diff --git a/Demo/Demo/Gravatar-Demo/DemoUploadImageViewController.swift b/Demo/Demo/Gravatar-Demo/DemoUploadImageViewController.swift index fc9a7471..84f498a6 100644 --- a/Demo/Demo/Gravatar-Demo/DemoUploadImageViewController.swift +++ b/Demo/Demo/Gravatar-Demo/DemoUploadImageViewController.swift @@ -3,8 +3,14 @@ import Gravatar import Combine class DemoUploadImageViewController: BaseFormViewController { - let emailFormField = TextFormField(placeholder: "Email", keyboardType: .emailAddress) - let tokenFormField = TextFormField(placeholder: "Token", isSecure: true) + @StoredValue(keyName: "QEEmailKey", defaultValue: "") + var savedEmail: String + + @StoredValue(keyName: "QETokenKey", defaultValue: "") + var savedToken: String + + lazy var emailFormField = TextFormField(placeholder: "Email", text: savedEmail, keyboardType: .emailAddress) + lazy var tokenFormField = TextFormField(placeholder: "Token", text: savedToken, isSecure: true) let avatarImageField = ImageFormField(size: .init(width: 300, height: 300)) let resultField = LabelField(title: "", subtitle: "") @@ -41,7 +47,7 @@ class DemoUploadImageViewController: BaseFormViewController { } private let activityIndicator = UIActivityIndicatorView(style: .large) - private var avatarSelectionBehavior: AvatarSelection = .preserveSelection + private var avatarSelectionPolicy: AvatarUploadSelectionPolicy = .preserveSelection override func viewDidLoad() { super.viewDidLoad() @@ -89,10 +95,10 @@ class DemoUploadImageViewController: BaseFormViewController { do { let avatarModel = try await service.upload( image, - selectionBehavior: avatarSelectionBehavior, + selectionPolicy: avatarSelectionPolicy, accessToken: token ) - resultField.subtitle = "✅ Avatar id \(avatarModel.id)" + resultField.subtitle = "✅ Avatar id \(avatarModel.imageID)" } catch { resultField.subtitle = "Error \((error as NSError).code): \(error.localizedDescription)" } @@ -136,11 +142,10 @@ extension DemoUploadImageViewController: UIImagePickerControllerDelegate, UINavi @objc private func setAvatarSelectionMethod(with email: String, sender: UIView?) { let controller = UIAlertController(title: "Avatar selection behavior:", message: nil, preferredStyle: .actionSheet) - - AvatarSelection.allCases(for: .init(email)).forEach { selectionCase in + AvatarUploadSelectionPolicy.allCases(for: .email(Email(email))).forEach { selectionCase in controller.addAction(UIAlertAction(title: selectionCase.description, style: .default) { [weak self] action in guard let self else { return } - avatarSelectionBehavior = selectionCase + avatarSelectionPolicy = selectionCase backendSelectionBehaviorButtonField.subtitle = selectionCase.description update(backendSelectionBehaviorButtonField) }) @@ -153,12 +158,16 @@ extension DemoUploadImageViewController: UIImagePickerControllerDelegate, UINavi } } -extension AvatarSelection { +extension AvatarUploadSelectionPolicy { var description: String { - switch self { - case .selectUploadedImage: return "Select uploaded image" - case .preserveSelection: return "Preserve selection" - case .selectUploadedImageIfNoneSelected: return "Select uploaded image if none selected" + if isSelectUploadedImagePolicy { + "Select uploaded image" + } else if isPreserveSelectionPolicy { + "Preserve selection" + } else if isSelectUploadedImageIfNoneSelectedPolicy { + "Select uploaded image if none selected" + } else { + "Unknown option" } } } diff --git a/Sources/Gravatar/Extensions/URL.swift b/Sources/Gravatar/Extensions/URL.swift index f609cd80..ce6d51d3 100644 --- a/Sources/Gravatar/Extensions/URL.swift +++ b/Sources/Gravatar/Extensions/URL.swift @@ -23,8 +23,8 @@ extension URL { && components.scheme == "https" } - func appendingQueryItems(for selectionBehavior: AvatarSelection) -> URL { - let queryItems = selectionBehavior.queryItems + func appendingQueryItems(for selectionPolicy: AvatarUploadSelectionPolicy) -> URL { + let queryItems = selectionPolicy.queryItems if #available(iOS 16.0, *) { return self.appending(queryItems: queryItems) } else { diff --git a/Sources/Gravatar/Network/Services/AvatarService.swift b/Sources/Gravatar/Network/Services/AvatarService.swift index 4c7f5b8d..f45b11ab 100644 --- a/Sources/Gravatar/Network/Services/AvatarService.swift +++ b/Sources/Gravatar/Network/Services/AvatarService.swift @@ -50,15 +50,22 @@ public struct AvatarService: Sendable { /// - accessToken: The authentication token for the user. This is a Gravatar OAuth2 access token. /// - Returns: An asynchronously-delivered `AvatarType` instance, containing data of the newly created avatar. @discardableResult + @available(*, deprecated, message: "Use `upload(_:accessToken:selectionBehavior:)` instead") public func upload(_ image: UIImage, selectionBehavior: AvatarSelection, accessToken: String) async throws -> AvatarType { - let avatar: Avatar = try await upload(image, accessToken: accessToken, selectionBehavior: selectionBehavior) + let avatar: Avatar = try await upload(image, accessToken: accessToken, selectionPolicy: selectionBehavior.map()) return avatar } + /// Uploads an image to be used as the user's Gravatar profile image, and returns the `URLResponse` of the network tasks asynchronously. Throws + /// ``ImageUploadError``. + /// - Parameters: + /// - image: The image to be uploaded. + /// - selectionPolicy: How to handle avatar selection after uploading a new avatar + /// - accessToken: The authentication token for the user. This is a Gravatar OAuth2 access token. + /// - Returns: An asynchronously-delivered `AvatarType` instance, containing data of the newly created avatar. @discardableResult - package func upload(_ image: UIImage, accessToken: String, selectionBehavior: AvatarSelection) async throws -> AvatarDetails { - let avatar: Avatar = try await upload(image, accessToken: accessToken, selectionBehavior: selectionBehavior) - return avatar + public func upload(_ image: UIImage, selectionPolicy: AvatarUploadSelectionPolicy, accessToken: String) async throws -> AvatarDetails { + try await upload(image, accessToken: accessToken, selectionPolicy: selectionPolicy) } /// Uploads an image to be used as the user's Gravatar profile image, and returns the `URLResponse` of the network tasks asynchronously. Throws @@ -69,12 +76,12 @@ public struct AvatarService: Sendable { /// - avatarSelection: How to handle avatar selection after uploading a new avatar /// - Returns: An asynchronously-delivered `Avatar` instance, containing data of the newly created avatar. @discardableResult - private func upload(_ image: UIImage, accessToken: String, selectionBehavior: AvatarSelection) async throws -> Avatar { + private func upload(_ image: UIImage, accessToken: String, selectionPolicy: AvatarUploadSelectionPolicy) async throws -> Avatar { do { let (data, _) = try await imageUploader.uploadImage( image.squared(), accessToken: accessToken, - avatarSelection: selectionBehavior, + avatarSelectionPolicy: selectionPolicy, additionalHTTPHeaders: nil ) let avatar: Avatar = try data.decode() diff --git a/Sources/Gravatar/Network/Services/ImageUploadService.swift b/Sources/Gravatar/Network/Services/ImageUploadService.swift index 31a43adb..e256e21e 100644 --- a/Sources/Gravatar/Network/Services/ImageUploadService.swift +++ b/Sources/Gravatar/Network/Services/ImageUploadService.swift @@ -11,7 +11,27 @@ struct ImageUploadService: ImageUploader { self.client = URLSessionHTTPClient(urlSession: urlSession) } + func uploadImage( + _ image: UIImage, + accessToken: String, + avatarSelectionPolicy selectionPolicy: AvatarUploadSelectionPolicy, + additionalHTTPHeaders: [HTTPHeaderField]? + ) async throws -> (data: Data, response: HTTPURLResponse) { + guard let data: Data = { + if #available(iOS 17.0, *) { + image.heicData() + } else { + image.jpegData(compressionQuality: 0.8) + } + }() else { + throw ImageUploadError.cannotConvertImageIntoData + } + + return try await uploadImage(data: data, accessToken: accessToken, selectionPolicy: selectionPolicy, additionalHTTPHeaders: additionalHTTPHeaders) + } + @discardableResult + @available(*, deprecated, message: "Use `uploadImage(_:accessToken:avatarSelectionPolicy:additionalHTTPHeaders:)` instead.") func uploadImage( _ image: UIImage, accessToken: String, @@ -28,20 +48,25 @@ struct ImageUploadService: ImageUploader { throw ImageUploadError.cannotConvertImageIntoData } - return try await uploadImage(data: data, accessToken: accessToken, avatarSelection: avatarSelection, additionalHTTPHeaders: additionalHTTPHeaders) + return try await uploadImage( + data: data, + accessToken: accessToken, + selectionPolicy: avatarSelection.map(), + additionalHTTPHeaders: additionalHTTPHeaders + ) } private func uploadImage( data: Data, accessToken: String, - avatarSelection: AvatarSelection, + selectionPolicy: AvatarUploadSelectionPolicy, additionalHTTPHeaders: [HTTPHeaderField]? ) async throws -> (Data, HTTPURLResponse) { let boundary = "\(UUID().uuidString)" let request = URLRequest.imageUploadRequest( with: boundary, additionalHTTPHeaders: additionalHTTPHeaders, - selectionBehavior: avatarSelection + selectionPolicy: selectionPolicy ).settingAuthorizationHeaderField(with: accessToken) let body = imageUploadBody(with: data, boundary: boundary) @@ -89,9 +114,9 @@ extension URLRequest { fileprivate static func imageUploadRequest( with boundary: String, additionalHTTPHeaders: [HTTPHeaderField]?, - selectionBehavior: AvatarSelection + selectionPolicy: AvatarUploadSelectionPolicy ) -> URLRequest { - var request = URLRequest(url: .avatarsURL.appendingQueryItems(for: selectionBehavior)) + var request = URLRequest(url: .avatarsURL.appendingQueryItems(for: selectionPolicy)) request.addValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") request.httpMethod = "POST" additionalHTTPHeaders?.forEach { headerTuple in @@ -101,18 +126,18 @@ extension URLRequest { } } -extension AvatarSelection { +extension AvatarUploadSelectionPolicy { var queryItems: [URLQueryItem] { - switch self { - case .selectUploadedImage(let email): + switch policy { + case .selectUploadedImage(let profileID): [ .init(name: "select_avatar", value: "true"), - .init(name: "selected_email_hash", value: email.id), + .init(name: "selected_email_hash", value: profileID.id), ] case .preserveSelection: [.init(name: "select_avatar", value: "false")] - case .selectUploadedImageIfNoneSelected(let email): - [.init(name: "selected_email_hash", value: email.id)] + case .selectUploadedImageIfNoneSelected(let profileID): + [.init(name: "selected_email_hash", value: profileID.id)] } } } diff --git a/Sources/Gravatar/Network/Services/ImageUploader.swift b/Sources/Gravatar/Network/Services/ImageUploader.swift index bdcbe54d..08eab6a9 100644 --- a/Sources/Gravatar/Network/Services/ImageUploader.swift +++ b/Sources/Gravatar/Network/Services/ImageUploader.swift @@ -14,10 +14,28 @@ protocol ImageUploader: Sendable { /// - additionalHTTPHeaders: Additional headers to add. /// - Returns: An asynchronously-delivered `URLResponse` instance, containing the response of the upload network task. @discardableResult + @available(*, deprecated, renamed: "uploadImage(_:accessToken:avatarSelectionPolicy:additionalHTTPHeaders:)") func uploadImage( _ image: UIImage, accessToken: String, avatarSelection: AvatarSelection, additionalHTTPHeaders: [HTTPHeaderField]? ) async throws -> (data: Data, response: HTTPURLResponse) + + /// Uploads an image to be used as the user's Gravatar profile image, and returns the `URLResponse` of the network tasks asynchronously. Throws + /// `ImageUploadError`. + /// - Parameters: + /// - image: The image to be uploaded. + /// - email: The user email account. + /// - accessToken: The authentication token for the user. + /// - avatarSelectionPolicy: How to handle avatar selection after uploading a new avatar + /// - additionalHTTPHeaders: Additional headers to add. + /// - Returns: An asynchronously-delivered `URLResponse` instance, containing the response of the upload network task. + @discardableResult + func uploadImage( + _ image: UIImage, + accessToken: String, + avatarSelectionPolicy: AvatarUploadSelectionPolicy, + additionalHTTPHeaders: [HTTPHeaderField]? + ) async throws -> (data: Data, response: HTTPURLResponse) } diff --git a/Sources/Gravatar/Options/AvatarSelection.swift b/Sources/Gravatar/Options/AvatarSelection.swift index 8d5006b7..01f1cad6 100644 --- a/Sources/Gravatar/Options/AvatarSelection.swift +++ b/Sources/Gravatar/Options/AvatarSelection.swift @@ -1,4 +1,5 @@ /// Defines how to handle avatar selection after uploading a new avatar +@available(*, deprecated, renamed: "AvatarUploadSelectionPolicy") public enum AvatarSelection: Equatable, Sendable { case preserveSelection case selectUploadedImage(for: Email) @@ -11,4 +12,69 @@ public enum AvatarSelection: Equatable, Sendable { .selectUploadedImageIfNoneSelected(for: email), ] } + + func map() -> AvatarUploadSelectionPolicy { + switch self { + case .preserveSelection: + .preserveSelection + case .selectUploadedImage(let email): + .selectUploadedImage(for: .email(email)) + case .selectUploadedImageIfNoneSelected(let email): + .selectUploadedImageIfNoneSelected(for: .email(email)) + } + } +} + +/// Determines if the uploaded image should be set as the avatar for the profile. +public struct AvatarUploadSelectionPolicy: Equatable, Sendable { + enum SelectionPolicy: Equatable, Sendable { + case preserveSelection + case selectUploadedImage(for: ProfileIdentifier) + case selectUploadedImageIfNoneSelected(for: ProfileIdentifier) + } + + let policy: SelectionPolicy + + // Do not set the uploaded image as the avatar for the profile. + public static let preserveSelection: AvatarUploadSelectionPolicy = .init(policy: .preserveSelection) + // Set the uploaded image as the avatar for the profile. + public static func selectUploadedImage(for profileID: ProfileIdentifier) -> AvatarUploadSelectionPolicy { + .init(policy: .selectUploadedImage(for: profileID)) + } + + // Set the uploaded image as the avatar for the profile only if there was no other avatar previously selected. + public static func selectUploadedImageIfNoneSelected(for profileID: ProfileIdentifier) -> AvatarUploadSelectionPolicy { + .init(policy: .selectUploadedImageIfNoneSelected(for: profileID)) + } + + /// A list of all policies available, set up with the given profile ID. + /// - Parameter profileID: The user's profile ID + /// - Returns: A list of all policies available + public static func allCases(for profileID: ProfileIdentifier) -> [AvatarUploadSelectionPolicy] { + [ + .preserveSelection, + .selectUploadedImage(for: profileID), + .selectUploadedImageIfNoneSelected(for: profileID), + ] + } + + public var isPreserveSelectionPolicy: Bool { + policy == .preserveSelection + } + + public var isSelectUploadedImagePolicy: Bool { + switch policy { + case .selectUploadedImage: + true + default: false + } + } + + public var isSelectUploadedImageIfNoneSelectedPolicy: Bool { + switch policy { + case .selectUploadedImageIfNoneSelected: + true + default: false + } + } } diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift index 031d8e37..09077b6c 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift @@ -307,8 +307,8 @@ class AvatarPickerViewModel: ObservableObject { do { let avatar = try await avatarService.upload( squareImage, - accessToken: accessToken, - selectionBehavior: .selectUploadedImageIfNoneSelected(for: email) + selectionPolicy: .selectUploadedImageIfNoneSelected(for: .email(email)), + accessToken: accessToken ) ImageCache.shared.setEntry(.ready(squareImage), for: avatar.imageURL) diff --git a/Tests/GravatarTests/AvatarServiceTests.swift b/Tests/GravatarTests/AvatarServiceTests.swift index 2ec15d6b..a49ebbb0 100644 --- a/Tests/GravatarTests/AvatarServiceTests.swift +++ b/Tests/GravatarTests/AvatarServiceTests.swift @@ -25,9 +25,9 @@ final class AvatarServiceTests: XCTestCase { let sessionMock = URLSessionMock(returnData: Bundle.imageUploadJsonData!, response: successResponse) let service = avatarService(with: sessionMock) - let avatar = try await service.upload(ImageHelper.testImage, selectionBehavior: .preserveSelection, accessToken: "AccessToken") + let avatar = try await service.upload(ImageHelper.testImage, selectionPolicy: .preserveSelection, accessToken: "AccessToken") - XCTAssertEqual(avatar.id, "6f3eac1c67f970f2a0c2ea8") + XCTAssertEqual(avatar.imageID, "6f3eac1c67f970f2a0c2ea8") let request = await sessionMock.request XCTAssertEqual(request?.url?.absoluteString, "https://api.gravatar.com/v3/me/avatars?select_avatar=false") @@ -44,7 +44,7 @@ final class AvatarServiceTests: XCTestCase { let service = avatarService(with: sessionMock) do { - try await service.upload(ImageHelper.testImage, selectionBehavior: .preserveSelection, accessToken: "AccessToken") + try await service.upload(ImageHelper.testImage, selectionPolicy: .preserveSelection, accessToken: "AccessToken") XCTFail("This should throw an error") } catch ImageUploadError.responseError(reason: let reason) where reason.httpStatusCode == responseCode { // Expected error has occurred. @@ -59,7 +59,7 @@ final class AvatarServiceTests: XCTestCase { let service = avatarService(with: sessionMock) do { - try await service.upload(UIImage(), selectionBehavior: .preserveSelection, accessToken: "AccessToken") + try await service.upload(UIImage(), selectionPolicy: .preserveSelection, accessToken: "AccessToken") XCTFail("This should throw an error") } catch let error as ImageUploadError { XCTAssertEqual(error, ImageUploadError.cannotConvertImageIntoData)