chromium/ios/chrome/browser/ui/settings/password/password_details/password_details_mediator.mm

// Copyright 2020 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/password/password_details/password_details_mediator.h"

#import <memory>
#import <utility>
#import <vector>

#import "base/containers/contains.h"
#import "base/containers/flat_set.h"
#import "base/memory/raw_ptr.h"
#import "base/ranges/algorithm.h"
#import "base/strings/sys_string_conversions.h"
#import "build/branding_buildflags.h"
#import "components/password_manager/core/browser/features/password_manager_features_util.h"
#import "components/password_manager/core/browser/password_form.h"
#import "components/password_manager/core/browser/password_manager_metrics_util.h"
#import "components/password_manager/core/browser/password_sync_util.h"
#import "components/password_manager/core/browser/ui/credential_ui_entry.h"
#import "components/password_manager/core/common/password_manager_pref_names.h"
#import "components/signin/public/identity_manager/account_info.h"
#import "components/sync/service/sync_service.h"
#import "ios/chrome/browser/passwords/model/ios_chrome_password_check_manager.h"
#import "ios/chrome/browser/passwords/model/ios_chrome_password_check_manager_factory.h"
#import "ios/chrome/browser/passwords/model/password_check_observer_bridge.h"
#import "ios/chrome/browser/passwords/model/password_checkup_metrics.h"
#import "ios/chrome/browser/passwords/model/password_checkup_utils.h"
#import "ios/chrome/browser/sync/model/sync_service_factory.h"
#import "ios/chrome/browser/ui/settings/password/account_storage_utils.h"
#import "ios/chrome/browser/ui/settings/password/password_details/credential_details.h"
#import "ios/chrome/browser/ui/settings/password/password_details/password_details_consumer.h"
#import "ios/chrome/browser/ui/settings/password/password_details/password_details_mediator+Testing.h"
#import "ios/chrome/browser/ui/settings/password/password_details/password_details_mediator_delegate.h"
#import "ios/chrome/browser/ui/settings/password/password_details/password_details_metrics_utils.h"
#import "ios/chrome/browser/ui/settings/password/password_details/password_details_table_view_controller_delegate.h"

#if !BUILDFLAG(GOOGLE_CHROME_BRANDING)
#import "base/command_line.h"
#import "components/password_manager/core/browser/password_manager_switches.h"
#endif  // !BUILDFLAG(GOOGLE_CHROME_BRANDING)

using base::SysNSStringToUTF16;
using password_manager::CredentialUIEntry;

namespace {

bool MatchesRealmUsernamePasswordAndCreationTime(
    CredentialDetails* credentialDetails,
    const CredentialUIEntry& credential) {
  return base::SysNSStringToUTF8(credentialDetails.signonRealm) ==
             credential.GetFirstSignonRealm() &&
         base::SysNSStringToUTF16(credentialDetails.username) ==
             credential.username &&
         base::SysNSStringToUTF16(credentialDetails.password) ==
             credential.password &&
         base::SysNSStringToUTF16(credentialDetails.userDisplayName) ==
             credential.user_display_name &&
         credentialDetails.creationTime == credential.creation_time;
}

// Whether displaying a credential as compromised is supported in the current
// context.
bool CanDisplayCredentialAsCompromised(DetailsContext details_context) {
  switch (details_context) {
    case DetailsContext::kPasswordSettings:
    case DetailsContext::kOutsideSettings:
    case DetailsContext::kCompromisedIssues:
    case DetailsContext::kDismissedWarnings:
      return true;
    case DetailsContext::kReusedIssues:
    case DetailsContext::kWeakIssues:
      return false;
  }
}

// Helper that determines if a credential should be displayed as compromised in
// password details. Even if a credential is compromised, it is only displayed
// as such when password details was opened from the password manager or the
// compromised password issues page.
bool ShouldDisplayCredentialAsCompromised(
    DetailsContext details_context,
    const CredentialUIEntry& credential,
    std::vector<password_manager::CredentialUIEntry> insecure_credentials) {
  if (!CanDisplayCredentialAsCompromised(details_context)) {
    return false;
  }

  for (const auto& insecure_credential : insecure_credentials) {
    if (credential == insecure_credential) {
      return IsCredentialUnmutedCompromised(insecure_credential);
    }
  }
  return false;
}

// Whether displaying a credential as muted is supported in the current context.
bool CanDisplayCredentialAsMuted(DetailsContext details_context) {
  switch (details_context) {
    case DetailsContext::kPasswordSettings:
    case DetailsContext::kOutsideSettings:
    case DetailsContext::kCompromisedIssues:
    case DetailsContext::kReusedIssues:
    case DetailsContext::kWeakIssues:
      return false;
    case DetailsContext::kDismissedWarnings:
      return true;
  }
}

// Helper that determines if a credential should be displayed as muted in
// password details. Even if a credential is muted, it is only displayed
// as such when password details was opened from the dismissed warning issues
// page.
bool ShouldDisplayCredentialAsMuted(
    DetailsContext details_context,
    const CredentialUIEntry& credential,
    std::vector<password_manager::CredentialUIEntry> insecure_credentials) {
  if (!CanDisplayCredentialAsMuted(details_context)) {
    return false;
  }

  for (const auto& insecure_credential : insecure_credentials) {
    if (credential == insecure_credential) {
      return insecure_credential.IsMuted();
    }
  }
  return false;
}

// Returns true if the credential matches the other arguments.
bool AreMatchingCredentials(const CredentialUIEntry& credential,
                            CredentialDetails* credential_details,
                            NSString* old_username,
                            NSString* old_user_display_name) {
  return
      [credential_details.signonRealm
          isEqualToString:base::SysUTF8ToNSString(
                              credential.GetFirstSignonRealm())] &&
      [old_username
          isEqualToString:base::SysUTF16ToNSString(credential.username)] &&
      [old_user_display_name isEqualToString:base::SysUTF16ToNSString(
                                                 credential.user_display_name)];
}

// Returns true if the credential matches the other arguments.
bool AreMatchingCredentials(const CredentialUIEntry& credential,
                            CredentialDetails* credential_details,
                            NSString* old_username,
                            NSString* old_password,
                            NSString* old_note) {
  return [credential_details.signonRealm
             isEqualToString:base::SysUTF8ToNSString(
                                 credential.GetFirstSignonRealm())] &&
         [old_username
             isEqualToString:base::SysUTF16ToNSString(credential.username)] &&
         [old_password
             isEqualToString:base::SysUTF16ToNSString(credential.password)] &&
         [old_note isEqualToString:base::SysUTF16ToNSString(credential.note)];
}

}  // namespace

@interface PasswordDetailsMediator () <
    PasswordCheckObserver,
    PasswordDetailsTableViewControllerDelegate> {
  // Password Check manager.
  scoped_refptr<IOSChromePasswordCheckManager> _manager;

  // Listens to compromised passwords changes.
  std::unique_ptr<PasswordCheckObserverBridge> _passwordCheckObserver;

  // The BrowserState pref service.
  raw_ptr<PrefService> _prefService;

  // The sync service.
  raw_ptr<syncer::SyncService> _syncService;

  // Delegate for this mediator.
  id<PasswordDetailsMediatorDelegate> _delegate;
}

// Dictionary of usernames of a same domain. Key: domain and value: NSSet of
// usernames.
@property(nonatomic, strong)
    NSMutableDictionary<NSString*, NSMutableSet<NSString*>*>*
        usernamesWithSameDomainDict;

// Display name to use for the Password Details view.
@property(nonatomic, strong) NSString* displayName;

// The context in which the password details are accessed.
@property(nonatomic, assign) DetailsContext context;

@end

@implementation PasswordDetailsMediator

- (instancetype)
    initWithPasswords:(const std::vector<CredentialUIEntry>&)credentials
          displayName:(NSString*)displayName
         browserState:(ChromeBrowserState*)browserState
              context:(DetailsContext)context
             delegate:(id<PasswordDetailsMediatorDelegate>)delegate {
  DCHECK(browserState);
  DCHECK(!credentials.empty());

  self = [super init];
  if (!self) {
    return nil;
  }

  _manager =
      IOSChromePasswordCheckManagerFactory::GetForBrowserState(browserState)
          .get();
  _passwordCheckObserver =
      std::make_unique<PasswordCheckObserverBridge>(self, _manager.get());
  _credentials = credentials;
  _displayName = displayName;
  _context = context;
  _prefService = browserState->GetPrefs();
  _syncService = SyncServiceFactory::GetForBrowserState(browserState);
  _delegate = delegate;

  return self;
}

- (void)setConsumer:(id<PasswordDetailsConsumer>)consumer {
  if (_consumer == consumer)
    return;
  _consumer = consumer;

  // The email might be empty and the callee handles that.
  [_consumer setUserEmail:base::SysUTF8ToNSString(
                              _syncService->GetAccountInfo().email)];

  [self providePasswordsToConsumer];

  if (self.credentials[0].blocked_by_user) {
    DCHECK_EQ(self.credentials.size(), 1u);
    [_consumer setIsBlockedSite:YES];
  }

  if ([self shouldDisplayShareButton]) {
    [_consumer setupRightShareButton:
                   _prefService->GetBoolean(
                       password_manager::prefs::kPasswordSharingEnabled)];
  }
}

- (void)disconnect {
  _passwordCheckObserver.reset();
  _manager = nullptr;
}

- (void)removeCredential:(CredentialDetails*)credentialDetails {
  // When details was opened from the Password Manager, only log password
  // check actions if the password is compromised.
  if (password_manager::ShouldRecordPasswordCheckUserAction(
          self.context, credentialDetails.compromised)) {
    password_manager::LogDeletePassword(
        password_manager::GetWarningTypeForDetailsContext(self.context));
  }

  // Map from CredentialDetails to CredentialUIEntry. Should support blocklists.
  auto it = base::ranges::find_if(
      _credentials, [credentialDetails](const CredentialUIEntry& credential) {
        return MatchesRealmUsernamePasswordAndCreationTime(credentialDetails,
                                                           credential);
      });
  if (it == _credentials.end()) {
    // TODO(crbug.com/40862365): Convert into DCHECK.
    return;
  }

  // Use the iterator before std::erase() makes it invalid.
  self.savedPasswordsPresenter->RemoveCredential(*it);
  // TODO(crbug.com/40862365). Once kPasswordsGrouping launches, the mediator
  // should update the passwords model and receive the updates via
  // SavedPasswordsPresenterObserver, instead of replicating the updates to its
  // own copy and calling [self providePasswordsToConsumer:]. Today when the
  // flag is disabled and the password is edited, it's impossible to identify
  // the new object to show (sign-on realm can't be used as an id, there might
  // be multiple credentials; nor username/password since the values changed).
  std::erase(_credentials, *it);
  [self providePasswordsToConsumer];

  // Update form managers so the list of password suggestions shown to the user
  // is the correct one.
  [_delegate updateFormManagers];
}

- (void)moveCredentialToAccountStore:(CredentialDetails*)credentialDetails {
  // Map from CredentialDetails to CredentialUIEntry.
  auto it = base::ranges::find_if(
      _credentials, [credentialDetails](const CredentialUIEntry& credential) {
        return MatchesRealmUsernamePasswordAndCreationTime(credentialDetails,
                                                           credential);
      });

  if (it == _credentials.end()) {
    return;
  }

  it->stored_in = {password_manager::PasswordForm::Store::kAccountStore};
  self.savedPasswordsPresenter->MoveCredentialsToAccount(
      {*it}, password_manager::metrics_util::MoveToAccountStoreTrigger::
                 kExplicitlyTriggeredInSettings);
  [self providePasswordsToConsumer];
}

- (void)moveCredentialToAccountStoreWithConflict:
    (CredentialDetails*)credentialDetails {
  auto localCredential = base::ranges::find_if(
      _credentials, [credentialDetails](const CredentialUIEntry& credential) {
        return MatchesRealmUsernamePasswordAndCreationTime(credentialDetails,
                                                           credential);
      });
  std::optional<CredentialUIEntry> accountCredential =
      [self conflictingAccountPassword:credentialDetails];
  DCHECK(localCredential != _credentials.end());
  DCHECK(accountCredential.has_value());
  if (localCredential->last_used_time < accountCredential->last_used_time) {
    [self removeCredential:credentialDetails];
    return;
  }
  [self removeCredential:[[CredentialDetails alloc]
                             initWithCredential:*accountCredential]];
  [self moveCredentialToAccountStore:credentialDetails];
}

- (BOOL)hasPasswordConflictInAccount:(CredentialDetails*)credential {
  return [self conflictingAccountPassword:credential].has_value();
}

- (void)didConfirmWarningDismissalForPassword:
    (CredentialDetails*)credentialDetails {
  // Map from CredentialDetails to CredentialUIEntry.
  auto it = base::ranges::find_if(
      _credentials, [credentialDetails](
                        const password_manager::CredentialUIEntry& credential) {
        return MatchesRealmUsernamePasswordAndCreationTime(credentialDetails,
                                                           credential);
      });

  if (it == _credentials.end()) {
    return;
  }

  _manager->MuteCredential(*it);
}

- (password_manager::SavedPasswordsPresenter*)savedPasswordsPresenter {
  return _manager->GetSavedPasswordsPresenter();
}

#pragma mark - PasswordDetailsTableViewControllerDelegate

- (void)passwordDetailsViewController:
            (PasswordDetailsTableViewController*)viewController
             didEditCredentialDetails:(CredentialDetails*)credentialDetails
                      withOldUsername:(NSString*)oldUsername
                   oldUserDisplayName:(NSString*)oldUserDisplayName
                          oldPassword:(NSString*)oldPassword
                              oldNote:(NSString*)oldNote {
  CredentialUIEntry originalCredential;
  CredentialUIEntry updatedCredential;
  std::vector<CredentialUIEntry>::iterator it;
  if (credentialDetails.credentialType == CredentialTypePasskey) {
    it = base::ranges::find_if(
        _credentials, [credentialDetails, oldUsername, oldUserDisplayName](
                          const CredentialUIEntry& credential) {
          return AreMatchingCredentials(credential, credentialDetails,
                                        oldUsername, oldUserDisplayName);
        });

    // There should be no reason not to find the credential in the vector of
    // credentials.
    DCHECK(it != _credentials.end());

    originalCredential = *it;
    updatedCredential = originalCredential;
    updatedCredential.username = SysNSStringToUTF16(credentialDetails.username);
    updatedCredential.user_display_name =
        SysNSStringToUTF16(credentialDetails.userDisplayName);
  } else if ([credentialDetails.password length] != 0) {
    it = base::ranges::find_if(
        _credentials, [credentialDetails, oldUsername, oldPassword,
                       oldNote](const CredentialUIEntry& credential) {
          return AreMatchingCredentials(credential, credentialDetails,
                                        oldUsername, oldPassword, oldNote);
        });

    // There should be no reason not to find the credential in the vector of
    // credentials.
    CHECK(it != _credentials.end());

    originalCredential = *it;
    updatedCredential = originalCredential;
    updatedCredential.username = SysNSStringToUTF16(credentialDetails.username);
    updatedCredential.password = SysNSStringToUTF16(credentialDetails.password);
    updatedCredential.note = SysNSStringToUTF16(credentialDetails.note);
  } else {
    return;
  }

  if (self.savedPasswordsPresenter->EditSavedCredentials(originalCredential,
                                                         updatedCredential) ==
      password_manager::SavedPasswordsPresenter::EditResult::kSuccess) {
    // Update the usernames by domain dictionary.
    NSString* signonRealm =
        base::SysUTF8ToNSString(updatedCredential.GetFirstSignonRealm());
    [self updateOldUsernameInDict:oldUsername
                    toNewUsername:credentialDetails.username
                  withSignonRealm:signonRealm];

    // Update the credential in the credentials vector.
    *it = std::move(updatedCredential);

    // Update form managers so the list of password suggestions shown to the
    // user is the correct one.
    [_delegate updateFormManagers];
  }
}

- (void)didFinishEditingPasswordDetails {
  [self providePasswordsToConsumer];
}

- (void)passwordDetailsViewController:
            (PasswordDetailsTableViewController*)viewController
                didAddPasswordDetails:(NSString*)username
                             password:(NSString*)password {
  NOTREACHED_IN_MIGRATION();
}

- (void)checkForDuplicates:(NSString*)username {
  NOTREACHED_IN_MIGRATION();
}

- (void)showExistingCredential:(NSString*)username {
  NOTREACHED_IN_MIGRATION();
}

- (void)didCancelAddPasswordDetails {
  NOTREACHED_IN_MIGRATION();
}

- (void)setWebsiteURL:(NSString*)website {
  NOTREACHED_IN_MIGRATION();
}

- (BOOL)isURLValid {
  return YES;
}

- (BOOL)isTLDMissing {
  return NO;
}

- (BOOL)isUsernameReused:(NSString*)newUsername forDomain:(NSString*)domain {
  // It is more efficient to check set of the usernames for the same origin
  // instead of delegating this to the `_manager`.
  return [[self.usernamesWithSameDomainDict objectForKey:domain]
      containsObject:newUsername];
}

- (void)dismissWarningForPassword:(CredentialDetails*)credential {
  // Show confirmation dialog.
  [_delegate showDismissWarningDialogWithCredentialDetails:credential];
}

- (void)restoreWarningForCurrentPassword {
  // Restoring a warning is only available in the
  // DetailsContext::kDismissedWarnings context, which is always showing only 1
  // credential.
  CHECK(self.credentials.size() == 1);
  password_manager::CredentialUIEntry credential = self.credentials[0];
  _manager->UnmuteCredential(credential);
  std::erase(_credentials, credential);
  [self providePasswordsToConsumer];
}

#pragma mark - PasswordCheckObserver

- (void)passwordCheckStateDidChange:(PasswordCheckState)state {
  // No-op. Changing password check state has no effect on compromised
  // passwords.
}

- (void)insecureCredentialsDidChange {
  [self providePasswordsToConsumer];
}

- (void)passwordCheckManagerWillShutdown {
  _passwordCheckObserver.reset();
}

#pragma mark - Private

- (NSMutableDictionary<NSString*, NSMutableSet<NSString*>*>*)
    usernamesWithSameDomainDict {
  if (!_usernamesWithSameDomainDict) {
    // TODO(crbug.com/40883869): Improve saved passwords logic when helper is
    // available in SavedPasswordsPresenter.
    _usernamesWithSameDomainDict = [[NSMutableDictionary alloc] init];
    NSMutableSet<NSString*>* signonRealms = [[NSMutableSet alloc] init];
    auto savedCredentials = self.savedPasswordsPresenter->GetSavedCredentials();

    // Store all usernames by domain.
    for (const auto& credential : self.credentials) {
      [signonRealms
          addObject:base::SysUTF8ToNSString(credential.GetFirstSignonRealm())];
    }
    for (const auto& cred : savedCredentials) {
      NSString* signonRealm =
          base::SysUTF8ToNSString(cred.GetFirstSignonRealm());
      if ([signonRealms containsObject:signonRealm]) {
        NSMutableSet* set =
            [_usernamesWithSameDomainDict objectForKey:signonRealm];
        if (!set) {
          set = [[NSMutableSet alloc] init];
          [set addObject:base::SysUTF16ToNSString(cred.username)];
          [_usernamesWithSameDomainDict setObject:set forKey:signonRealm];

        } else {
          [set addObject:base::SysUTF16ToNSString(cred.username)];
        }
      }
    }
  }
  return _usernamesWithSameDomainDict;
}

// Pushes password details to the consumer.
- (void)providePasswordsToConsumer {
  NSMutableArray<CredentialDetails*>* passwords = [NSMutableArray array];
  // Fetch the insecure credentials to get their updated version.
  std::vector<password_manager::CredentialUIEntry> insecureCredentials;
  // Only fetch insecure credentials if they are going to be used.
  if (CanDisplayCredentialAsCompromised(self.context) ||
      CanDisplayCredentialAsMuted(self.context)) {
    insecureCredentials = _manager->GetInsecureCredentials();
  }
  for (const CredentialUIEntry& credential : self.credentials) {
    CredentialDetails* credentialDetails =
        [[CredentialDetails alloc] initWithCredential:credential];
    credentialDetails.context = self.context;
    credentialDetails.compromised = ShouldDisplayCredentialAsCompromised(
        self.context, credential, insecureCredentials);

    // `credentialDetails.isCompromised` is always false for muted credentials,
    // so short-circuit to avoid unnecessary computation in
    // ShouldDisplayCredentialAsMuted.
    credentialDetails.muted =
        !credentialDetails.isCompromised &&
        ShouldDisplayCredentialAsMuted(self.context, credential,
                                       insecureCredentials);

    // Only offer moving to the account if all of these hold.
    // - The embedder of this page wants to support it.
    // - The entry was flagged as local only in the top-level view.
    // - The user is interested in saving passwords to the account, i.e. they
    // are opted in to account storage.
    credentialDetails.shouldOfferToMoveToAccount =
        self.context == DetailsContext::kPasswordSettings &&
        password_manager::features_util::IsOptedInForAccountStorage(
            _prefService, _syncService) &&
        ShouldShowLocalOnlyIcon(credential, _syncService);
    [passwords addObject:credentialDetails];
  }
  [self.consumer setCredentials:passwords andTitle:_displayName];
}

// Update the usernames by domain dictionary by removing the old username and
// adding the new one if it has changed.
- (void)updateOldUsernameInDict:(NSString*)oldUsername
                  toNewUsername:(NSString*)newUsername
                withSignonRealm:(NSString*)signonRealm {
  if ([oldUsername isEqualToString:newUsername]) {
    return;
  }

  NSMutableSet* set =
      [self.usernamesWithSameDomainDict objectForKey:signonRealm];
  if (set) {
    [set removeObject:oldUsername];
    [set addObject:newUsername];
  }
}

// Returns a credential that a) is saved in the user account, and b) has the
// same website/username as `credentialDetails`, but a different password value.
- (std::optional<CredentialUIEntry>)conflictingAccountPassword:
    (CredentialDetails*)credentialDetails {
  // All credentials for the same website are in `_credentials` due to password
  // grouping. So it's enough to search that reduced list and not all saved
  // passwords.
  auto it = base::ranges::find_if(
      _credentials, [credentialDetails](const CredentialUIEntry& credential) {
        return credential.stored_in.contains(
                   password_manager::PasswordForm::Store::kAccountStore) &&
               base::SysNSStringToUTF8(credentialDetails.signonRealm) ==
                   credential.GetFirstSignonRealm() &&
               base::SysNSStringToUTF16(credentialDetails.username) ==
                   credential.username &&
               base::SysNSStringToUTF16(credentialDetails.password) !=
                   credential.password;
      });
  if (it == _credentials.end()) {
    return std::nullopt;
  }
  return *it;
}

// Returns YES if all of the following conditions are met:
// * User is syncing or signed in and opted in to account storage.
// * Password sending feature is enabled.
// * Build is branded (bypassed with a command line switch in EG tests).
- (BOOL)shouldDisplayShareButton {
#if !BUILDFLAG(GOOGLE_CHROME_BRANDING)
  if (!base::CommandLine::ForCurrentProcess()->HasSwitch(
          password_manager::kEnableShareButtonUnbranded)) {
    return false;
  }
#endif  // !BUILDFLAG(GOOGLE_CHROME_BRANDING)

  return password_manager::sync_util::GetAccountForSaving(_prefService,
                                                          _syncService)
      .has_value();
}

@end