chromium/ios/chrome/browser/ui/settings/sync/sync_encryption_passphrase_table_view_controller.mm

// Copyright 2015 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/settings/sync/sync_encryption_passphrase_table_view_controller.h"

#import <memory>

#import "base/apple/foundation_util.h"
#import "base/i18n/time_formatting.h"
#import "base/ios/ios_util.h"
#import "base/metrics/user_metrics.h"
#import "base/metrics/user_metrics_action.h"
#import "base/strings/sys_string_conversions.h"
#import "components/google/core/common/google_util.h"
#import "components/signin/public/identity_manager/objc/identity_manager_observer_bridge.h"
#import "components/strings/grit/components_strings.h"
#import "components/sync/service/sync_service.h"
#import "components/sync/service/sync_user_settings.h"
#import "ios/chrome/browser/net/model/crurl.h"
#import "ios/chrome/browser/settings/model/sync/utils/sync_util.h"
#import "ios/chrome/browser/shared/coordinator/scene/scene_state.h"
#import "ios/chrome/browser/shared/model/application_context/application_context.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/features/features.h"
#import "ios/chrome/browser/shared/ui/table_view/cells/table_view_link_header_footer_item.h"
#import "ios/chrome/browser/shared/ui/table_view/cells/table_view_text_item.h"
#import "ios/chrome/browser/shared/ui/table_view/table_view_utils.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/identity_manager_factory.h"
#import "ios/chrome/browser/signin/model/system_identity.h"
#import "ios/chrome/browser/sync/model/sync_service_factory.h"
#import "ios/chrome/browser/ui/scoped_ui_blocker/scoped_ui_blocker.h"
#import "ios/chrome/browser/ui/settings/cells/byo_textfield_item.h"
#import "ios/chrome/browser/ui/settings/cells/passphrase_error_item.h"
#import "ios/chrome/browser/ui/settings/google_services/google_services_settings_constants.h"
#import "ios/chrome/browser/ui/settings/settings_navigation_controller.h"
#import "ios/chrome/grit/ios_branded_strings.h"
#import "ios/chrome/grit/ios_strings.h"
#import "ui/base/l10n/l10n_util.h"
#import "ui/base/l10n/l10n_util_mac.h"
#import "url/gurl.h"

using sync_encryption_passphrase::ItemTypeEnterPassphrase;
using sync_encryption_passphrase::ItemTypeError;
using sync_encryption_passphrase::ItemTypeFooter;
using sync_encryption_passphrase::ItemTypeMessage;
using sync_encryption_passphrase::SectionIdentifierPassphrase;

namespace {

const CGFloat kSpinnerButtonCustomViewSize = 48;
const CGFloat kSpinnerButtonPadding = 18;

}  // namespace

@interface SyncEncryptionPassphraseTableViewController () <
    IdentityManagerObserverBridgeDelegate> {
  // Whether the decryption progress is currently being shown.
  BOOL _isDecryptionProgressShown;
  NSString* _savedTitle;
  UIBarButtonItem* _savedLeftButton;
  std::unique_ptr<SyncObserverBridge> _syncObserver;
  std::unique_ptr<signin::IdentityManagerObserverBridge>
      _identityManagerObserver;
  UITextField* _passphrase;
  std::unique_ptr<ScopedUIBlocker> _uiBlocker;

  // Whether Settings have been dismissed.
  BOOL _settingsAreDismissed;
}

@property(nonatomic, assign, readonly) Browser* browser;

@end

@implementation SyncEncryptionPassphraseTableViewController

@synthesize browser = _browser;

- (instancetype)initWithBrowser:(Browser*)browser {
  DCHECK(browser);

  self = [super initWithStyle:ChromeTableViewStyle()];
  if (!self) {
    return nullptr;
  }

  _browser = browser;
  _processingMessage = l10n_util::GetNSString(IDS_SYNC_LOGIN_SETTING_UP);
  _footerMessage = l10n_util::GetNSString(IDS_IOS_SYNC_PASSPHRASE_RECOVER);
  self.title = l10n_util::GetNSString(IDS_IOS_SYNC_ENTER_PASSPHRASE_TITLE);
  self.shouldHideDoneButton = YES;

  ChromeBrowserState* browserState = _browser->GetBrowserState();
  syncer::SyncService* service =
      SyncServiceFactory::GetForBrowserState(browserState);
  // TODO(crbug.com/40765960): The reason this is an if and not a DCHECK is
  // because SyncCreatePassphraseTableViewController inherits from this class.
  // This should be changed, i.e. either extract the minimum common logic
  // between the 2 to a new base class, or not share code at all.
  if (service->IsEngineInitialized() &&
      service->GetUserSettings()->IsUsingExplicitPassphrase()) {
    base::Time passphraseTime =
        service->GetUserSettings()->GetExplicitPassphraseTime();
    NSString* userEmail =
        AuthenticationServiceFactory::GetForBrowserState(browserState)
            ->GetPrimaryIdentity(signin::ConsentLevel::kSignin)
            .userEmail;
    DCHECK(userEmail);
    _headerMessage =
        passphraseTime.is_null()
            ? l10n_util::GetNSStringF(
                  IDS_IOS_SYNC_ENTER_PASSPHRASE_BODY_WITH_EMAIL,
                  base::SysNSStringToUTF16(userEmail))
            : l10n_util::GetNSStringF(
                  IDS_IOS_SYNC_ENTER_PASSPHRASE_BODY_WITH_EMAIL_AND_DATE,
                  base::SysNSStringToUTF16(userEmail),
                  base::TimeFormatShortDate(passphraseTime));
  }

  _identityManagerObserver =
      std::make_unique<signin::IdentityManagerObserverBridge>(
          IdentityManagerFactory::GetForBrowserState(browserState), self);
  return self;
}

- (UITextField*)passphrase {
  return _passphrase;
}

- (NSString*)syncErrorMessage {
  if (_settingsAreDismissed)
    return nil;
  if (_syncErrorMessage)
    return _syncErrorMessage;
  ChromeBrowserState* browserState = self.browser->GetBrowserState();
  syncer::SyncService* service =
      SyncServiceFactory::GetForBrowserState(browserState);
  DCHECK(service);

  // Passphrase error directly set `_syncErrorMessage`.
  if (service->GetUserActionableError() ==
      syncer::SyncService::UserActionableError::kNeedsPassphrase) {
    return nil;
  }

  return GetSyncErrorMessageForBrowserState(browserState);
}

#pragma mark - View lifecycle

- (void)viewDidLoad {
  [super viewDidLoad];
  [self loadModel];
  [self setRightNavBarItem];
  [self setLeftNavBarItem];

  SceneState* sceneState = self.browser->GetSceneState();
  _uiBlocker = std::make_unique<ScopedUIBlocker>(sceneState);
  self.view.accessibilityIdentifier =
      kSyncEncryptionPassphraseTableViewAccessibilityIdentifier;
}

- (void)didReceiveMemoryWarning {
  [super didReceiveMemoryWarning];
  if (![self isViewLoaded]) {
    _passphrase = nil;
  }
}

- (void)viewWillDisappear:(BOOL)animated {
  [super viewWillDisappear:animated];
  [self.passphrase resignFirstResponder];
}

- (void)viewDidDisappear:(BOOL)animated {
  [super viewDidDisappear:animated];
  if ([self isMovingFromParentViewController]) {
    [self unregisterTextField:self.passphrase];
  }
  _uiBlocker.reset();
}

#pragma mark - SettingsRootTableViewController

- (void)loadModel {
  [super loadModel];
  TableViewModel* model = self.tableViewModel;

  [model addSectionWithIdentifier:SectionIdentifierPassphrase];
  if (self.headerMessage) {
    [model addItem:[self passphraseMessageItem]
        toSectionWithIdentifier:SectionIdentifierPassphrase];
  }
  [model addItem:[self passphraseItem]
      toSectionWithIdentifier:SectionIdentifierPassphrase];

  NSString* errorMessage = [self syncErrorMessage];
  if (errorMessage) {
    [model addItem:[self passphraseErrorItemWithMessage:errorMessage]
        toSectionWithIdentifier:SectionIdentifierPassphrase];
  }
  [model setFooter:[self footerItem]
      forSectionWithIdentifier:SectionIdentifierPassphrase];
}

#pragma mark - UIAdaptivePresentationControllerDelegate

- (BOOL)presentationControllerShouldDismiss:
    (UIPresentationController*)presentationController {
  return ![_passphrase.text length];
}

#pragma mark - Items

// Returns a passphrase message item.
- (TableViewItem*)passphraseMessageItem {
  TableViewTextItem* item =
      [[TableViewTextItem alloc] initWithType:ItemTypeMessage];
  item.text = self.headerMessage;
  item.enabled = NO;
  return item;
}

// Returns a passphrase item.
- (TableViewItem*)passphraseItem {
  if (_passphrase) {
    [self unregisterTextField:_passphrase];
  }
  _passphrase = [[UITextField alloc] init];
  _passphrase.secureTextEntry = YES;
  _passphrase.backgroundColor = UIColor.clearColor;
  _passphrase.autocorrectionType = UITextAutocorrectionTypeNo;
  _passphrase.font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody];
  _passphrase.adjustsFontForContentSizeCategory = YES;
  _passphrase.placeholder = l10n_util::GetNSString(IDS_SYNC_PASSPHRASE_LABEL);
  _passphrase.accessibilityIdentifier =
      kSyncEncryptionPassphraseTextFieldAccessibilityIdentifier;
  [self registerTextField:_passphrase];

  BYOTextFieldItem* item =
      [[BYOTextFieldItem alloc] initWithType:ItemTypeEnterPassphrase];
  item.textField = _passphrase;
  return item;
}

// Returns a passphrase error item having `errorMessage` as title.
- (TableViewItem*)passphraseErrorItemWithMessage:(NSString*)errorMessage {
  PassphraseErrorItem* item =
      [[PassphraseErrorItem alloc] initWithType:ItemTypeError];
  item.text = errorMessage;
  return item;
}

// Returns the footer item for passphrase section.
- (TableViewHeaderFooterItem*)footerItem {
  TableViewLinkHeaderFooterItem* footerItem =
      [[TableViewLinkHeaderFooterItem alloc] initWithType:ItemTypeFooter];
  footerItem.text = self.footerMessage;
  footerItem.urls = @[ [[CrURL alloc]
      initWithGURL:google_util::AppendGoogleLocaleParam(
                       GURL(kSyncGoogleDashboardURL),
                       GetApplicationContext()->GetApplicationLocale())] ];
  return footerItem;
}

#pragma mark - UITableViewDelegate

- (void)tableView:(UITableView*)tableView
    didSelectRowAtIndexPath:(NSIndexPath*)indexPath {
  [super tableView:tableView didSelectRowAtIndexPath:indexPath];
  NSInteger itemType = [self.tableViewModel itemTypeForIndexPath:indexPath];
  if (itemType == ItemTypeEnterPassphrase) {
    [_passphrase becomeFirstResponder];
  }
  [tableView deselectRowAtIndexPath:indexPath animated:YES];
}

- (UIView*)tableView:(UITableView*)tableView
    viewForFooterInSection:(NSInteger)section {
  UIView* view = [super tableView:tableView viewForFooterInSection:section];
  if (SectionIdentifierPassphrase ==
      [self.tableViewModel sectionIdentifierForSectionIndex:section]) {
    TableViewLinkHeaderFooterView* linkView =
        base::apple::ObjCCastStrict<TableViewLinkHeaderFooterView>(view);
    linkView.delegate = self;
  }
  return view;
}

#pragma mark - Behavior

- (BOOL)forDecryption {
  return YES;
}

- (void)signInPressed {
  DCHECK(!_settingsAreDismissed);
  DCHECK([_passphrase text].length);
  ChromeBrowserState* browserState = self.browser->GetBrowserState();

  if (!_syncObserver.get()) {
    _syncObserver.reset(new SyncObserverBridge(
        self, SyncServiceFactory::GetForBrowserState(browserState)));
  }

  // Clear out the error message.
  self.syncErrorMessage = nil;

  syncer::SyncService* service =
      SyncServiceFactory::GetForBrowserState(browserState);
  DCHECK(service);
  // It is possible for a race condition to happen where a user is allowed
  // to call the backend with the passphrase before the backend is
  // initialized.
  // See crbug/276714. As a temporary measure, ignore the tap on sign-in
  // button. A better fix may be to disable the rightBarButtonItem (submit)
  // until backend is initialized.
  if (!service->IsEngineInitialized())
    return;

  [self showDecryptionProgress];
  std::string passphrase = base::SysNSStringToUTF8([_passphrase text]);
  if ([self forDecryption]) {
    if (!service->GetUserSettings()->SetDecryptionPassphrase(passphrase)) {
      _syncObserver.reset();
      [self clearFieldsOnError:l10n_util::GetNSString(
                                   IDS_IOS_SYNC_INCORRECT_PASSPHRASE)];
      [self hideDecryptionProgress];
    }
  } else {
    service->GetUserSettings()->SetEncryptionPassphrase(passphrase);
  }
  [self reloadData];
}

- (void)cancelPressed {
  CHECK(self.presentModally);
  [self.navigationController.presentingViewController
      dismissViewControllerAnimated:YES
                         completion:nil];
}

// Sets up the navigation bar's right button. The button will be enabled iff
// `-areAllFieldsFilled` returns YES.
- (void)setRightNavBarItem {
  UIBarButtonItem* submitButtonItem = self.navigationItem.rightBarButtonItem;
  if (!submitButtonItem) {
    submitButtonItem = [[UIBarButtonItem alloc]
        initWithTitle:l10n_util::GetNSString(IDS_IOS_SYNC_DECRYPT_BUTTON)
                style:UIBarButtonItemStylePlain
               target:self
               action:@selector(signInPressed)];
  }
  submitButtonItem.enabled = [self areAllFieldsFilled];

  // Only setting the enabled state doesn't make the item redraw. As a
  // workaround, set it again.
  self.navigationItem.rightBarButtonItem = submitButtonItem;
}

- (void)setLeftNavBarItem {
  if (!self.presentModally) {
    return;
  }
  UIBarButtonItem* cancelButton = [[UIBarButtonItem alloc]
      initWithBarButtonSystemItem:UIBarButtonSystemItemCancel
                           target:self
                           action:@selector(cancelPressed)];
  self.navigationItem.leftBarButtonItem = cancelButton;
}

- (BOOL)areAllFieldsFilled {
  return [self.passphrase text].length > 0;
}

- (void)clearFieldsOnError:(NSString*)errorMessage {
  self.syncErrorMessage = errorMessage;
  [self.passphrase setText:@""];
}

// Shows the UI to indicate the decryption is being attempted.
- (void)showDecryptionProgress {
  if (_isDecryptionProgressShown)
    return;
  _isDecryptionProgressShown = YES;

  // Hide the button.
  self.navigationItem.rightBarButtonItem = nil;

  // Custom title view with spinner.
  DCHECK(!_savedTitle);
  DCHECK(!_savedLeftButton);
  _savedLeftButton = self.navigationItem.leftBarButtonItem;
  self.navigationItem.leftBarButtonItem = [self spinnerButton];
  _savedTitle = [self.title copy];
  self.title = self.processingMessage;
}

// Hides the UI to indicate decryption is in process.
- (void)hideDecryptionProgress {
  if (!_isDecryptionProgressShown)
    return;
  _isDecryptionProgressShown = NO;

  self.navigationItem.leftBarButtonItem = _savedLeftButton;
  _savedLeftButton = nil;
  self.title = _savedTitle;
  _savedTitle = nil;
  [self setRightNavBarItem];
}

- (void)registerTextField:(UITextField*)textField {
  [textField addTarget:self
                action:@selector(textFieldDidBeginEditing:)
      forControlEvents:UIControlEventEditingDidBegin];
  [textField addTarget:self
                action:@selector(textFieldDidChange:)
      forControlEvents:UIControlEventEditingChanged];
  [textField addTarget:self
                action:@selector(textFieldDidEndEditing:)
      forControlEvents:UIControlEventEditingDidEndOnExit];
}

- (void)unregisterTextField:(UITextField*)textField {
  [textField removeTarget:self
                   action:@selector(textFieldDidBeginEditing:)
         forControlEvents:UIControlEventEditingDidBegin];
  [textField removeTarget:self
                   action:@selector(textFieldDidChange:)
         forControlEvents:UIControlEventEditingChanged];
  [textField removeTarget:self
                   action:@selector(textFieldDidEndEditing:)
         forControlEvents:UIControlEventEditingDidEndOnExit];
}

// Creates a new UIBarButtonItem with a spinner.
- (UIBarButtonItem*)spinnerButton {
  CGRect customViewFrame = CGRectMake(0, 0, kSpinnerButtonCustomViewSize,
                                      kSpinnerButtonCustomViewSize);
  UIView* customView = [[UIView alloc] initWithFrame:customViewFrame];

  UIActivityIndicatorView* spinner = GetMediumUIActivityIndicatorView();

  CGRect spinnerFrame = [spinner bounds];
  spinnerFrame.origin.x = kSpinnerButtonPadding;
  spinnerFrame.origin.y = kSpinnerButtonPadding;
  [spinner setFrame:spinnerFrame];
  [customView addSubview:spinner];

  UIBarButtonItem* leftBarButtonItem =
      [[UIBarButtonItem alloc] initWithCustomView:customView];

  [spinner setHidesWhenStopped:NO];
  [spinner startAnimating];

  return leftBarButtonItem;
}

#pragma mark - UIControl events listener

- (void)textFieldDidBeginEditing:(id)sender {
  // Remove the error cell if there is one.
  TableViewModel* model = self.tableViewModel;
  if ([model hasItemForItemType:ItemTypeError
              sectionIdentifier:SectionIdentifierPassphrase]) {
    DCHECK(self.syncErrorMessage);
    NSIndexPath* path =
        [model indexPathForItemType:ItemTypeError
                  sectionIdentifier:SectionIdentifierPassphrase];
    [model removeItemWithType:ItemTypeError
        fromSectionWithIdentifier:SectionIdentifierPassphrase];
    [self.tableView deleteRowsAtIndexPaths:@[ path ]
                          withRowAnimation:UITableViewRowAnimationAutomatic];
    self.syncErrorMessage = nil;
  }
}

- (void)textFieldDidChange:(id)sender {
  [self setRightNavBarItem];
}

- (void)textFieldDidEndEditing:(id)sender {
  if (sender == self.passphrase) {
    if ([self areAllFieldsFilled]) {
      [self signInPressed];
    } else {
      [self clearFieldsOnError:l10n_util::GetNSString(
                                   IDS_SYNC_EMPTY_PASSPHRASE_ERROR)];
      [self reloadData];
    }
  }
}

#pragma mark - SyncObserverModelBridge

- (void)onSyncStateChanged {
  DCHECK(!_settingsAreDismissed);
  ChromeBrowserState* browserState = self.browser->GetBrowserState();
  syncer::SyncService* service =
      SyncServiceFactory::GetForBrowserState(browserState);

  if (!service->IsEngineInitialized()) {
    return;
  }

  // Checking if the operation succeeded.
  if (!service->GetUserSettings()->IsPassphraseRequired() &&
      (service->GetUserSettings()->IsUsingExplicitPassphrase() ||
       [self forDecryption])) {
    _syncObserver.reset();
    SettingsNavigationController* settingsNavigationController =
        base::apple::ObjCCast<SettingsNavigationController>(
            self.navigationController);
    // During the sign-in flow it is possible for the Sync state to
    // change when the user is in the Advanced Settings (e.g., if the user
    // confirms a Sync passphrase). Because these navigation controllers are
    // not directly related to Settings, we check the type before dismissal.
    // TODO(crbug.com/40158230): Revisit with Advanced Sync Settings changes.
    if (settingsNavigationController) {
      [settingsNavigationController
          popViewControllerOrCloseSettingsAnimated:YES];
    } else if (self.presentModally) {
      [self.navigationController.presentingViewController
          dismissViewControllerAnimated:YES
                             completion:nil];
    } else {
      [self.navigationController popViewControllerAnimated:YES];
    }
    return;
  }

  // Handling passphrase error case.
  if (service->GetUserSettings()->IsPassphraseRequired()) {
    self.syncErrorMessage =
        l10n_util::GetNSString(IDS_IOS_SYNC_INCORRECT_PASSPHRASE);
  }
  [self hideDecryptionProgress];
  [self reloadData];
}

#pragma mark - IdentityManagerObserverBridgeDelegate

- (void)onEndBatchOfRefreshTokenStateChanges {
  DCHECK(!_settingsAreDismissed);
  ChromeBrowserState* browserState = self.browser->GetBrowserState();
  if (AuthenticationServiceFactory::GetForBrowserState(browserState)
          ->HasPrimaryIdentity(signin::ConsentLevel::kSignin)) {
    return;
  }
  if (!self.presentModally) {
    [base::apple::ObjCCastStrict<SettingsNavigationController>(
        self.navigationController) popViewControllerOrCloseSettingsAnimated:NO];
  }
}

#pragma mark - SettingsControllerProtocol callbacks

- (void)reportDismissalUserAction {
  // Sync Passphrase Settings screen can be closed when being presented from
  // an infobar.
  base::RecordAction(
      base::UserMetricsAction("MobileSyncPassphraseSettingsClose"));
}

- (void)reportBackUserAction {
  // No-op for this view controller.
}

- (void)settingsWillBeDismissed {
  if (_settingsAreDismissed) {
    // This method can be called twice when the account is removed. Related to
    // crbug.com/1480441.
    return;
  }

  // Remove observer bridges.
  _syncObserver.reset();
  _identityManagerObserver.reset();

  // Clear C++ ivars.
  _browser = nullptr;

  _settingsAreDismissed = true;
}

#pragma mark - UIAdaptivePresentationControllerDelegate

- (void)presentationControllerDidDismiss:
    (UIPresentationController*)presentationController {
  base::RecordAction(base::UserMetricsAction(
      "IOSSyncEncryptionPassphraseSettingsCloseWithSwipe"));
}

@end