// Copyright 2014 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/autofill/model/form_suggestion_controller.h"
#import <memory>
#import "base/apple/foundation_util.h"
#import "base/memory/raw_ptr.h"
#import "base/metrics/histogram_functions.h"
#import "base/strings/sys_string_conversions.h"
#import "base/strings/utf_string_conversions.h"
#import "components/autofill/core/browser/ui/autofill_suggestion_delegate.h"
#import "components/autofill/ios/browser/form_suggestion.h"
#import "components/autofill/ios/browser/form_suggestion_provider.h"
#import "components/autofill/ios/form_util/form_activity_params.h"
#import "components/plus_addresses/features.h"
#import "components/prefs/pref_service.h"
#import "ios/chrome/browser/autofill/model/form_input_navigator.h"
#import "ios/chrome/browser/autofill/model/form_input_suggestions_provider.h"
#import "ios/chrome/browser/autofill/model/form_suggestion_controller.mm"
#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/public/features/features.h"
#import "ios/chrome/browser/shared/ui/symbols/symbols.h"
#import "ios/chrome/common/ui/colors/semantic_color_names.h"
#import "ios/web/common/url_scheme_util.h"
#import "ios/web/public/js_messaging/web_frames_manager.h"
#import "ios/web/public/ui/crw_web_view_proxy.h"
#import "ios/web/public/web_state.h"
using autofill::FieldRendererId;
using autofill::FormRendererId;
// Block types for `RunSearchPipeline`.
using PipelineBlock = void (^)(void (^completion)(BOOL));
using PipelineCompletionBlock = void (^)(NSUInteger index);
namespace {
// Point size of the SF Symbol used for default icons.
const CGFloat kSymbolPointSize = 17.0f;
// Struct that describes suggestion state.
struct AutofillSuggestionState {
AutofillSuggestionState(const autofill::FormActivityParams& params);
// The name of the form for autofill.
std::string form_name;
// The numeric identifier of the form for autofill.
FormRendererId form_renderer_id;
// The identifier of the field for autofill.
std::string field_identifier;
// The numeric identifier of the field for autofill.
FieldRendererId field_renderer_id;
// The identifier of the frame for autofill.
std::string frame_identifier;
// The user-typed value in the field.
std::string typed_value;
// The suggestions for the form field. An array of `FormSuggestion`.
NSArray* suggestions;
};
AutofillSuggestionState::AutofillSuggestionState(
const autofill::FormActivityParams& params)
: form_name(params.form_name),
form_renderer_id(params.form_renderer_id),
field_identifier(params.field_identifier),
field_renderer_id(params.field_renderer_id),
frame_identifier(params.frame_id),
typed_value(params.value) {}
// Executes each PipelineBlock in `blocks` in order until one invokes its
// completion with YES, in which case `on_complete` will be invoked with the
// `index` of the succeeding block, or until they all invoke their completions
// with NO, in which case `on_complete` will be invoked with NSNotFound.
void RunSearchPipeline(NSArray<PipelineBlock>* blocks,
PipelineCompletionBlock on_complete,
NSUInteger from_index = 0) {
if (from_index == [blocks count]) {
on_complete(NSNotFound);
return;
}
PipelineBlock block = blocks[from_index];
block(^(BOOL success) {
if (success) {
on_complete(from_index);
} else {
RunSearchPipeline(blocks, on_complete, from_index + 1);
}
});
}
// Returns the default icon for the suggestion type.
UIImage* defaultIconForType(autofill::SuggestionType type) {
switch (type) {
case autofill::SuggestionType::kGeneratePasswordEntry:
return MakeSymbolMulticolor(
CustomSymbolWithPointSize(kPasswordManagerSymbol, kSymbolPointSize));
case autofill::SuggestionType::kCreateNewPlusAddress:
case autofill::SuggestionType::kFillExistingPlusAddress: {
BOOL isPlusAddressFeaturesEnabled = base::FeatureList::IsEnabled(
plus_addresses::features::kPlusAddressesEnabled);
#if BUILDFLAG(IOS_USE_BRANDED_SYMBOLS)
return isPlusAddressFeaturesEnabled
? CustomSymbolWithPointSize(kGooglePlusAddressSymbol,
kSymbolPointSize)
: nil;
#else
return isPlusAddressFeaturesEnabled
? DefaultSymbolWithPointSize(kMailFillSymbol, kSymbolPointSize)
: nil;
#endif
}
case autofill::SuggestionType::kAutocompleteEntry:
default:
return nil;
}
}
} // namespace
@interface FormSuggestionController () {
// Callback to update the accessory view.
FormSuggestionsReadyCompletion _accessoryViewUpdateBlock;
// Autofill suggestion state.
std::unique_ptr<AutofillSuggestionState> _suggestionState;
// Providers for suggestions, sorted according to the order in which
// they should be asked for suggestions, with highest priority in front.
NSArray* _suggestionProviders;
// Access to WebView from the CRWWebController.
id<CRWWebViewProxy> _webViewProxy;
}
// Unique id of the last request.
@property(nonatomic, assign) NSUInteger requestIdentifier;
// Updates keyboard for `suggestionState`.
- (void)updateKeyboard:(AutofillSuggestionState*)suggestionState;
// Updates keyboard with `suggestions`.
- (void)updateKeyboardWithSuggestions:(NSArray*)suggestions;
// Clears state in between page loads.
- (void)resetSuggestionState;
@end
@implementation FormSuggestionController {
// The WebState this instance is observing. Will be null after
// -webStateDestroyed: has been called.
raw_ptr<web::WebState> _webState;
// Bridge to observe the web state from Objective-C.
std::unique_ptr<web::WebStateObserverBridge> _webStateObserverBridge;
// The provider for the current set of suggestions.
__weak id<FormSuggestionProvider> _provider;
}
@synthesize formInputNavigator = _formInputNavigator;
- (instancetype)initWithWebState:(web::WebState*)webState
providers:(NSArray*)providers {
self = [super init];
if (self) {
DCHECK(webState);
_webState = webState;
_webStateObserverBridge =
std::make_unique<web::WebStateObserverBridge>(self);
_webState->AddObserver(_webStateObserverBridge.get());
_webViewProxy = webState->GetWebViewProxy();
_suggestionProviders = [providers copy];
}
return self;
}
- (void)dealloc {
if (_webState) {
_webState->RemoveObserver(_webStateObserverBridge.get());
_webStateObserverBridge.reset();
_webState = nullptr;
}
}
- (void)detachFromWebState {
if (_webState) {
_webState->RemoveObserver(_webStateObserverBridge.get());
_webStateObserverBridge.reset();
_webState = nullptr;
}
}
#pragma mark - CRWWebStateObserver
- (void)webStateDestroyed:(web::WebState*)webState {
DCHECK_EQ(_webState, webState);
[self detachFromWebState];
}
- (void)webState:(web::WebState*)webState didLoadPageWithSuccess:(BOOL)success {
DCHECK_EQ(_webState, webState);
[self processPage:webState];
}
- (void)processPage:(web::WebState*)webState {
[self resetSuggestionState];
}
- (void)setWebViewProxy:(id<CRWWebViewProxy>)webViewProxy {
_webViewProxy = webViewProxy;
}
- (void)retrieveSuggestionsForForm:(const autofill::FormActivityParams&)params
webState:(web::WebState*)webState {
self.requestIdentifier += 1;
NSUInteger requestIdentifier = self.requestIdentifier;
__weak FormSuggestionController* weakSelf = self;
FormSuggestionProviderQuery* formQuery = [[FormSuggestionProviderQuery alloc]
initWithFormName:base::SysUTF8ToNSString(params.form_name)
formRendererID:params.form_renderer_id
fieldIdentifier:base::SysUTF8ToNSString(params.field_identifier)
fieldRendererID:params.field_renderer_id
fieldType:base::SysUTF8ToNSString(params.field_type)
type:base::SysUTF8ToNSString(params.type)
typedValue:base::SysUTF8ToNSString(
_suggestionState.get()->typed_value)
frameID:base::SysUTF8ToNSString(params.frame_id)];
BOOL hasUserGesture = params.has_user_gesture;
// Build a block for each provider that will invoke its completion with YES
// if the provider can provide suggestions for the specified form/field/type
// and NO otherwise.
NSMutableArray* findProviderBlocks = [[NSMutableArray alloc] init];
for (NSUInteger i = 0; i < [_suggestionProviders count]; i++) {
PipelineBlock block = ^(void (^completion)(BOOL success)) {
// Access all the providers through `self` to guarantee that both
// `self` and all the providers exist when the block is executed.
// `_suggestionProviders` is immutable, so the subscripting is
// always valid.
FormSuggestionController* strongSelf = weakSelf;
if (!strongSelf)
return;
id<FormSuggestionProvider> provider = strongSelf->_suggestionProviders[i];
[provider checkIfSuggestionsAvailableForForm:formQuery
hasUserGesture:hasUserGesture
webState:webState
completionHandler:completion];
};
[findProviderBlocks addObject:block];
}
// Once the suggestions are retrieved, update the suggestions UI.
SuggestionsReadyCompletion readyCompletion =
^(NSArray<FormSuggestion*>* suggestions,
id<FormSuggestionProvider> provider) {
[weakSelf onSuggestionsReady:suggestions provider:provider];
};
// Once a provider is found, use it to retrieve suggestions.
PipelineCompletionBlock completion = ^(NSUInteger providerIndex) {
// Ignore outdated results.
if (weakSelf.requestIdentifier != requestIdentifier) {
return;
}
if (providerIndex == NSNotFound) {
[weakSelf onNoSuggestionsAvailable];
return;
}
FormSuggestionController* strongSelf = weakSelf;
if (!strongSelf)
return;
id<FormSuggestionProvider> provider =
strongSelf->_suggestionProviders[providerIndex];
[provider retrieveSuggestionsForForm:formQuery
webState:webState
completionHandler:readyCompletion];
};
// Run all the blocks in `findProviderBlocks` until one invokes its
// completion with YES. The first one to do so will be passed to
// `completion`.
RunSearchPipeline(findProviderBlocks, completion);
}
- (void)onNoSuggestionsAvailable {
// Check the update block hasn't been reset while waiting for suggestions.
if (!_accessoryViewUpdateBlock) {
return;
}
_accessoryViewUpdateBlock(@[], self);
}
- (void)onSuggestionsReady:(NSArray<FormSuggestion*>*)suggestions
provider:(id<FormSuggestionProvider>)provider {
// TODO(ios): crbug.com/249916. If we can also pass in the form/field for
// which `suggestions` are, we should check here if `suggestions` are for
// the current active element. If not, reset `_suggestionState`.
if (!_suggestionState) {
// The suggestion state was reset in between the call to Autofill API (e.g.
// OnAskForValuesToFill) and this method being called back. Results are
// therefore no longer relevant.
return;
}
_provider = provider;
_suggestionState->suggestions = [self copyAndAdjustSuggestions:suggestions];
[self updateKeyboard:_suggestionState.get()];
}
- (void)resetSuggestionState {
_provider = nil;
_suggestionState.reset();
}
- (void)clearSuggestions {
// Note that other parts of the suggestionsState are not reset.
if (!_suggestionState.get())
return;
_suggestionState->suggestions = [[NSArray alloc] init];
[self updateKeyboard:_suggestionState.get()];
}
- (void)updateKeyboard:(AutofillSuggestionState*)suggestionState {
if (!suggestionState) {
if (_accessoryViewUpdateBlock)
_accessoryViewUpdateBlock(nil, self);
} else {
[self updateKeyboardWithSuggestions:suggestionState->suggestions];
}
}
- (void)updateKeyboardWithSuggestions:(NSArray<FormSuggestion*>*)suggestions {
if (_accessoryViewUpdateBlock) {
_accessoryViewUpdateBlock(suggestions, self);
}
}
#pragma mark - FormSuggestionClient
- (void)didSelectSuggestion:(FormSuggestion*)suggestion
atIndex:(NSInteger)index {
const AutofillSuggestionState* suggestionState = _suggestionState.get();
if (suggestionState) {
[self didSelectSuggestion:suggestion
atIndex:index
state:(*suggestionState)];
}
}
- (void)didSelectSuggestion:(FormSuggestion*)suggestion
atIndex:(NSInteger)index
params:(const autofill::FormActivityParams&)params {
AutofillSuggestionState suggestionState(params);
[self didSelectSuggestion:suggestion atIndex:index state:suggestionState];
}
#pragma mark - FormInputSuggestionsProvider
- (void)retrieveSuggestionsForForm:(const autofill::FormActivityParams&)params
webState:(web::WebState*)webState
accessoryViewUpdateBlock:
(FormSuggestionsReadyCompletion)accessoryViewUpdateBlock {
[self processPage:webState];
_suggestionState.reset(new AutofillSuggestionState(params));
_accessoryViewUpdateBlock = [accessoryViewUpdateBlock copy];
[self retrieveSuggestionsForForm:params webState:webState];
}
- (void)inputAccessoryViewControllerDidReset {
_accessoryViewUpdateBlock = nil;
[self resetSuggestionState];
}
- (SuggestionProviderType)type {
return _provider ? _provider.type : SuggestionProviderTypeUnknown;
}
- (autofill::FillingProduct)mainFillingProduct {
return _provider ? _provider.mainFillingProduct
: autofill::FillingProduct::kNone;
}
#pragma mark - Private
// Copies the incoming suggestions, making adjustments if necessary.
- (NSArray<FormSuggestion*>*)copyAndAdjustSuggestions:
(NSArray<FormSuggestion*>*)suggestions {
BOOL isPlusAddressFeaturesEnabled = base::FeatureList::IsEnabled(
plus_addresses::features::kPlusAddressesEnabled);
if (!IsKeyboardAccessoryUpgradeEnabled() && !isPlusAddressFeaturesEnabled) {
return [suggestions copy];
}
NSMutableArray<FormSuggestion*>* suggestionsCopy = [NSMutableArray array];
for (FormSuggestion* suggestion : suggestions) {
BOOL isPlusAddressSuggestion =
(suggestion.type == autofill::SuggestionType::kCreateNewPlusAddress) ||
(suggestion.type == autofill::SuggestionType::kFillExistingPlusAddress);
UIImage* defaultIcon = defaultIconForType(suggestion.type);
// If there are no icons, but we have a default icon for this suggestion,
// copy the suggestion and add the default icon. If
// `IsKeyboardAccessoryUpgradeEnabled()`, update the icon for this
// suggestion. Otherwise, only update the icons for the plus address
// suggestions.
BOOL shouldUpdateIcon =
(IsKeyboardAccessoryUpgradeEnabled() || isPlusAddressSuggestion) &&
!suggestion.icon && defaultIcon;
if (shouldUpdateIcon) {
// If we ever get suggestions with metadata here, we'll need to use a
// different [FormSuggestion suggestionWithValue:...] to perform the copy.
CHECK(!suggestion.metadata.is_single_username_form);
FormSuggestion* suggestionCopy = [FormSuggestion
suggestionWithValue:suggestion.value
minorValue:suggestion.minorValue
displayDescription:suggestion.displayDescription
icon:defaultIcon
type:suggestion.type
backendIdentifier:suggestion.backendIdentifier
requiresReauth:suggestion.requiresReauth
acceptanceA11yAnnouncement:suggestion.acceptanceA11yAnnouncement];
// TODO(crbug.com/353663764): Include `featureForIPH` in the
// `FormSuggestion` constructor.
suggestionCopy.featureForIPH = suggestion.featureForIPH;
[suggestionsCopy addObject:suggestionCopy];
} else {
[suggestionsCopy addObject:suggestion];
}
}
return suggestionsCopy;
}
// Performs the selection of the suggestion at the provided `index` based on the
// provided `suggestionState`.
- (void)didSelectSuggestion:(FormSuggestion*)suggestion
atIndex:(NSInteger)index
state:(const AutofillSuggestionState&)suggestionState {
// If a password related suggestion was selected, reset the password bottom
// sheet dismiss count to 0.
if (_provider.type == SuggestionProviderTypePassword) {
[self resetPasswordBottomSheetDismissCount];
}
// Send the suggestion to the provider. Upon completion advance the cursor
// for single-field Autofill, or close the keyboard for full-form Autofill.
__weak FormSuggestionController* weakSelf = self;
[_provider
didSelectSuggestion:suggestion
atIndex:index
form:base::SysUTF8ToNSString(suggestionState.form_name)
formRendererID:suggestionState.form_renderer_id
fieldIdentifier:base::SysUTF8ToNSString(
suggestionState.field_identifier)
fieldRendererID:suggestionState.field_renderer_id
frameID:base::SysUTF8ToNSString(
suggestionState.frame_identifier)
completionHandler:^{
[[weakSelf formInputNavigator] closeKeyboardWithoutButtonPress];
}];
}
// Resets the password bottom sheet dismiss count to 0.
- (void)resetPasswordBottomSheetDismissCount {
ChromeBrowserState* browserState =
_webState
? ChromeBrowserState::FromBrowserState(_webState->GetBrowserState())
: nullptr;
if (browserState) {
int dismissCount = browserState->GetPrefs()->GetInteger(
prefs::kIosPasswordBottomSheetDismissCount);
browserState->GetPrefs()->SetInteger(
prefs::kIosPasswordBottomSheetDismissCount, 0);
if (dismissCount > 0) {
// Log how many times the bottom sheet had been dismissed before being
// re-enabled.
static constexpr int kHistogramMin = 1;
static constexpr int kHistogramMax = 4;
static constexpr size_t kHistogramBuckets = 3;
base::UmaHistogramCustomCounts(
"IOS.ResetDismissCount.Password.BottomSheet", dismissCount,
kHistogramMin, kHistogramMax, kHistogramBuckets);
}
}
}
@end