chromium/ios/chrome/browser/download/ui_bundled/download_manager_coordinator.mm

// Copyright 2018 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/chrome/browser/download/ui_bundled/download_manager_coordinator.h"

#import <MobileCoreServices/MobileCoreServices.h>
#import <StoreKit/StoreKit.h>

#import <memory>
#import <set>
#import <utility>

#import "base/apple/foundation_util.h"
#import "base/apple/scoped_cftyperef.h"
#import "base/check_op.h"
#import "base/feature_list.h"
#import "base/functional/bind.h"
#import "base/metrics/histogram_functions.h"
#import "base/metrics/user_metrics.h"
#import "base/metrics/user_metrics_action.h"
#import "base/strings/stringprintf.h"
#import "base/strings/sys_string_conversions.h"
#import "base/strings/utf_string_conversions.h"
#import "components/strings/grit/components_strings.h"
#import "ios/chrome/browser/download/model/confirm_download_closing_overlay.h"
#import "ios/chrome/browser/download/model/confirm_download_replacing_overlay.h"
#import "ios/chrome/browser/download/model/download_directory_util.h"
#import "ios/chrome/browser/download/model/download_manager_metric_names.h"
#import "ios/chrome/browser/download/model/download_manager_tab_helper.h"
#import "ios/chrome/browser/download/model/external_app_util.h"
#import "ios/chrome/browser/download/model/installation_notifier.h"
#import "ios/chrome/browser/download/ui_bundled/activities/open_downloads_folder_activity.h"
#import "ios/chrome/browser/download/ui_bundled/download_manager_mediator.h"
#import "ios/chrome/browser/download/ui_bundled/download_manager_view_controller.h"
#import "ios/chrome/browser/download/ui_bundled/download_manager_view_controller_delegate.h"
#import "ios/chrome/browser/download/ui_bundled/download_manager_view_controller_protocol.h"
#import "ios/chrome/browser/download/ui_bundled/legacy_download_manager_view_controller.h"
#import "ios/chrome/browser/download/ui_bundled/unopened_downloads_tracker.h"
#import "ios/chrome/browser/drive/model/drive_service_factory.h"
#import "ios/chrome/browser/drive/model/upload_task.h"
#import "ios/chrome/browser/overlays/model/public/common/confirmation/confirmation_overlay_response.h"
#import "ios/chrome/browser/overlays/model/public/overlay_callback_manager.h"
#import "ios/chrome/browser/overlays/model/public/overlay_request_queue.h"
#import "ios/chrome/browser/shared/coordinator/layout_guide/layout_guide_util.h"
#import "ios/chrome/browser/shared/model/browser/browser.h"
#import "ios/chrome/browser/shared/model/profile/profile_ios.h"
#import "ios/chrome/browser/shared/model/url/chrome_url_constants.h"
#import "ios/chrome/browser/shared/model/web_state_list/web_state_list.h"
#import "ios/chrome/browser/shared/model/web_state_list/web_state_list_observer.h"
#import "ios/chrome/browser/shared/public/commands/application_commands.h"
#import "ios/chrome/browser/shared/public/commands/browser_coordinator_commands.h"
#import "ios/chrome/browser/shared/public/commands/command_dispatcher.h"
#import "ios/chrome/browser/shared/public/commands/open_new_tab_command.h"
#import "ios/chrome/browser/shared/public/commands/save_to_drive_commands.h"
#import "ios/chrome/browser/shared/public/features/features.h"
#import "ios/chrome/browser/signin/model/identity_manager_factory.h"
#import "ios/chrome/browser/store_kit/model/store_kit_coordinator.h"
#import "ios/chrome/browser/store_kit/model/store_kit_coordinator_delegate.h"
#import "ios/chrome/browser/ui/fullscreen/fullscreen_controller.h"
#import "ios/chrome/browser/ui/presenters/contained_presenter.h"
#import "ios/chrome/browser/ui/presenters/contained_presenter_delegate.h"
#import "ios/chrome/grit/ios_strings.h"
#import "ios/components/webui/web_ui_url_constants.h"
#import "ios/web/common/features.h"
#import "ios/web/public/download/download_task.h"
#import "ios/web/public/web_client.h"
#import "net/base/apple/url_conversions.h"
#import "net/base/net_errors.h"
#import "ui/base/l10n/l10n_util_mac.h"

@interface DownloadManagerCoordinator () <ContainedPresenterDelegate,
                                          DownloadManagerViewControllerDelegate,
                                          StoreKitCoordinatorDelegate> {
  // View controller for presenting Download Manager UI.
  UIViewController<DownloadManagerConsumer,
                   DownloadManagerViewControllerProtocol>* _viewController;
  // View controller for presenting "Open In.." dialog.
  UIActivityViewController* _openInController;
  DownloadManagerMediator _mediator;
  StoreKitCoordinator* _storeKitCoordinator;
  UnopenedDownloadsTracker _unopenedDownloads;
  // YES after _stop has been called.
  BOOL _stopped;
  // YES if the UI presented by this coordinator should adapt to the fullscreen.
  BOOL _shouldObserveFullscreen;
  // `start` was called when the coordinator stopping animation was still
  // in progress.
  // Restart when the animation ends.
  BOOL _restartPending;
}
@end

@implementation DownloadManagerCoordinator

- (void)dealloc {
  DCHECK(_stopped);
}

- (void)start {
  DCHECK(self.presenter);
  DCHECK(self.browser);

  if (_stopped && self.presenter.presentedViewController) {
    // Stopping animation is still in progress. Wait until it is done to
    // restart.
    _restartPending = YES;
    return;
  }
  _stopped = NO;
  NSNotificationCenter* defaultCenter = [NSNotificationCenter defaultCenter];
  [defaultCenter addObserver:self
                    selector:@selector(applicationDidEnterBackground:)
                        name:UIApplicationDidEnterBackgroundNotification
                      object:nil];

  BOOL isIncognito = self.browser->GetBrowserState()->IsOffTheRecord();
  _viewController = base::FeatureList::IsEnabled(kIOSSaveToDrive)
                        ? [[DownloadManagerViewController alloc] init]
                        : [[LegacyDownloadManagerViewController alloc] init];
  _viewController.delegate = self;
  _viewController.layoutGuideCenter = LayoutGuideCenterForBrowser(self.browser);
  _viewController.incognito = isIncognito;

  if (_shouldObserveFullscreen) {
    FullscreenController* fullscreenController =
        FullscreenController::FromBrowser(self.browser);
    [_viewController setFullscreenController:fullscreenController];
  }

  if (base::FeatureList::IsEnabled(kIOSSaveToDrive)) {
    _mediator.SetIsIncognito(isIncognito);
    ChromeBrowserState* browserState = self.browser->GetBrowserState();
    _mediator.SetIdentityManager(
        IdentityManagerFactory::GetForBrowserState(browserState));
    _mediator.SetDriveService(
        drive::DriveServiceFactory::GetForBrowserState(browserState));
    _mediator.SetPrefService(browserState->GetPrefs());
  }

  _mediator.SetDownloadTask(_downloadTask);
  _mediator.SetConsumer(_viewController);
  if (base::FeatureList::IsEnabled(kIOSDownloadNoUIUpdateInBackground)) {
    _mediator.StartObservingNotifications();
  }

  self.presenter.baseViewController = self.baseViewController;
  self.presenter.presentedViewController = _viewController;
  self.presenter.delegate = self;

  self.browser->GetWebStateList()->AddObserver(&_unopenedDownloads);

  [self.presenter prepareForPresentation];

  [self.presenter presentAnimated:self.animatesPresentation];
}

- (void)stop {
  _mediator.SetDriveService(nullptr);
  _mediator.SetPrefService(nullptr);
  _mediator.SetIdentityManager(nullptr);
  if (base::FeatureList::IsEnabled(kIOSDownloadNoUIUpdateInBackground)) {
    _mediator.StopObservingNotifications();
  }

  if (_viewController) {
    [self.presenter dismissAnimated:self.animatesPresentation];
    // Prevent delegate callbacks for stopped coordinator.
    _viewController.delegate = nil;
    [_viewController setFullscreenController:nullptr];
    _viewController = nil;
  }

  _shouldObserveFullscreen = NO;
  _downloadTask = nullptr;

  if (self.browser)
    (self.browser->GetWebStateList())->RemoveObserver(&_unopenedDownloads);

  [self stopStoreKitCoordinator];

  [[InstallationNotifier sharedInstance] unregisterForNotifications:self];
  _stopped = YES;
}

- (UIViewController*)viewController {
  return _viewController;
}

#pragma mark - DownloadManagerTabHelperDelegate

- (void)downloadManagerTabHelper:(DownloadManagerTabHelper*)tabHelper
               didCreateDownload:(web::DownloadTask*)download
               webStateIsVisible:(BOOL)webStateIsVisible {
  base::UmaHistogramEnumeration("Download.IOSDownloadFileUI",
                                DownloadFileUI::DownloadFileStarted,
                                DownloadFileUI::Count);

  if (!webStateIsVisible) {
    // Do nothing if a background Tab requested download UI presentation.
    return;
  }

  BOOL replacingExistingDownload = _downloadTask ? YES : NO;
  _downloadTask = download;

  if (web::GetWebClient()->EnableFullscreenAPI()) {
    // Exit fullscreen since download UI will be behind fullscreen mode.
    web::WebState* webState = download->GetWebState();
    webState->CloseMediaPresentations();
  }

  if (replacingExistingDownload) {
    _mediator.SetDownloadTask(_downloadTask);
  } else {
    self.animatesPresentation = YES;
    [self start];
  }
}

- (void)downloadManagerTabHelper:(DownloadManagerTabHelper*)tabHelper
         decidePolicyForDownload:(web::DownloadTask*)download
               completionHandler:(void (^)(NewDownloadPolicy))handler {
  std::unique_ptr<OverlayRequest> request =
      OverlayRequest::CreateWithConfig<ConfirmDownloadReplacingRequest>();

  request->GetCallbackManager()->AddCompletionCallback(
      base::BindOnce(^(OverlayResponse* response) {
        // `response` is null if WebState was destroyed. Don't call completion
        // handler if no buttons were tapped.
        if (response) {
          bool confirmed =
              response->GetInfo<ConfirmationOverlayResponse>()->confirmed();
          base::UmaHistogramBoolean("Download.IOSDownloadReplaced", confirmed);
          handler(confirmed ? kNewDownloadPolicyReplace
                            : kNewDownloadPolicyDiscard);
        }
      }));

  web::WebState* webState = download->GetWebState();
  if (web::GetWebClient()->EnableFullscreenAPI()) {
    // Close fullscreen mode in the event that a download is attempting to
    // replace a pending download request.
    webState->CloseMediaPresentations();
  }

  OverlayRequestQueue::FromWebState(webState, OverlayModality::kWebContentArea)
      ->AddRequest(std::move(request));
}

- (void)downloadManagerTabHelper:(DownloadManagerTabHelper*)tabHelper
                 didHideDownload:(web::DownloadTask*)download
                        animated:(BOOL)animated {
  DCHECK_EQ(_downloadTask, download);
  self.animatesPresentation = animated;
  [self stop];
  self.animatesPresentation = YES;
}

- (void)downloadManagerTabHelper:(DownloadManagerTabHelper*)tabHelper
                 didShowDownload:(web::DownloadTask*)download
                        animated:(BOOL)animated {
  DCHECK_NE(_downloadTask, download);
  _downloadTask = download;
  self.animatesPresentation = animated;
  [self start];
  self.animatesPresentation = YES;
}

- (void)downloadManagerTabHelper:(DownloadManagerTabHelper*)tabHelper
               didCancelDownload:(web::DownloadTask*)download {
  if (!_downloadTask) {
    // If the task was initially cancelled from this coordinator, it may already
    // be stopped. Test if the `_downloadTask` was already cleaned before this
    // observer is called.
    return;
  }
  DCHECK_EQ(_downloadTask, download);
  self.animatesPresentation = NO;
  [self stop];
  self.animatesPresentation = YES;
}

- (void)downloadManagerTabHelper:(DownloadManagerTabHelper*)tabHelper
               adaptToFullscreen:(bool)adaptToFullscreen {
  _shouldObserveFullscreen = adaptToFullscreen;
  if (adaptToFullscreen) {
    FullscreenController* fullscreenController =
        FullscreenController::FromBrowser(self.browser);
    [_viewController setFullscreenController:fullscreenController];
  } else {
    [_viewController setFullscreenController:nullptr];
  }
}

- (void)downloadManagerTabHelper:(DownloadManagerTabHelper*)tabHelper
            wantsToStartDownload:(web::DownloadTask*)download {
  DCHECK_EQ(_downloadTask, download);
  [self tryDownload];
}

#pragma mark - ContainedPresenterDelegate

- (void)containedPresenterDidPresent:(id<ContainedPresenter>)presenter {
  DCHECK(presenter == self.presenter);
}

- (void)containedPresenterDidDismiss:(id<ContainedPresenter>)presenter {
  DCHECK(presenter == self.presenter);
  // The view controller may not be dealloced immediately.
  presenter.presentedViewController = nil;
  if (_restartPending) {
    _restartPending = NO;
    [self start];
  }
}

#pragma mark - DownloadManagerViewControllerDelegate

- (void)downloadManagerViewControllerDidClose:(UIViewController*)controller {
  if (_mediator.GetDownloadManagerState() != kDownloadManagerStateInProgress) {
    base::UmaHistogramEnumeration("Download.IOSDownloadFileResult",
                                  DownloadFileResult::NotStarted,
                                  DownloadFileResult::Count);
    base::RecordAction(base::UserMetricsAction("IOSDownloadClose"));
    [self cancelDownload];
    return;
  }
  base::RecordAction(
      base::UserMetricsAction("IOSDownloadTryCloseWhenInProgress"));

  std::unique_ptr<OverlayRequest> request =
      OverlayRequest::CreateWithConfig<ConfirmDownloadClosingRequest>();

  __weak DownloadManagerCoordinator* weakSelf = self;
  request->GetCallbackManager()->AddCompletionCallback(
      base::BindOnce(^(OverlayResponse* response) {
        if (response &&
            response->GetInfo<ConfirmationOverlayResponse>()->confirmed()) {
          base::UmaHistogramEnumeration("Download.IOSDownloadFileResult",
                                        DownloadFileResult::Cancelled,
                                        DownloadFileResult::Count);
          [weakSelf cancelDownload];
        }
      }));

  web::WebState* webState = self.downloadTask->GetWebState();
  OverlayRequestQueue::FromWebState(webState, OverlayModality::kWebContentArea)
      ->AddRequest(std::move(request));
}

- (void)installDriveForDownloadManagerViewController:
    (UIViewController*)controller {
  base::RecordAction(base::UserMetricsAction("IOSDownloadInstallGoogleDrive"));
  [self presentStoreKitForGoogleDriveApp];
}

- (void)downloadManagerViewControllerDidStartDownload:
    (UIViewController*)controller {
  if (!_mediator.IsSaveToDriveAvailable()) {
    [self tryDownload];
    return;
  }
  id<SaveToDriveCommands> saveToDriveHandler = HandlerForProtocol(
      self.browser->GetCommandDispatcher(), SaveToDriveCommands);
  [saveToDriveHandler showSaveToDriveForDownload:_downloadTask];
}

- (void)downloadManagerViewControllerDidRetry:(UIViewController*)controller {
  UploadTask* uploadTask = _mediator.GetUploadTask();
  if (uploadTask && uploadTask->GetError() != nil) {
    // If there is an upload task which failed, retry the upload.
    base::RecordAction(base::UserMetricsAction("MobileDownloadRetryUpload"));
    uploadTask->Start();
    return;
  }
  // Otherwise retry download.
  [self tryDownload];
}

- (void)downloadManagerViewControllerDidOpenInDriveApp:
    (UIViewController*)controller {
  CHECK(base::FeatureList::IsEnabled(kIOSSaveToDrive));
  UploadTask* uploadTask = _mediator.GetUploadTask();
  if (!uploadTask) {
    // While it should not be possible that uploadTask is nil at this point,
    // there has been reports that show it is possible.
    // TODO(crbug.com/324897399): investigate and remove early return.
    return;
  }
  base::RecordAction(base::UserMetricsAction("IOSDownloadOpenInDriveApp"));
  std::optional<GURL> openFileInDriveURL =
      uploadTask->GetResponseLink(/* add_user_identifier= */ true);
  CHECK(openFileInDriveURL);
  [UIApplication.sharedApplication
                openURL:net::NSURLWithGURL(*openFileInDriveURL)
                options:@{UIApplicationOpenURLOptionUniversalLinksOnly : @YES}
      completionHandler:nil];
}

- (void)presentOpenInForDownloadManagerViewController:
    (UIViewController*)controller {
  base::RecordAction(base::UserMetricsAction("IOSDownloadOpenIn"));
  base::FilePath path = _mediator.GetDownloadPath();
  NSURL* URL = [NSURL fileURLWithPath:base::SysUTF8ToNSString(path.value())];

  NSArray* customActions = @[ URL ];
  NSArray* activities = nil;

  OpenDownloadsFolderActivity* customActivity =
      [[OpenDownloadsFolderActivity alloc] init];
  customActivity.browserHandler = HandlerForProtocol(
      self.browser->GetCommandDispatcher(), BrowserCoordinatorCommands);
  activities = @[ customActivity ];

  _openInController =
      [[UIActivityViewController alloc] initWithActivityItems:customActions
                                        applicationActivities:activities];

  _openInController.excludedActivityTypes =
      @[ UIActivityTypeCopyToPasteboard, UIActivityTypeSaveToCameraRoll ];

  // UIActivityViewController is presented in a popover on iPad.
  _openInController.popoverPresentationController.sourceView =
      _viewController.openInSourceView;
  _openInController.popoverPresentationController.sourceRect =
      _viewController.openInSourceView.bounds;
  [_viewController presentViewController:_openInController
                                animated:YES
                              completion:nil];
}

- (void)openDownloadedFileForDownloadManagerViewController:
    (UIViewController*)controller {
  base::RecordAction(base::UserMetricsAction("IOSDownloadOpen"));
  base::FilePath path = _mediator.GetDownloadPath();
  GURL filePathURL =
      GURL(base::StringPrintf("%s://%s", "file", path.value().c_str()));
  GURL virtualFilePathURL = GURL(
      base::StringPrintf("%s://%s/%s", kChromeUIScheme, kChromeUIDownloadsHost,
                         filePathURL.ExtractFileName().c_str()));
  OpenNewTabCommand* command = [[OpenNewTabCommand alloc]
       initWithURL:filePathURL
        virtualURL:virtualFilePathURL
          referrer:web::Referrer()
       inIncognito:self.browser->GetBrowserState()->IsOffTheRecord()
      inBackground:NO
          appendTo:OpenPosition::kCurrentTab];
  id<ApplicationCommands> applicationHandler = HandlerForProtocol(
      self.browser->GetCommandDispatcher(), ApplicationCommands);
  [applicationHandler openURLInNewTab:command];
}

#pragma mark - Private

// Attempts to start the current download task, either for the first time or
// after one or several previously failed attempts.
- (void)tryDownload {
  DownloadManagerTabHelper* tabHelper =
      DownloadManagerTabHelper::FromWebState(_downloadTask->GetWebState());
  if (_downloadTask->GetErrorCode() != net::OK) {
    base::RecordAction(base::UserMetricsAction("MobileDownloadRetryDownload"));
  } else if (tabHelper->WillDownloadTaskBeSavedToDrive()) {
    base::RecordAction(
        base::UserMetricsAction("IOSDownloadStartDownloadToDrive"));
  } else {
    base::RecordAction(base::UserMetricsAction("IOSDownloadStartDownload"));
    _unopenedDownloads.Add(_downloadTask);
  }
  _mediator.StartDownloading();
}

- (void)stopStoreKitCoordinator {
  [_storeKitCoordinator stop];
  _storeKitCoordinator.delegate = nil;
  _storeKitCoordinator = nil;
}

// Cancels the download task and stops the coordinator.
- (void)cancelDownload {
  // `stop` nulls-our _downloadTask and `Cancel` destroys the task. Call `stop`
  // first to perform all coordinator cleanups, but copy `_downloadTask`
  // pointer to destroy the task.
  web::DownloadTask* downloadTask = _downloadTask;
  [self stop];

  // The pointer may be null if -stop was called before -cancelDownload.
  // This can happen during shutdown because -stop is called when the UI
  // is destroyed, but whether or not -cancelDownload is called depends
  // on whether the object is deallocated or not when the block created
  // in -downloadManagerViewControllerDidClose: is executed. Due to the
  // autorelease pool, it is not possible to control how the order of
  // those two events. Thus, this code needs to support a null value at
  // this point.
  if (downloadTask) {
    downloadTask->Cancel();
  }
}

// Called when Google Drive app is installed after starting StoreKitCoordinator.
- (void)didInstallGoogleDriveApp {
  base::UmaHistogramEnumeration(
      "Download.IOSDownloadFileUIGoogleDrive",
      DownloadFileUIGoogleDrive::GoogleDriveInstalledAfterDisplay,
      DownloadFileUIGoogleDrive::Count);
  if (base::FeatureList::IsEnabled(kIOSSaveToDrive)) {
    _mediator.SetGoogleDriveAppInstalled(true);
  }
  _mediator.UpdateConsumer();
}

// Presents StoreKit dialog for Google Drive application.
- (void)presentStoreKitForGoogleDriveApp {
  if (!_storeKitCoordinator) {
    _storeKitCoordinator = [[StoreKitCoordinator alloc]
        initWithBaseViewController:self.baseViewController
                           browser:self.browser];
    _storeKitCoordinator.delegate = self;
    _storeKitCoordinator.iTunesProductParameters = @{
      SKStoreProductParameterITunesItemIdentifier :
          kGoogleDriveITunesItemIdentifier
    };
  }
  [_storeKitCoordinator start];
  if (!base::FeatureList::IsEnabled(kIOSSaveToDrive) &&
      [_viewController respondsToSelector:@selector
                       (setInstallDriveButtonVisible:animated:)]) {
    [_viewController setInstallDriveButtonVisible:NO animated:YES];
  }

  [[InstallationNotifier sharedInstance]
      registerForInstallationNotifications:self
                              withSelector:@selector(didInstallGoogleDriveApp)
                                 forScheme:kGoogleDriveAppURLScheme];
}

#pragma mark - Notification callback

- (void)applicationDidEnterBackground:(NSNotification*)note {
  [_openInController.presentingViewController
      dismissViewControllerAnimated:YES
                         completion:nil];
}

#pragma mark - StoreKitCoordinatorDelegate

- (void)storeKitCoordinatorWantsToStop:(StoreKitCoordinator*)coordinator {
  CHECK_EQ(coordinator, _storeKitCoordinator);
  [self stopStoreKitCoordinator];
}

@end