| // Copyright 2021 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #import "ios/web/download/download_native_task_bridge.h" |
| |
| #import "base/apple/foundation_util.h" |
| #import "base/check.h" |
| #import "base/files/file_util.h" |
| #import "base/functional/callback.h" |
| #import "base/task/thread_pool.h" |
| #import "ios/web/download/download_result.h" |
| #import "ios/web/web_view/error_translation_util.h" |
| #import "net/base/net_errors.h" |
| |
| namespace { |
| |
| // Helper to get the size of file at `file_path`. Returns -1 in case of error. |
| int64_t FileSizeForFileAtPath(base::FilePath file_path) { |
| int64_t file_size = 0; |
| if (!base::GetFileSize(file_path, &file_size)) |
| return -1; |
| |
| return file_size; |
| } |
| |
| // Helper to invoke the download complete callback after getting the file |
| // size. |
| void DownloadDidFinishWithSize( |
| NativeDownloadTaskProgressCallback progress_callback, |
| NativeDownloadTaskCompleteCallback complete_callback, |
| int64_t file_size) { |
| if (file_size != -1 && !progress_callback.is_null()) { |
| progress_callback.Run( |
| /* bytes_received */ file_size, /* total_bytes */ file_size, |
| /* fraction_completed */ 1.0); |
| } |
| |
| web::DownloadResult download_result(net::OK); |
| std::move(complete_callback).Run(download_result); |
| } |
| |
| // Represents the possible state of the DownloadNativeTaskBridge. |
| enum class DownloadNativeTaskState { |
| // The object has been initialized. |
| kInitialized, |
| |
| // The download is in progress. |
| kInProgress, |
| |
| // The download has been resumed. It is waiting for WebKit to acknowledge |
| // the download request and ask for the path for the file. |
| kResumed, |
| |
| // WebKit is ready to start the download. It is waiting for Chromium to |
| // provide the path where the data should be written to disk. |
| kPendingStart, |
| |
| // The download has been stopped (either cancelled, or due to an error) |
| // and can be resumed (i.e. _resumeData is not nil). |
| kStoppedResumable, |
| |
| // The download has been stopped (either cancelled, or due to an error) |
| // but cannot be resume (i.e. _resumeData is nil). |
| kStoppedPermanent, |
| }; |
| |
| } // anonymous namespace |
| |
| @implementation DownloadNativeTaskBridge { |
| void (^_startDownloadBlock)(NSURL*); |
| id<DownloadNativeTaskBridgeDelegate> _delegate; |
| NativeDownloadTaskProgressCallback _progressCallback; |
| NativeDownloadTaskResponseCallback _responseCallback; |
| NativeDownloadTaskCompleteCallback _completeCallback; |
| DownloadNativeTaskState _status; |
| WKDownload* _download; |
| NSData* _resumeData; |
| } |
| |
| - (instancetype)initWithDownload:(WKDownload*)download |
| delegate: |
| (id<DownloadNativeTaskBridgeDelegate>)delegate { |
| if ((self = [super init])) { |
| _download = download; |
| _delegate = delegate; |
| _download.delegate = self; |
| |
| _status = DownloadNativeTaskState::kInitialized; |
| } |
| return self; |
| } |
| |
| - (void)dealloc { |
| [self stopObservingDownloadProgress]; |
| |
| // At this point, _startDownloadBlock should be nil. However as seen in |
| // https://crbug.com/344476170 it appears this invariant is not true. |
| // Since WebKit terminates the app with a NSException if the block is |
| // not called, invoke here if it is still set. This is a workaround |
| // until a proper fix is implemented (i.e. using a real state object |
| // to ensure that it is not possible for the block to not be invoked |
| // when the `_status` changes). |
| if (_startDownloadBlock) { |
| _startDownloadBlock(nil); |
| _startDownloadBlock = nil; |
| } |
| } |
| |
| - (void)cancel { |
| if (_status == DownloadNativeTaskState::kPendingStart) { |
| // WKDownload will pass a block to its delegate when calling its |
| // - download:decideDestinationUsingResponse:suggestedFilename |
| //:completionHandler: method. WKDownload enforces that this block is called |
| // before the object is destroyed or the download is cancelled. Thus it |
| // must be called now. |
| // |
| // Call it with a temporary path, and schedule a block to delete the file |
| // later (to avoid keeping the file around). Use a random non-empty name |
| // for the file as `self.suggestedFilename` can be `nil` which would result |
| // in the deletion of the directory `NSTemporaryDirectory()` preventing the |
| // creation of any temporary file afterwards. |
| NSString* filename = [[NSUUID UUID] UUIDString]; |
| NSURL* url = |
| [NSURL fileURLWithPath:[NSTemporaryDirectory() |
| stringByAppendingPathComponent:filename]]; |
| |
| CHECK(_startDownloadBlock); |
| _startDownloadBlock(url); |
| _startDownloadBlock = nil; |
| |
| dispatch_async(dispatch_get_global_queue(QOS_CLASS_BACKGROUND, 0), ^{ |
| NSFileManager* manager = [NSFileManager defaultManager]; |
| [manager removeItemAtURL:url error:nil]; |
| }); |
| } |
| |
| [self stopObservingDownloadProgress]; |
| __weak __typeof(self) weakSelf = self; |
| [_download cancel:^(NSData* data) { |
| [weakSelf stoppedWithResumeData:data]; |
| }]; |
| _download = nil; |
| } |
| |
| - (void)startDownload:(const base::FilePath&)path |
| progressCallback:(NativeDownloadTaskProgressCallback)progressCallback |
| responseCallback:(NativeDownloadTaskResponseCallback)responseCallback |
| completeCallback:(NativeDownloadTaskCompleteCallback)completeCallback { |
| CHECK(!path.empty()); |
| |
| _progressCallback = std::move(progressCallback); |
| _responseCallback = std::move(responseCallback); |
| _completeCallback = std::move(completeCallback); |
| _urlForDownload = base::apple::FilePathToNSURL(path); |
| |
| switch (_status) { |
| case DownloadNativeTaskState::kPendingStart: { |
| [self responseReceived:_response]; |
| [self startObservingDownloadProgress]; |
| _startDownloadBlock(_urlForDownload); |
| _startDownloadBlock = nil; |
| return; |
| } |
| |
| case DownloadNativeTaskState::kStoppedResumable: { |
| CHECK(_resumeData); |
| __weak __typeof(self) weakSelf = self; |
| _status = DownloadNativeTaskState::kResumed; |
| [_delegate resumeDownloadNativeTask:_resumeData |
| completionHandler:^(WKDownload* download) { |
| [weakSelf onResumedDownload:download]; |
| }]; |
| return; |
| } |
| |
| default: { |
| [self downloadDidFailWithErrorCode:net::ERR_UNEXPECTED resumeData:nil]; |
| return; |
| } |
| } |
| } |
| |
| - (void)stoppedWithResumeData:(NSData*)resumeData { |
| _resumeData = resumeData; |
| _status = _resumeData ? DownloadNativeTaskState::kStoppedResumable |
| : DownloadNativeTaskState::kStoppedPermanent; |
| } |
| |
| - (void)onResumedDownload:(WKDownload*)download { |
| _resumeData = nil; |
| if (download) { |
| _download = download; |
| _download.delegate = self; |
| // WKDownload will call |
| //-decideDestinationUsingResponse:suggestedFilename:completionHandler: |
| // where the download will be started. |
| } else { |
| _progressCallback.Reset(); |
| |
| _status = DownloadNativeTaskState::kStoppedPermanent; |
| web::DownloadResult download_result(net::ERR_FAILED, /*can_retry=*/false); |
| std::move(_completeCallback).Run(download_result); |
| } |
| } |
| |
| - (void)downloadDidFailWithErrorCode:(int)errorCode |
| resumeData:(NSData*)resumeData { |
| [self stopObservingDownloadProgress]; |
| [self stoppedWithResumeData:resumeData]; |
| if (!_completeCallback.is_null()) { |
| _progressCallback.Reset(); |
| |
| web::DownloadResult download_result(errorCode, resumeData != nil); |
| std::move(_completeCallback).Run(download_result); |
| } |
| } |
| |
| #pragma mark - Properties |
| |
| - (NSProgress*)progress { |
| return _download.progress; |
| } |
| |
| #pragma mark - WKDownloadDelegate |
| |
| - (void)download:(WKDownload*)download |
| decideDestinationUsingResponse:(NSURLResponse*)response |
| suggestedFilename:(NSString*)suggestedFilename |
| completionHandler:(void (^)(NSURL* destination))handler { |
| CHECK_EQ(download, _download); |
| |
| _response = response; |
| _suggestedFilename = suggestedFilename; |
| [self responseReceived:_response]; |
| |
| switch (_status) { |
| case DownloadNativeTaskState::kInitialized: { |
| CHECK(!_startDownloadBlock); |
| _startDownloadBlock = handler; |
| _status = DownloadNativeTaskState::kPendingStart; |
| if (![_delegate onDownloadNativeTaskBridgeReadyForDownload:self]) { |
| [self cancel]; |
| } |
| return; |
| } |
| |
| case DownloadNativeTaskState::kResumed: { |
| CHECK(_urlForDownload); |
| [self startObservingDownloadProgress]; |
| handler(_urlForDownload); |
| return; |
| } |
| |
| // Under certain circumstances, it was found that this method may be called |
| // multiple times for the same object by WebKit. This may be due to a bug |
| // in WebKit or in Chromium. Investigation is still pending. |
| // |
| // When this happen, the download cannot make progress if we call either of |
| // the `handler` block passed, and if the UI is notified, the code will try |
| // to cancel the download before starting it. To prevent entering this state |
| // mark the download as in a permanent error. |
| // |
| // TODO(crbug.com/340644917): Explain root cause when found. |
| default: { |
| [self downloadDidFailWithErrorCode:net::ERR_UNEXPECTED resumeData:nil]; |
| if (_startDownloadBlock) { |
| _startDownloadBlock(nil); |
| _startDownloadBlock = nil; |
| } |
| handler(nil); |
| return; |
| } |
| } |
| } |
| |
| - (void)download:(WKDownload*)download |
| didFailWithError:(NSError*)error |
| resumeData:(NSData*)resumeData { |
| int errorCode = net::OK; |
| NSURL* url = _response.URL; |
| if (!web::GetNetErrorFromIOSErrorCode(error.code, &errorCode, url)) { |
| errorCode = net::ERR_FAILED; |
| } |
| |
| [self downloadDidFailWithErrorCode:errorCode resumeData:resumeData]; |
| } |
| |
| - (void)downloadDidFinish:(WKDownload*)download { |
| [self stopObservingDownloadProgress]; |
| if (!_completeCallback.is_null()) { |
| // The method -downloadDidFinish: will be called as soon as the |
| // download completes, even before the NSProgress item may have |
| // been updated. |
| // |
| // To prevent truncating the downloaded file, get the real size |
| // of the file from the disk and call `_progressCallback` first |
| // before calling `_completeCallback`. |
| // |
| // See https://crbug.com/1346030 for examples of truncation. |
| base::ThreadPool::PostTaskAndReplyWithResult( |
| FROM_HERE, {base::TaskPriority::USER_VISIBLE, base::MayBlock()}, |
| base::BindOnce(&FileSizeForFileAtPath, |
| base::apple::NSStringToFilePath(_urlForDownload.path)), |
| base::BindOnce(&DownloadDidFinishWithSize, std::move(_progressCallback), |
| std::move(_completeCallback))); |
| } |
| } |
| |
| #pragma mark - KVO |
| |
| - (void)observeValueForKeyPath:(NSString*)keyPath |
| ofObject:(id)object |
| change:(NSDictionary*)change |
| context:(void*)context { |
| CHECK_EQ(_status, DownloadNativeTaskState::kInProgress); |
| if (!_progressCallback.is_null()) { |
| NSProgress* progress = self.progress; |
| _progressCallback.Run(progress.completedUnitCount, progress.totalUnitCount, |
| progress.fractionCompleted); |
| } |
| } |
| |
| #pragma mark - Private methods |
| |
| - (void)startObservingDownloadProgress { |
| CHECK_NE(_status, DownloadNativeTaskState::kInProgress); |
| _status = DownloadNativeTaskState::kInProgress; |
| [self.progress addObserver:self |
| forKeyPath:@"fractionCompleted" |
| options:NSKeyValueObservingOptionNew |
| context:nil]; |
| } |
| |
| - (void)stopObservingDownloadProgress { |
| if (_status == DownloadNativeTaskState::kInProgress) { |
| [self.progress removeObserver:self |
| forKeyPath:@"fractionCompleted" |
| context:nil]; |
| } |
| _status = DownloadNativeTaskState::kStoppedPermanent; |
| } |
| |
| - (void)responseReceived:(NSURLResponse*)response { |
| if (_responseCallback.is_null()) { |
| return; |
| } |
| |
| int http_error = -1; |
| if ([response isKindOfClass:[NSHTTPURLResponse class]]) { |
| http_error = |
| base::apple::ObjCCastStrict<NSHTTPURLResponse>(response).statusCode; |
| } |
| |
| std::move(_responseCallback).Run(http_error, response.MIMEType); |
| } |
| |
| @end |