Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 22 additions & 13 deletions Demo/Demo/Gravatar-Demo/DemoUploadImageViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: "")

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)"
}
Expand Down Expand Up @@ -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)
})
Expand All @@ -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"
}
}
}
4 changes: 2 additions & 2 deletions Sources/Gravatar/Extensions/URL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
19 changes: 13 additions & 6 deletions Sources/Gravatar/Network/Services/AvatarService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down
47 changes: 36 additions & 11 deletions Sources/Gravatar/Network/Services/ImageUploadService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)]
}
}
}
18 changes: 18 additions & 0 deletions Sources/Gravatar/Network/Services/ImageUploader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
66 changes: 66 additions & 0 deletions Sources/Gravatar/Options/AvatarSelection.swift
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
8 changes: 4 additions & 4 deletions Tests/GravatarTests/AvatarServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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.
Expand All @@ -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)
Expand Down