Skip to content

Commit 342e40c

Browse files
authored
Use ProfileID instead of Email for image upload option (#792)
1 parent f32fe83 commit 342e40c

File tree

8 files changed

+163
-38
lines changed

8 files changed

+163
-38
lines changed

Demo/Demo/Gravatar-Demo/DemoUploadImageViewController.swift

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,14 @@ import Gravatar
33
import Combine
44

55
class DemoUploadImageViewController: BaseFormViewController {
6-
let emailFormField = TextFormField(placeholder: "Email", keyboardType: .emailAddress)
7-
let tokenFormField = TextFormField(placeholder: "Token", isSecure: true)
6+
@StoredValue(keyName: "QEEmailKey", defaultValue: "")
7+
var savedEmail: String
8+
9+
@StoredValue(keyName: "QETokenKey", defaultValue: "")
10+
var savedToken: String
11+
12+
lazy var emailFormField = TextFormField(placeholder: "Email", text: savedEmail, keyboardType: .emailAddress)
13+
lazy var tokenFormField = TextFormField(placeholder: "Token", text: savedToken, isSecure: true)
814
let avatarImageField = ImageFormField(size: .init(width: 300, height: 300))
915
let resultField = LabelField(title: "", subtitle: "")
1016

@@ -41,7 +47,7 @@ class DemoUploadImageViewController: BaseFormViewController {
4147
}
4248

4349
private let activityIndicator = UIActivityIndicatorView(style: .large)
44-
private var avatarSelectionBehavior: AvatarSelection = .preserveSelection
50+
private var avatarSelectionPolicy: AvatarUploadSelectionPolicy = .preserveSelection
4551

4652
override func viewDidLoad() {
4753
super.viewDidLoad()
@@ -89,10 +95,10 @@ class DemoUploadImageViewController: BaseFormViewController {
8995
do {
9096
let avatarModel = try await service.upload(
9197
image,
92-
selectionBehavior: avatarSelectionBehavior,
98+
selectionPolicy: avatarSelectionPolicy,
9399
accessToken: token
94100
)
95-
resultField.subtitle = "✅ Avatar id \(avatarModel.id)"
101+
resultField.subtitle = "✅ Avatar id \(avatarModel.imageID)"
96102
} catch {
97103
resultField.subtitle = "Error \((error as NSError).code): \(error.localizedDescription)"
98104
}
@@ -136,11 +142,10 @@ extension DemoUploadImageViewController: UIImagePickerControllerDelegate, UINavi
136142
@objc private func setAvatarSelectionMethod(with email: String, sender: UIView?) {
137143
let controller = UIAlertController(title: "Avatar selection behavior:", message: nil, preferredStyle: .actionSheet)
138144

139-
140-
AvatarSelection.allCases(for: .init(email)).forEach { selectionCase in
145+
AvatarUploadSelectionPolicy.allCases(for: .email(Email(email))).forEach { selectionCase in
141146
controller.addAction(UIAlertAction(title: selectionCase.description, style: .default) { [weak self] action in
142147
guard let self else { return }
143-
avatarSelectionBehavior = selectionCase
148+
avatarSelectionPolicy = selectionCase
144149
backendSelectionBehaviorButtonField.subtitle = selectionCase.description
145150
update(backendSelectionBehaviorButtonField)
146151
})
@@ -153,12 +158,16 @@ extension DemoUploadImageViewController: UIImagePickerControllerDelegate, UINavi
153158
}
154159
}
155160

156-
extension AvatarSelection {
161+
extension AvatarUploadSelectionPolicy {
157162
var description: String {
158-
switch self {
159-
case .selectUploadedImage: return "Select uploaded image"
160-
case .preserveSelection: return "Preserve selection"
161-
case .selectUploadedImageIfNoneSelected: return "Select uploaded image if none selected"
163+
if isSelectUploadedImagePolicy {
164+
"Select uploaded image"
165+
} else if isPreserveSelectionPolicy {
166+
"Preserve selection"
167+
} else if isSelectUploadedImageIfNoneSelectedPolicy {
168+
"Select uploaded image if none selected"
169+
} else {
170+
"Unknown option"
162171
}
163172
}
164173
}

Sources/Gravatar/Extensions/URL.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ extension URL {
2323
&& components.scheme == "https"
2424
}
2525

26-
func appendingQueryItems(for selectionBehavior: AvatarSelection) -> URL {
27-
let queryItems = selectionBehavior.queryItems
26+
func appendingQueryItems(for selectionPolicy: AvatarUploadSelectionPolicy) -> URL {
27+
let queryItems = selectionPolicy.queryItems
2828
if #available(iOS 16.0, *) {
2929
return self.appending(queryItems: queryItems)
3030
} else {

Sources/Gravatar/Network/Services/AvatarService.swift

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,15 +50,22 @@ public struct AvatarService: Sendable {
5050
/// - accessToken: The authentication token for the user. This is a Gravatar OAuth2 access token.
5151
/// - Returns: An asynchronously-delivered `AvatarType` instance, containing data of the newly created avatar.
5252
@discardableResult
53+
@available(*, deprecated, message: "Use `upload(_:accessToken:selectionBehavior:)` instead")
5354
public func upload(_ image: UIImage, selectionBehavior: AvatarSelection, accessToken: String) async throws -> AvatarType {
54-
let avatar: Avatar = try await upload(image, accessToken: accessToken, selectionBehavior: selectionBehavior)
55+
let avatar: Avatar = try await upload(image, accessToken: accessToken, selectionPolicy: selectionBehavior.map())
5556
return avatar
5657
}
5758

59+
/// Uploads an image to be used as the user's Gravatar profile image, and returns the `URLResponse` of the network tasks asynchronously. Throws
60+
/// ``ImageUploadError``.
61+
/// - Parameters:
62+
/// - image: The image to be uploaded.
63+
/// - selectionPolicy: How to handle avatar selection after uploading a new avatar
64+
/// - accessToken: The authentication token for the user. This is a Gravatar OAuth2 access token.
65+
/// - Returns: An asynchronously-delivered `AvatarType` instance, containing data of the newly created avatar.
5866
@discardableResult
59-
package func upload(_ image: UIImage, accessToken: String, selectionBehavior: AvatarSelection) async throws -> AvatarDetails {
60-
let avatar: Avatar = try await upload(image, accessToken: accessToken, selectionBehavior: selectionBehavior)
61-
return avatar
67+
public func upload(_ image: UIImage, selectionPolicy: AvatarUploadSelectionPolicy, accessToken: String) async throws -> AvatarDetails {
68+
try await upload(image, accessToken: accessToken, selectionPolicy: selectionPolicy)
6269
}
6370

6471
/// 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 {
6976
/// - avatarSelection: How to handle avatar selection after uploading a new avatar
7077
/// - Returns: An asynchronously-delivered `Avatar` instance, containing data of the newly created avatar.
7178
@discardableResult
72-
private func upload(_ image: UIImage, accessToken: String, selectionBehavior: AvatarSelection) async throws -> Avatar {
79+
private func upload(_ image: UIImage, accessToken: String, selectionPolicy: AvatarUploadSelectionPolicy) async throws -> Avatar {
7380
do {
7481
let (data, _) = try await imageUploader.uploadImage(
7582
image.squared(),
7683
accessToken: accessToken,
77-
avatarSelection: selectionBehavior,
84+
avatarSelectionPolicy: selectionPolicy,
7885
additionalHTTPHeaders: nil
7986
)
8087
let avatar: Avatar = try data.decode()

Sources/Gravatar/Network/Services/ImageUploadService.swift

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,27 @@ struct ImageUploadService: ImageUploader {
1111
self.client = URLSessionHTTPClient(urlSession: urlSession)
1212
}
1313

14+
func uploadImage(
15+
_ image: UIImage,
16+
accessToken: String,
17+
avatarSelectionPolicy selectionPolicy: AvatarUploadSelectionPolicy,
18+
additionalHTTPHeaders: [HTTPHeaderField]?
19+
) async throws -> (data: Data, response: HTTPURLResponse) {
20+
guard let data: Data = {
21+
if #available(iOS 17.0, *) {
22+
image.heicData()
23+
} else {
24+
image.jpegData(compressionQuality: 0.8)
25+
}
26+
}() else {
27+
throw ImageUploadError.cannotConvertImageIntoData
28+
}
29+
30+
return try await uploadImage(data: data, accessToken: accessToken, selectionPolicy: selectionPolicy, additionalHTTPHeaders: additionalHTTPHeaders)
31+
}
32+
1433
@discardableResult
34+
@available(*, deprecated, message: "Use `uploadImage(_:accessToken:avatarSelectionPolicy:additionalHTTPHeaders:)` instead.")
1535
func uploadImage(
1636
_ image: UIImage,
1737
accessToken: String,
@@ -28,20 +48,25 @@ struct ImageUploadService: ImageUploader {
2848
throw ImageUploadError.cannotConvertImageIntoData
2949
}
3050

31-
return try await uploadImage(data: data, accessToken: accessToken, avatarSelection: avatarSelection, additionalHTTPHeaders: additionalHTTPHeaders)
51+
return try await uploadImage(
52+
data: data,
53+
accessToken: accessToken,
54+
selectionPolicy: avatarSelection.map(),
55+
additionalHTTPHeaders: additionalHTTPHeaders
56+
)
3257
}
3358

3459
private func uploadImage(
3560
data: Data,
3661
accessToken: String,
37-
avatarSelection: AvatarSelection,
62+
selectionPolicy: AvatarUploadSelectionPolicy,
3863
additionalHTTPHeaders: [HTTPHeaderField]?
3964
) async throws -> (Data, HTTPURLResponse) {
4065
let boundary = "\(UUID().uuidString)"
4166
let request = URLRequest.imageUploadRequest(
4267
with: boundary,
4368
additionalHTTPHeaders: additionalHTTPHeaders,
44-
selectionBehavior: avatarSelection
69+
selectionPolicy: selectionPolicy
4570
).settingAuthorizationHeaderField(with: accessToken)
4671

4772
let body = imageUploadBody(with: data, boundary: boundary)
@@ -89,9 +114,9 @@ extension URLRequest {
89114
fileprivate static func imageUploadRequest(
90115
with boundary: String,
91116
additionalHTTPHeaders: [HTTPHeaderField]?,
92-
selectionBehavior: AvatarSelection
117+
selectionPolicy: AvatarUploadSelectionPolicy
93118
) -> URLRequest {
94-
var request = URLRequest(url: .avatarsURL.appendingQueryItems(for: selectionBehavior))
119+
var request = URLRequest(url: .avatarsURL.appendingQueryItems(for: selectionPolicy))
95120
request.addValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
96121
request.httpMethod = "POST"
97122
additionalHTTPHeaders?.forEach { headerTuple in
@@ -101,18 +126,18 @@ extension URLRequest {
101126
}
102127
}
103128

104-
extension AvatarSelection {
129+
extension AvatarUploadSelectionPolicy {
105130
var queryItems: [URLQueryItem] {
106-
switch self {
107-
case .selectUploadedImage(let email):
131+
switch policy {
132+
case .selectUploadedImage(let profileID):
108133
[
109134
.init(name: "select_avatar", value: "true"),
110-
.init(name: "selected_email_hash", value: email.id),
135+
.init(name: "selected_email_hash", value: profileID.id),
111136
]
112137
case .preserveSelection:
113138
[.init(name: "select_avatar", value: "false")]
114-
case .selectUploadedImageIfNoneSelected(let email):
115-
[.init(name: "selected_email_hash", value: email.id)]
139+
case .selectUploadedImageIfNoneSelected(let profileID):
140+
[.init(name: "selected_email_hash", value: profileID.id)]
116141
}
117142
}
118143
}

Sources/Gravatar/Network/Services/ImageUploader.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,28 @@ protocol ImageUploader: Sendable {
1414
/// - additionalHTTPHeaders: Additional headers to add.
1515
/// - Returns: An asynchronously-delivered `URLResponse` instance, containing the response of the upload network task.
1616
@discardableResult
17+
@available(*, deprecated, renamed: "uploadImage(_:accessToken:avatarSelectionPolicy:additionalHTTPHeaders:)")
1718
func uploadImage(
1819
_ image: UIImage,
1920
accessToken: String,
2021
avatarSelection: AvatarSelection,
2122
additionalHTTPHeaders: [HTTPHeaderField]?
2223
) async throws -> (data: Data, response: HTTPURLResponse)
24+
25+
/// Uploads an image to be used as the user's Gravatar profile image, and returns the `URLResponse` of the network tasks asynchronously. Throws
26+
/// `ImageUploadError`.
27+
/// - Parameters:
28+
/// - image: The image to be uploaded.
29+
/// - email: The user email account.
30+
/// - accessToken: The authentication token for the user.
31+
/// - avatarSelectionPolicy: How to handle avatar selection after uploading a new avatar
32+
/// - additionalHTTPHeaders: Additional headers to add.
33+
/// - Returns: An asynchronously-delivered `URLResponse` instance, containing the response of the upload network task.
34+
@discardableResult
35+
func uploadImage(
36+
_ image: UIImage,
37+
accessToken: String,
38+
avatarSelectionPolicy: AvatarUploadSelectionPolicy,
39+
additionalHTTPHeaders: [HTTPHeaderField]?
40+
) async throws -> (data: Data, response: HTTPURLResponse)
2341
}

Sources/Gravatar/Options/AvatarSelection.swift

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/// Defines how to handle avatar selection after uploading a new avatar
2+
@available(*, deprecated, renamed: "AvatarUploadSelectionPolicy")
23
public enum AvatarSelection: Equatable, Sendable {
34
case preserveSelection
45
case selectUploadedImage(for: Email)
@@ -11,4 +12,69 @@ public enum AvatarSelection: Equatable, Sendable {
1112
.selectUploadedImageIfNoneSelected(for: email),
1213
]
1314
}
15+
16+
func map() -> AvatarUploadSelectionPolicy {
17+
switch self {
18+
case .preserveSelection:
19+
.preserveSelection
20+
case .selectUploadedImage(let email):
21+
.selectUploadedImage(for: .email(email))
22+
case .selectUploadedImageIfNoneSelected(let email):
23+
.selectUploadedImageIfNoneSelected(for: .email(email))
24+
}
25+
}
26+
}
27+
28+
/// Determines if the uploaded image should be set as the avatar for the profile.
29+
public struct AvatarUploadSelectionPolicy: Equatable, Sendable {
30+
enum SelectionPolicy: Equatable, Sendable {
31+
case preserveSelection
32+
case selectUploadedImage(for: ProfileIdentifier)
33+
case selectUploadedImageIfNoneSelected(for: ProfileIdentifier)
34+
}
35+
36+
let policy: SelectionPolicy
37+
38+
// Do not set the uploaded image as the avatar for the profile.
39+
public static let preserveSelection: AvatarUploadSelectionPolicy = .init(policy: .preserveSelection)
40+
// Set the uploaded image as the avatar for the profile.
41+
public static func selectUploadedImage(for profileID: ProfileIdentifier) -> AvatarUploadSelectionPolicy {
42+
.init(policy: .selectUploadedImage(for: profileID))
43+
}
44+
45+
// Set the uploaded image as the avatar for the profile only if there was no other avatar previously selected.
46+
public static func selectUploadedImageIfNoneSelected(for profileID: ProfileIdentifier) -> AvatarUploadSelectionPolicy {
47+
.init(policy: .selectUploadedImageIfNoneSelected(for: profileID))
48+
}
49+
50+
/// A list of all policies available, set up with the given profile ID.
51+
/// - Parameter profileID: The user's profile ID
52+
/// - Returns: A list of all policies available
53+
public static func allCases(for profileID: ProfileIdentifier) -> [AvatarUploadSelectionPolicy] {
54+
[
55+
.preserveSelection,
56+
.selectUploadedImage(for: profileID),
57+
.selectUploadedImageIfNoneSelected(for: profileID),
58+
]
59+
}
60+
61+
public var isPreserveSelectionPolicy: Bool {
62+
policy == .preserveSelection
63+
}
64+
65+
public var isSelectUploadedImagePolicy: Bool {
66+
switch policy {
67+
case .selectUploadedImage:
68+
true
69+
default: false
70+
}
71+
}
72+
73+
public var isSelectUploadedImageIfNoneSelectedPolicy: Bool {
74+
switch policy {
75+
case .selectUploadedImageIfNoneSelected:
76+
true
77+
default: false
78+
}
79+
}
1480
}

Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,8 +307,8 @@ class AvatarPickerViewModel: ObservableObject {
307307
do {
308308
let avatar = try await avatarService.upload(
309309
squareImage,
310-
accessToken: accessToken,
311-
selectionBehavior: .selectUploadedImageIfNoneSelected(for: email)
310+
selectionPolicy: .selectUploadedImageIfNoneSelected(for: .email(email)),
311+
accessToken: accessToken
312312
)
313313
ImageCache.shared.setEntry(.ready(squareImage), for: avatar.imageURL)
314314

Tests/GravatarTests/AvatarServiceTests.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,9 @@ final class AvatarServiceTests: XCTestCase {
2525
let sessionMock = URLSessionMock(returnData: Bundle.imageUploadJsonData!, response: successResponse)
2626
let service = avatarService(with: sessionMock)
2727

28-
let avatar = try await service.upload(ImageHelper.testImage, selectionBehavior: .preserveSelection, accessToken: "AccessToken")
28+
let avatar = try await service.upload(ImageHelper.testImage, selectionPolicy: .preserveSelection, accessToken: "AccessToken")
2929

30-
XCTAssertEqual(avatar.id, "6f3eac1c67f970f2a0c2ea8")
30+
XCTAssertEqual(avatar.imageID, "6f3eac1c67f970f2a0c2ea8")
3131

3232
let request = await sessionMock.request
3333
XCTAssertEqual(request?.url?.absoluteString, "https://api.gravatar.com/v3/me/avatars?select_avatar=false")
@@ -44,7 +44,7 @@ final class AvatarServiceTests: XCTestCase {
4444
let service = avatarService(with: sessionMock)
4545

4646
do {
47-
try await service.upload(ImageHelper.testImage, selectionBehavior: .preserveSelection, accessToken: "AccessToken")
47+
try await service.upload(ImageHelper.testImage, selectionPolicy: .preserveSelection, accessToken: "AccessToken")
4848
XCTFail("This should throw an error")
4949
} catch ImageUploadError.responseError(reason: let reason) where reason.httpStatusCode == responseCode {
5050
// Expected error has occurred.
@@ -59,7 +59,7 @@ final class AvatarServiceTests: XCTestCase {
5959
let service = avatarService(with: sessionMock)
6060

6161
do {
62-
try await service.upload(UIImage(), selectionBehavior: .preserveSelection, accessToken: "AccessToken")
62+
try await service.upload(UIImage(), selectionPolicy: .preserveSelection, accessToken: "AccessToken")
6363
XCTFail("This should throw an error")
6464
} catch let error as ImageUploadError {
6565
XCTAssertEqual(error, ImageUploadError.cannotConvertImageIntoData)

0 commit comments

Comments
 (0)