chromium/ios/chrome/browser/ui/send_tab_to_self/send_tab_to_self_coordinator.mm

// Copyright 2019 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/ui/send_tab_to_self/send_tab_to_self_coordinator.h"

#import <MaterialComponents/MaterialSnackbar.h>

#import <memory>
#import <optional>
#import <utility>

#import "base/apple/foundation_util.h"
#import "base/check.h"
#import "base/ios/block_types.h"
#import "base/memory/raw_ptr.h"
#import "base/strings/sys_string_conversions.h"
#import "components/send_tab_to_self/entry_point_display_reason.h"
#import "components/send_tab_to_self/metrics_util.h"
#import "components/send_tab_to_self/send_tab_to_self_model.h"
#import "components/send_tab_to_self/send_tab_to_self_sync_service.h"
#import "components/send_tab_to_self/target_device_info.h"
#import "components/signin/public/base/consent_level.h"
#import "components/signin/public/base/signin_metrics.h"
#import "components/sync/service/sync_service.h"
#import "components/sync/service/sync_service_observer.h"
#import "ios/chrome/browser/send_tab_to_self/model/send_tab_to_self_browser_agent.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/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/show_signin_command.h"
#import "ios/chrome/browser/shared/public/commands/snackbar_commands.h"
#import "ios/chrome/browser/shared/public/commands/toolbar_commands.h"
#import "ios/chrome/browser/shared/ui/util/snackbar_util.h"
#import "ios/chrome/browser/shared/ui/util/uikit_ui_util.h"
#import "ios/chrome/browser/signin/model/authentication_service.h"
#import "ios/chrome/browser/signin/model/authentication_service_factory.h"
#import "ios/chrome/browser/signin/model/chrome_account_manager_service.h"
#import "ios/chrome/browser/signin/model/chrome_account_manager_service_factory.h"
#import "ios/chrome/browser/signin/model/system_identity.h"
#import "ios/chrome/browser/sync/model/send_tab_to_self_sync_service_factory.h"
#import "ios/chrome/browser/sync/model/sync_service_factory.h"
#import "ios/chrome/browser/ui/authentication/signin/signin_constants.h"
#import "ios/chrome/browser/ui/authentication/signin_presenter.h"
#import "ios/chrome/browser/ui/infobars/presentation/infobar_modal_positioner.h"
#import "ios/chrome/browser/ui/send_tab_to_self/send_tab_to_self_modal_delegate.h"
#import "ios/chrome/browser/ui/send_tab_to_self/send_tab_to_self_modal_presentation_controller.h"
#import "ios/chrome/browser/ui/send_tab_to_self/send_tab_to_self_table_view_controller.h"
#import "ios/chrome/grit/ios_strings.h"
#import "ui/base/l10n/l10n_util.h"

namespace {

// Snackbar category for activity services.
NSString* const kActivityServicesSnackbarCategory =
    @"ActivityServicesSnackbarCategory";

class TargetDeviceListWaiter : public syncer::SyncServiceObserver {
 public:
  using GetDisplayReasonCallback = base::RepeatingCallback<
      std::optional<send_tab_to_self::EntryPointDisplayReason>()>;

  // Queries `get_display_reason_callback` until it indicates the device list is
  // known (i.e. until it returns kOfferFeature or kInformNoTargetDevice), then
  // calls `on_list_known_callback`. Destroying the object aborts the waiting.
  TargetDeviceListWaiter(
      syncer::SyncService* sync_service,
      const GetDisplayReasonCallback& get_display_reason_callback,
      base::OnceClosure on_list_known_callback)
      : sync_service_(sync_service),
        get_display_reason_callback_(get_display_reason_callback),
        on_list_known_callback_(std::move(on_list_known_callback)) {
    sync_service_->AddObserver(this);
    OnStateChanged(sync_service_);
  }

  TargetDeviceListWaiter(const TargetDeviceListWaiter&) = delete;
  TargetDeviceListWaiter& operator=(const TargetDeviceListWaiter&) = delete;

  ~TargetDeviceListWaiter() override { sync_service_->RemoveObserver(this); }

  void OnStateChanged(syncer::SyncService*) override {
    std::optional<send_tab_to_self::EntryPointDisplayReason> display_reason =
        get_display_reason_callback_.Run();
    if (!display_reason) {
      // Model starting up, keep waiting.
      return;
    }
    switch (*display_reason) {
      case send_tab_to_self::EntryPointDisplayReason::kOfferSignIn:
        break;
      case send_tab_to_self::EntryPointDisplayReason::kOfferFeature:
      case send_tab_to_self::EntryPointDisplayReason::kInformNoTargetDevice:
        sync_service_->RemoveObserver(this);
        std::move(on_list_known_callback_).Run();
        break;
    }
  }

 private:
  const raw_ptr<syncer::SyncService> sync_service_;
  const GetDisplayReasonCallback get_display_reason_callback_;
  base::OnceClosure on_list_known_callback_;
};

void ShowSendingMessage(CommandDispatcher* dispatcher, NSString* deviceName) {
  if (!dispatcher) {
    return;
  }

  TriggerHapticFeedbackForNotification(UINotificationFeedbackTypeSuccess);
  NSString* text =
      l10n_util::GetNSStringF(IDS_IOS_SEND_TAB_TO_SELF_SNACKBAR_MESSAGE,
                              base::SysNSStringToUTF16(deviceName));
  MDCSnackbarMessage* message = CreateSnackbarMessage(text);
  message.accessibilityLabel = text;
  message.duration = 2.0;
  message.category = kActivityServicesSnackbarCategory;
  [HandlerForProtocol(dispatcher, SnackbarCommands)
      showSnackbarMessage:message];
}

void OpenManageDevicesTab(CommandDispatcher* dispatcher) {
  if (!dispatcher) {
    return;
  }

  id<ApplicationCommands> handler =
      HandlerForProtocol(dispatcher, ApplicationCommands);
  [handler openURLInNewTab:[OpenNewTabCommand
                               commandWithURLFromChrome:
                                   GURL(kGoogleMyAccountDeviceActivityURL)]];
}

}  // namespace

@interface SendTabToSelfCoordinator () <UIViewControllerTransitioningDelegate,
                                        InfobarModalPositioner,
                                        SendTabToSelfModalDelegate> {
  std::unique_ptr<TargetDeviceListWaiter> _targetDeviceListWaiter;
}

@property(nonatomic, weak, readonly) id<SigninPresenter> signinPresenter;
@property(nonatomic, assign, readonly) GURL url;
@property(nonatomic, copy, readonly) NSString* title;

// The TableViewController that shows the Send Tab To Self UI. This is NOT the
// presented controller, it is wrapped in a UINavigationController.
@property(nonatomic, strong)
    SendTabToSelfTableViewController* sendTabToSelfViewController;
// If non-null, this is called when iOS finishes the animated dismissal of the
// view controllers. This is called after this object is destroyed so it must
// NOT rely on self. Instead the block should retain its dependencies.
@property(nonatomic, copy) ProceduralBlock dismissedCompletion;
@property(nonatomic, assign) BOOL stopped;

@end

@implementation SendTabToSelfCoordinator

#pragma mark - Public

- (id)initWithBaseViewController:(UIViewController*)baseViewController
                         browser:(Browser*)browser
                 signinPresenter:(id<SigninPresenter>)signinPresenter
                             url:(const GURL&)url
                           title:(NSString*)title {
  self = [super initWithBaseViewController:baseViewController browser:browser];
  if (!self) {
    return nil;
  }

  _signinPresenter = signinPresenter;
  _url = url;
  _title = title;
  return self;
}

#pragma mark - ChromeCoordinator Methods

- (void)start {
  [self show];
}

// Do not call directly, use the hideSendTabToSelfUI() command instead!
- (void)stop {
  DCHECK(!self.stopped) << "Already stopped";
  self.stopped = YES;
  // Abort the waiting if it's still ongoing.
  _targetDeviceListWaiter.reset();
  [self.baseViewController
      dismissViewControllerAnimated:YES
                         completion:self.dismissedCompletion];
  // Embedders currently don't wait for the dismissal to finish, so might as
  // well reset fields immediately.
  self.sendTabToSelfViewController = nil;
  self.dismissedCompletion = nil;
}

#pragma mark - UIViewControllerTransitioningDelegate

- (UIPresentationController*)
    presentationControllerForPresentedViewController:
        (UIViewController*)presented
                            presentingViewController:
                                (UIViewController*)presenting
                                sourceViewController:(UIViewController*)source {
  SendTabToSelfModalPresentationController* presentationController =
      [[SendTabToSelfModalPresentationController alloc]
          initWithPresentedViewController:presented
                 presentingViewController:presenting];
  presentationController.modalPositioner = self;
  return presentationController;
}

#pragma mark - InfobarModalPositioner

- (CGFloat)modalHeightForWidth:(CGFloat)width {
  UIView* view = self.sendTabToSelfViewController.view;
  CGSize contentSize = CGSizeZero;
  if (UIScrollView* scrollView = base::apple::ObjCCast<UIScrollView>(view)) {
    CGRect layoutFrame = self.baseViewController.view.bounds;
    layoutFrame.size.width = width;
    scrollView.frame = layoutFrame;
    [scrollView setNeedsLayout];
    [scrollView layoutIfNeeded];
    contentSize = scrollView.contentSize;
  } else {
    contentSize = [view sizeThatFits:CGSizeMake(width, CGFLOAT_MAX)];
  }

  // Since the TableView is contained in a NavigationController get the
  // navigation bar height.
  CGFloat navigationBarHeight =
      self.sendTabToSelfViewController.navigationController.navigationBar.frame
          .size.height;

  return contentSize.height + navigationBarHeight;
}

#pragma mark - SendTabToSelfModalDelegate

- (void)dismissViewControllerAnimated {
  [HandlerForProtocol(self.browser->GetCommandDispatcher(),
                      BrowserCoordinatorCommands) hideSendTabToSelfUI];
}

- (void)sendTabToTargetDeviceCacheGUID:(NSString*)cacheGUID
                      targetDeviceName:(NSString*)deviceName {
  SendTabToSelfSyncServiceFactory::GetForBrowserState(
      self.browser->GetBrowserState())
      ->GetSendTabToSelfModel()
      ->AddEntry(self.url, base::SysNSStringToUTF8(self.title),
                 base::SysNSStringToUTF8(cacheGUID));

  // ShowSendingMessage() opens UI, so wait for the dialog to be dismissed.
  __weak CommandDispatcher* weakDispatcher =
      self.browser->GetCommandDispatcher();
  self.dismissedCompletion = ^{
    ShowSendingMessage(weakDispatcher, deviceName);
  };
  [HandlerForProtocol(self.browser->GetCommandDispatcher(),
                      BrowserCoordinatorCommands) hideSendTabToSelfUI];
}

- (void)openManageDevicesTab {
  // OpenManageDevicesTab() opens UI, so wait for the dialog to be dismissed.
  __weak CommandDispatcher* weakDispatcher =
      self.browser->GetCommandDispatcher();
  self.dismissedCompletion = ^{
    OpenManageDevicesTab(weakDispatcher);
  };
  [HandlerForProtocol(self.browser->GetCommandDispatcher(),
                      BrowserCoordinatorCommands) hideSendTabToSelfUI];
}

#pragma mark - Private

- (void)show {
  std::optional<send_tab_to_self::EntryPointDisplayReason> displayReason =
      [self displayReason];
  DCHECK(displayReason);

  switch (*displayReason) {
    case send_tab_to_self::EntryPointDisplayReason::kInformNoTargetDevice:
    case send_tab_to_self::EntryPointDisplayReason::kOfferFeature: {
      ChromeBrowserState* browserState = self.browser->GetBrowserState();
      send_tab_to_self::SendTabToSelfSyncService* syncService =
          SendTabToSelfSyncServiceFactory::GetForBrowserState(browserState);
      // This modal should not be launched in incognito mode where syncService
      // is undefined.
      DCHECK(syncService);
      ChromeAccountManagerService* accountManagerService =
          ChromeAccountManagerServiceFactory::GetForBrowserState(browserState);
      DCHECK(accountManagerService);
      id<SystemIdentity> account =
          AuthenticationServiceFactory::GetForBrowserState(browserState)
              ->GetPrimaryIdentity(signin::ConsentLevel::kSignin);
      DCHECK(account) << "The user must be signed in to share a tab";
      self.sendTabToSelfViewController =
          [[SendTabToSelfTableViewController alloc]
              initWithDeviceList:syncService->GetSendTabToSelfModel()
                                     ->GetTargetDeviceInfoSortedList()
                        delegate:self
                   accountAvatar:accountManagerService
                                     ->GetIdentityAvatarWithIdentity(
                                         account,
                                         IdentityAvatarSize::TableViewIcon)
                    accountEmail:account.userEmail];
      UINavigationController* navigationController =
          [[UINavigationController alloc]
              initWithRootViewController:self.sendTabToSelfViewController];

      navigationController.transitioningDelegate = self;
      navigationController.modalPresentationStyle = UIModalPresentationCustom;
      [self.baseViewController presentViewController:navigationController
                                            animated:YES
                                          completion:nil];
      break;
    }
    case send_tab_to_self::EntryPointDisplayReason::kOfferSignIn: {
      __weak __typeof(self) weakSelf = self;
      ShowSigninCommandCompletionCallback callback =
          ^(SigninCoordinatorResult result,
            SigninCompletionInfo* completionInfo) {
            BOOL succeeded = result == SigninCoordinatorResultSuccess;
            [weakSelf onSigninComplete:succeeded];
          };
      ShowSigninCommand* command = [[ShowSigninCommand alloc]
          initWithOperation:AuthenticationOperation::kSigninOnly
                   identity:nil
                accessPoint:signin_metrics::AccessPoint::
                                ACCESS_POINT_SEND_TAB_TO_SELF_PROMO
                promoAction:signin_metrics::PromoAction::
                                PROMO_ACTION_NO_SIGNIN_PROMO
                   callback:callback];
      [self.signinPresenter showSignin:command];
      break;
    }
  }
}

- (void)onSigninComplete:(BOOL)succeeded {
  if (!succeeded) {
    [HandlerForProtocol(self.browser->GetCommandDispatcher(),
                        BrowserCoordinatorCommands) hideSendTabToSelfUI];
    return;
  }
  __weak __typeof(self) weakSelf = self;
  _targetDeviceListWaiter = std::make_unique<TargetDeviceListWaiter>(
      SyncServiceFactory::GetForBrowserState(self.browser->GetBrowserState()),
      base::BindRepeating(
          [](__typeof(self) strongSelf) { return [strongSelf displayReason]; },
          weakSelf),
      base::BindOnce(
          [](__typeof(self) strongSelf) {
            [strongSelf onTargetDeviceListReady];
          },
          weakSelf));
}

- (void)onTargetDeviceListReady {
  _targetDeviceListWaiter.reset();
  [self show];
}

- (std::optional<send_tab_to_self::EntryPointDisplayReason>)displayReason {
  send_tab_to_self::SendTabToSelfSyncService* service =
      SendTabToSelfSyncServiceFactory::GetForBrowserState(
          self.browser->GetBrowserState());
  return service ? service->GetEntryPointDisplayReason(_url) : std::nullopt;
}

@end