// Copyright 2022 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/passwords/ui_bundled/password_suggestion_coordinator.h"
#import "base/check.h"
#import "base/metrics/histogram_functions.h"
#import "base/notreached.h"
#import "base/strings/sys_string_conversions.h"
#import "components/autofill/core/common/password_generation_util.h"
#import "components/password_manager/core/browser/features/password_features.h"
#import "components/password_manager/ios/constants.h"
#import "components/password_manager/ios/password_manager_java_script_feature.h"
#import "components/prefs/pref_service.h"
#import "ios/chrome/browser/autofill/model/bottom_sheet/autofill_bottom_sheet_tab_helper.h"
#import "ios/chrome/browser/autofill/model/form_input_accessory_view_handler.h"
#import "ios/chrome/browser/passwords/ui_bundled/password_suggestion_view_controller.h"
#import "ios/chrome/browser/shared/model/browser/browser.h"
#import "ios/chrome/browser/shared/model/prefs/pref_names.h"
#import "ios/chrome/browser/shared/model/profile/profile_ios.h"
#import "ios/chrome/browser/shared/model/web_state_list/web_state_list.h"
#import "ios/chrome/browser/shared/public/commands/application_commands.h"
#import "ios/chrome/browser/shared/public/commands/command_dispatcher.h"
#import "ios/chrome/browser/signin/model/authentication_service.h"
#import "ios/chrome/browser/signin/model/authentication_service_factory.h"
#import "ios/chrome/common/ui/confirmation_alert/confirmation_alert_action_handler.h"
#import "ios/web/public/js_messaging/web_frame.h"
#import "ios/web/public/js_messaging/web_frames_manager.h"
#import "ios/web/public/web_state.h"
#import "ui/base/device_form_factor.h"
namespace {
constexpr CGFloat preferredCornerRadius = 20;
} // namespace
@interface PasswordSuggestionCoordinator () <
ConfirmationAlertActionHandler,
UIAdaptivePresentationControllerDelegate>
// Main view controller for this coordinator.
@property(nonatomic, strong) PasswordSuggestionViewController* viewController;
// The suggested strong password.
@property(nonatomic, copy) NSString* passwordSuggestion;
// The suggest password decision handler.
@property(nonatomic, copy) void (^decisionHandler)(BOOL accept);
@end
@implementation PasswordSuggestionCoordinator {
// YES when the bottom sheet is proactive where it is triggered upon focus.
BOOL _proactive;
}
- (instancetype)initWithBaseViewController:(UIViewController*)baseViewController
browser:(Browser*)browser
passwordSuggestion:(NSString*)passwordSuggestion
decisionHandler:
(void (^)(BOOL accept))decisionHandler
proactive:(BOOL)proactive {
self = [super initWithBaseViewController:baseViewController browser:browser];
if (self) {
_passwordSuggestion = passwordSuggestion;
_decisionHandler = decisionHandler;
_proactive = proactive;
}
return self;
}
- (void)start {
self.viewController = [[PasswordSuggestionViewController alloc]
initWithPasswordSuggestion:self.passwordSuggestion
userEmail:[self userEmail]
proactive:_proactive];
self.viewController.presentationController.delegate = self;
self.viewController.actionHandler = self;
NSNotificationCenter* defaultCenter = [NSNotificationCenter defaultCenter];
[defaultCenter addObserver:self
selector:@selector(applicationDidEnterBackground:)
name:UIApplicationDidEnterBackgroundNotification
object:nil];
self.viewController.modalPresentationStyle = UIModalPresentationPageSheet;
UISheetPresentationController* presentationController =
self.viewController.sheetPresentationController;
presentationController.detents = [self detents];
presentationController.prefersEdgeAttachedInCompactHeight =
[self isEdgeAttachedInCompactHeight];
presentationController.preferredCornerRadius = preferredCornerRadius;
// Immediately dismiss the keyboard (only on tablet) because the
// PasswordSuggestion view controller is incorrectly being displayed behind
// the keyboard. This issue does not happen on mobile devices.
// For more information, please see: https://www.crbug.com/1307759.
if (ui::GetDeviceFormFactor() == ui::DEVICE_FORM_FACTOR_TABLET) {
[self closeKeyboard];
}
[self.baseViewController presentViewController:self.viewController
animated:YES
completion:nil];
// Dismiss right away if the presentation failed to avoid having a zombie
// coordinator. This is the best proxy we have to know whether the view
// controller for the bottom sheet could really be presented as the completion
// block is only called when presentation really happens, and we can't get any
// error message or signal. Based on what we could test, we know that
// presentingViewController is only set if the view controller can be
// presented, where it is left to nil if the presentation is rejected for
// various reasons (having another view controller already presented is one of
// them). One should not think they can know all the reasons why the
// presentation fails.
//
// Keep this line at the end of -start because the
// delegate will likely -stop the coordinator when closing suggestions, so the
// coordinator should be in the most up to date state where it can be safely
// stopped.
if (!self.viewController.presentingViewController) {
[self.delegate closePasswordSuggestion];
}
}
- (void)stop {
[self.viewController.presentingViewController
dismissViewControllerAnimated:YES
completion:nil];
self.viewController = nil;
}
#pragma mark - ConfirmationAlertActionHandler
- (void)confirmationAlertSecondaryAction {
[self handleDecision:NO];
[self incrementDismissCount];
[self disableBottomSheet];
[self.delegate closePasswordSuggestion];
}
- (void)confirmationAlertPrimaryAction {
[self handleDecision:YES];
[self.delegate closePasswordSuggestion];
}
#pragma mark - UIAdaptivePresentationControllerDelegate
- (void)presentationControllerDidDismiss:
(UIPresentationController*)presentationController {
[self handleDecision:NO];
[self incrementDismissCount];
[self disableBottomSheet];
[self.delegate closePasswordSuggestion];
}
#pragma mark - Notification callback
- (void)applicationDidEnterBackground:(NSNotification*)notification {
[self confirmationAlertSecondaryAction];
}
#pragma mark - Private methods
// Returns the user email.
- (NSString*)userEmail {
ChromeBrowserState* browserState = self.browser->GetBrowserState();
AuthenticationService* authService =
AuthenticationServiceFactory::GetForBrowserState(browserState);
id<SystemIdentity> authenticatedIdentity =
authService->GetPrimaryIdentity(signin::ConsentLevel::kSignin);
return authenticatedIdentity.userEmail;
}
- (void)handleDecision:(BOOL)accept {
if (accept) {
[self resetPasswordGenerationBottomSheetDismissCount];
}
if (self.decisionHandler) {
self.decisionHandler(accept);
}
}
// Closes the keyboard.
- (void)closeKeyboard {
NSString* activeWebStateIdentifier = self.browser->GetWebStateList()
->GetActiveWebState()
->GetStableIdentifier();
[self onCloseKeyboardWithIdentifier:activeWebStateIdentifier];
}
- (web::WebState*)activeWebState {
if (!self.browser) {
return nullptr;
}
web::WebState* activeWebState =
self.browser->GetWebStateList()->GetActiveWebState();
if (!activeWebState) {
return nullptr;
}
return activeWebState;
}
// Helper method which closes the keyboard.
- (void)onCloseKeyboardWithIdentifier:(NSString*)identifier {
web::WebState* webState = [self activeWebState];
if (!webState) {
return;
}
// Note that it may have changed between the moment the
// block was created and its invocation. So check whether
// the WebState identifier is the same.
NSString* webStateIdentifier = webState->GetStableIdentifier();
if (![webStateIdentifier isEqualToString:identifier])
return;
password_manager::PasswordManagerJavaScriptFeature* feature =
password_manager::PasswordManagerJavaScriptFeature::GetInstance();
web::WebFrame* mainFrame =
feature->GetWebFramesManager(webState)->GetMainWebFrame();
if (!mainFrame) {
return;
}
FormInputAccessoryViewHandler* handler =
[[FormInputAccessoryViewHandler alloc] init];
handler.webState = webState;
NSString* mainFrameID = base::SysUTF8ToNSString(mainFrame->GetFrameId());
[handler setLastFocusFormActivityWebFrameID:mainFrameID];
[handler closeKeyboardWithoutButtonPress];
}
// Increments the password generation bottom sheet dismiss count
// preference.
- (void)incrementDismissCount {
if (!base::FeatureList::IsEnabled(
password_manager::features::
kIOSProactivePasswordGenerationBottomSheet)) {
return;
}
ChromeBrowserState* browserState = self.browser->GetBrowserState();
if (!browserState) {
return;
}
PrefService* prefService = browserState->GetPrefs();
if (prefService) {
const int newDismissCount =
prefService->GetInteger(
prefs::kIosPasswordGenerationBottomSheetDismissCount) +
1;
prefService->SetInteger(
prefs::kIosPasswordGenerationBottomSheetDismissCount, newDismissCount);
if (newDismissCount == AutofillBottomSheetTabHelper::
kPasswordGenerationBottomSheetMaxDismissCount) {
base::UmaHistogramEnumeration(
"PasswordGeneration.BottomSheetStateTransitionPasswordGeneration.iOS."
"ProactiveBottomSheetStateTransition",
PasswordGenerationBottomSheetStateTransitionType::kSilenced);
}
}
}
// Disables the proactive password generation bottom sheet for the current tab
// session by detaching the listeners.
- (void)disableBottomSheet {
if (!base::FeatureList::IsEnabled(
password_manager::features::
kIOSProactivePasswordGenerationBottomSheet)) {
return;
}
web::WebState* webState = [self activeWebState];
if (!webState) {
return;
}
AutofillBottomSheetTabHelper* tabHelper =
AutofillBottomSheetTabHelper::FromWebState(webState);
if (!tabHelper) {
return;
}
tabHelper->DetachPasswordGenerationListenersForAllFrames();
}
// Resets the proactive password generation bottom sheet dismiss count to 0 when
// a generated password suggestion is accepted.
- (void)resetPasswordGenerationBottomSheetDismissCount {
if (!base::FeatureList::IsEnabled(
password_manager::features::
kIOSProactivePasswordGenerationBottomSheet)) {
return;
}
web::WebState* webState = [self activeWebState];
if (!webState) {
return;
}
ChromeBrowserState* browserState =
ChromeBrowserState::FromBrowserState(webState->GetBrowserState());
if (!browserState) {
return;
}
PrefService* prefService = browserState->GetPrefs();
if (prefService) {
const int currentDismissCount = prefService->GetInteger(
prefs::kIosPasswordGenerationBottomSheetDismissCount);
if (currentDismissCount ==
AutofillBottomSheetTabHelper::
kPasswordGenerationBottomSheetMaxDismissCount) {
base::UmaHistogramEnumeration(
"PasswordGeneration.iOS.ProactiveBottomSheetStateTransition",
PasswordGenerationBottomSheetStateTransitionType::kUnsilenced);
}
prefService->SetInteger(
prefs::kIosPasswordGenerationBottomSheetDismissCount, 0);
}
}
// Returns the minimum detent height such that the entire content can be
// shown, constrained between the medium and large detent heights. If
// the content does not fit entirely in the largest height, the content
// is scrollable.
- (UISheetPresentationControllerDetent*)preferredHeightDetent {
auto resolver = ^CGFloat(
id<UISheetPresentationControllerDetentResolutionContext> context) {
CGFloat height = [self.viewController preferredHeightForContent];
CGFloat largeDetentHeight = [UISheetPresentationControllerDetent.largeDetent
resolvedValueInContext:context];
height = MIN(height, largeDetentHeight);
CGFloat mediumDetentHeight =
[UISheetPresentationControllerDetent.mediumDetent
resolvedValueInContext:context];
return MAX(height, mediumDetentHeight);
};
return [UISheetPresentationControllerDetent
customDetentWithIdentifier:@"preferred_height"
resolver:resolver];
}
- (NSArray<UISheetPresentationControllerDetent*>*)detents {
// Custom sized detents for modals are available from iOS 16.
if (@available(iOS 18, *)) {
if (UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad) {
// As of iOS 18, the modal on iPad no longer appears near the bottom
// edge and should not be expandable (i.e. large detent should not
// be an option).
return @[ [self preferredHeightDetent] ];
}
// Having the large detent as an option makes the modal expandable to
// the maximum size.
return @[
[self preferredHeightDetent],
UISheetPresentationControllerDetent.largeDetent
];
} else if (@available(iOS 16, *)) {
// Having the large detent as an option makes the modal expandable to
// the maximum size.
return @[
[self preferredHeightDetent],
UISheetPresentationControllerDetent.largeDetent
];
}
return @[
UISheetPresentationControllerDetent.mediumDetent,
UISheetPresentationControllerDetent.largeDetent
];
}
- (BOOL)isEdgeAttachedInCompactHeight {
if (@available(iOS 18, *)) {
// This specifically affects the iPad mini format, so the bottom
// sheet does not attach to the bottom edge like it does on iPhone.
if (UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad) {
return NO;
}
}
return YES;
}
@end