-
Notifications
You must be signed in to change notification settings - Fork 116
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Background image upload for products without attaching images to the products #15277
base: trunk
Are you sure you want to change the base?
Changes from 22 commits
682e932
60f1f77
3bdc3ea
828f80b
f6fdbd9
aed77cd
08bb136
ca08551
0534deb
c611e5d
3736482
677c91b
6ab6900
8e5e3f8
1a13232
a8017d4
3103925
0c2559d
909f9bb
eca879b
82c7d6b
24ed03c
237e7e9
beb20fe
d31b7c8
5dd0d54
47e2505
06e7962
0c7be43
3fcb776
f0c71cb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,164 @@ | ||
import Foundation | ||
|
||
public protocol MediaUploadSessionManagerDelegate: AnyObject { | ||
func mediaUploadSessionManager(_ manager: MediaUploadSessionManager, | ||
didCompleteUpload uploadID: String, | ||
withResult result: Result<Media, Error>) | ||
} | ||
|
||
/// Background upload specific errors | ||
enum BackgroundUploadError: Error { | ||
case invalidRequestBody | ||
case invalidResponse | ||
case decodingError | ||
} | ||
|
||
/// Session Manager for media upload in background | ||
/// | ||
public final class MediaUploadSessionManager: NSObject { | ||
|
||
public let backgroundSessionIdentifier: String | ||
private lazy var backgroundSession: URLSession = { | ||
let config = URLSessionConfiguration.background(withIdentifier: backgroundSessionIdentifier) | ||
config.sharedContainerIdentifier = "group.com.automattic.woocommerce" | ||
config.sessionSendsLaunchEvents = true | ||
config.isDiscretionary = false | ||
config.allowsCellularAccess = true | ||
return URLSession(configuration: config, delegate: self, delegateQueue: nil) | ||
}() | ||
|
||
private var completionHandlers: [String: (Result<Media, Error>) -> Void] = [:] | ||
private var taskResponseData: [Int: Data] = [:] | ||
private var backgroundCompletionHandler: (() -> Void)? | ||
weak var delegate: MediaUploadSessionManagerDelegate? | ||
|
||
public init(sessionIdentifier: String = "com.automattic.woocommerce.background.upload") { | ||
self.backgroundSessionIdentifier = sessionIdentifier | ||
super.init() | ||
} | ||
|
||
public func uploadMedia(request: URLRequest, | ||
mediaItem: UploadableMedia, | ||
uploadID: String, | ||
completion: @escaping (Result<Media, Error>) -> Void) { | ||
completionHandlers[uploadID] = completion | ||
|
||
guard let httpBody = request.httpBody else { | ||
let error = BackgroundUploadError.invalidRequestBody | ||
completion(.failure(error)) | ||
return | ||
} | ||
|
||
do { | ||
// Create temp file with proper extension from mediaItem | ||
let tempDirectory = FileManager.default.temporaryDirectory | ||
let tempFileURL = tempDirectory.appendingPathComponent(mediaItem.filename) | ||
try httpBody.write(to: tempFileURL) | ||
|
||
// Upload using temp file | ||
var modifiedRequest = request | ||
modifiedRequest.httpBody = nil | ||
|
||
let task = backgroundSession.uploadTask(with: modifiedRequest, fromFile: tempFileURL) | ||
task.taskDescription = uploadID | ||
task.resume() | ||
|
||
// Cleanup temp file | ||
DispatchQueue.main.async { | ||
try? FileManager.default.removeItem(at: tempFileURL) | ||
} | ||
} catch { | ||
DDLogError("⛔️ MediaUploadSessionManager- Failed image upload while creating temp file: \(error)") | ||
completion(.failure(error)) | ||
} | ||
} | ||
|
||
public func handleBackgroundSessionCompletion(_ completionHandler: @escaping () -> Void) { | ||
backgroundCompletionHandler = completionHandler | ||
} | ||
} | ||
|
||
extension MediaUploadSessionManager: URLSessionDataDelegate { | ||
public func urlSession(_ session: URLSession, | ||
dataTask: URLSessionDataTask, | ||
didReceive data: Data) { | ||
if let existingData = taskResponseData[dataTask.taskIdentifier] { | ||
taskResponseData[dataTask.taskIdentifier] = existingData + data | ||
} else { | ||
taskResponseData[dataTask.taskIdentifier] = data | ||
} | ||
} | ||
|
||
public func urlSession(_ session: URLSession, | ||
task: URLSessionTask, | ||
didCompleteWithError error: Error?) { | ||
guard let uploadID = task.taskDescription else { | ||
DDLogDebug("MediaUploadSessionManager- task completed without an upload identifier. Task identifier: \(task.taskIdentifier)") | ||
return | ||
} | ||
defer { | ||
taskResponseData.removeValue(forKey: task.taskIdentifier) | ||
} | ||
|
||
if let error = error { | ||
DDLogError("⛔️ MediaUploadSessionManager- Upload failure for task (\(uploadID)): encountered error: \(error.localizedDescription)") | ||
notifyCompletion(.failure(error), for: uploadID) | ||
return | ||
} | ||
|
||
guard let httpResponse = task.response as? HTTPURLResponse else { | ||
DDLogError("⛔️ MediaUploadSessionManager- Upload failure for task (\(uploadID)): " + | ||
"response is not a valid HTTPURLResponse. Actual response: " + | ||
"\(String(describing: task.response))") | ||
notifyCompletion(.failure(BackgroundUploadError.invalidResponse), for: uploadID) | ||
return | ||
} | ||
|
||
guard let data = taskResponseData[task.taskIdentifier] else { | ||
DDLogError("⛔️ MediaUploadSessionManager- Upload failure for task (\(uploadID)): " + | ||
"missing response data for task with identifier \(task.taskIdentifier)") | ||
notifyCompletion(.failure(BackgroundUploadError.invalidResponse), for: uploadID) | ||
return | ||
} | ||
|
||
if !(200...299).contains(httpResponse.statusCode) { | ||
DDLogError("⛔️ MediaUploadSessionManager- Upload failure for task (\(uploadID)): " + | ||
"unexpected HTTP status code \(httpResponse.statusCode). " + | ||
"Full response: \(httpResponse) Headers: \(httpResponse.allHeaderFields)") | ||
notifyCompletion(.failure(BackgroundUploadError.invalidResponse), for: uploadID) | ||
return | ||
} | ||
|
||
// Use MediaMapper to parse response | ||
if let jsonString = String(data: data, encoding: .utf8) { | ||
} else { | ||
DDLogError("⛔️ MediaUploadSessionManager- Failed to convert response data to JSON string for task (\(uploadID))") | ||
} | ||
let mapper = WordPressMediaMapper() | ||
do { | ||
let media = try mapper.map(response: data) | ||
notifyCompletion(.success(media.toMedia()), for: uploadID) | ||
} catch { | ||
DDLogError("⛔️ MediaUploadSessionManager- Upload failure for task (\(uploadID)): error mapping media: \(error.localizedDescription)") | ||
notifyCompletion(.failure(error), for: uploadID) | ||
} | ||
} | ||
|
||
public func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { | ||
DispatchQueue.main.async { [weak self] in | ||
DDLogDebug("MediaUploadSessionManager- Background URL session did finish events. Invoking completion handler.") | ||
self?.backgroundCompletionHandler?() | ||
self?.backgroundCompletionHandler = nil | ||
} | ||
} | ||
|
||
private func notifyCompletion(_ result: Result<Media, Error>, for uploadID: String) { | ||
DispatchQueue.main.async { [weak self] in | ||
DDLogError("⛔️ MediaUploadSessionManager- Notifying completion for task (\(uploadID)) with result: \(result)") | ||
guard let self = self else { return } | ||
self.completionHandlers[uploadID]?(result) | ||
self.completionHandlers.removeValue(forKey: uploadID) | ||
self.delegate?.mediaUploadSessionManager(self, didCompleteUpload: uploadID, withResult: result) | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,5 @@ | ||
import Foundation | ||
import Alamofire | ||
|
||
/// Protocol for `MediaRemote` mainly used for mocking. | ||
public protocol MediaRemoteProtocol { | ||
|
@@ -19,6 +20,11 @@ public protocol MediaRemoteProtocol { | |
productID: Int64, | ||
mediaID: Int64, | ||
completion: @escaping (Result<WordPressMedia, Error>) -> Void) | ||
|
||
/// Creates a URLRequest for uploading media in background | ||
func uploadMediaRequest(siteID: Int64, | ||
productID: Int64, | ||
mediaItem: UploadableMedia) async throws -> URLRequest | ||
} | ||
|
||
/// Media: Remote Endpoints | ||
|
@@ -163,6 +169,68 @@ public class MediaRemote: Remote, MediaRemoteProtocol { | |
completion(.failure(error)) | ||
} | ||
} | ||
|
||
public func uploadMediaRequest(siteID: Int64, | ||
productID: Int64, | ||
mediaItem: UploadableMedia) async throws -> URLRequest { | ||
|
||
let boundary = UUID().uuidString | ||
let path = "sites/\(siteID)/media" | ||
|
||
let dotcomRequest = try DotcomRequest(wordpressApiVersion: .wpMark2, | ||
method: .post, | ||
path: path, | ||
parameters: nil, | ||
availableAsRESTRequest: true) | ||
|
||
guard let network = network as? AlamofireNetwork else { | ||
throw NetworkError.unacceptableStatusCode(statusCode: 500, response: nil) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could you kindly explain why we have to throw There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good question. I identified it as a generic error, but the HTTP error 500 is more for server-side errors, while for a client error, it's probably best to use a generic error code like 400 (Bad Request). Wdyt? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Thanks! This sounds good to me. |
||
} | ||
|
||
let converter = RequestConverter(credentials: network.credentials) | ||
var request = try converter.convert(dotcomRequest).asURLRequest() | ||
|
||
// Authenticate the request if we have credentials | ||
if let credentials = network.credentials { | ||
request = try DefaultRequestAuthenticator(credentials: credentials).authenticate(request) | ||
} else { | ||
throw NetworkError.unacceptableStatusCode(statusCode: 401, response: nil) | ||
} | ||
|
||
// Add multipart content type | ||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") | ||
|
||
// Add form data | ||
var body = Data() | ||
func append(_ string: String) { | ||
body.append(string.data(using: .utf8)!) | ||
} | ||
|
||
let params: [String: String] = [ | ||
ParameterKey.wordPressMediaPostID: "\(productID)", | ||
ParameterKey.fieldsWordPressSite: ParameterValue.wordPressMediaFields, | ||
ParameterKey.wordPressAltText: mediaItem.altText ?? "" | ||
] | ||
|
||
//Add parameters | ||
for (key, value) in params { | ||
append("--\(boundary)\r\n") | ||
append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n") | ||
append("\(value)\r\n") | ||
} | ||
|
||
// Add file data | ||
append("--\(boundary)\r\n") | ||
append("Content-Disposition: form-data; name=\"file\"; filename=\"\(mediaItem.filename)\"\r\n") | ||
append("Content-Type: \(mediaItem.mimeType)\r\n\r\n") | ||
body.append(try Data(contentsOf: mediaItem.localURL)) | ||
append("\r\n") | ||
append("--\(boundary)--\r\n") | ||
|
||
request.httpBody = body | ||
request.httpMethod = "POST" | ||
return request | ||
} | ||
} | ||
|
||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential concurrent access to dictionaries.
completionHandlers
andtaskResponseData
may be accessed from different threads (e.g., duringdidReceive data
callbacks vs. the main thread). Swift dictionaries are not inherently thread-safe. Consider using a dedicated serial queue or locks to avoid data races.📝 Committable suggestion