chromium/ios/web/download/download_native_task_bridge.mm

// 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