-
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 all 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,172 @@ | ||
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? = nil) { | ||
// Use bundle based identifiers as suggested here | ||
// https://www.avanderlee.com/swift/urlsession-common-pitfalls-with-background-download-upload-tasks/#bundle-based-identifiers | ||
let appBundleName = Bundle.main.bundleURL.lastPathComponent | ||
.lowercased() | ||
.replacingOccurrences(of: " ", with: ".") | ||
let defaultSessionIdentifier = "com.background.\(appBundleName)" | ||
self.backgroundSessionIdentifier = sessionIdentifier ?? defaultSessionIdentifier | ||
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 | ||
|
||
// Upload tasks in background works only if we use a file reference | ||
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) | ||
} | ||
Comment on lines
+74
to
+77
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. I don't understand why we removed the temporary file here. Shouldn't we wait for the upload task to finish/fail? 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. So, as you understand from reading that article, it is necessary to have the file on disk. However, when you create and start an upload task using a file URL, URLSession immediately begins accessing the file to prepare for the upload. The system reads the file data synchronously or copies the file data into its own buffer to manage the upload independently of the app's process. At that moment, it is no longer needed. Since the upload task has already been started and the system has a reference to the file, deleting it from the app's sandboxed file system doesn't interrupt the upload. There's a slight chance that if the file is deleted before URLSession has begun reading it, the upload could fail. That's why I use Thinking aloud about it, for maximum safety, I tried to defer deleting the temp file until we receive confirmation that the upload task has successfully started or even until the upload completes. However, this might not be practical because:
So I think the current solution offers a good balance between cleanup and reliability. 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. As it is a temporary directory, we shouldn't have to worry about file remaining in disk for extended period. We can delete the file once the upload completes and let the system deal with cleaning it up in case the app is force-quit. What do you think? 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. If we delete it right away, we somehow prevent the system from cleaning up the directory at its discretion if the device has little available disk space. Also, I believe it will be easy to test in the future, right? |
||
} 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) { | ||
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. I see that |
||
} 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.
I see that we have already appended the data of the media item to the request body in this line. Do we still have to create a temporary file and pass it to this
uploadTask
method?Is this the reason for using a temp file? If yes, can you add a comment to explain?
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.
That's exactly the reason. I will add a comment to clarify that for the future 👍