blob: dd0d09e46cc8193392b8e553b1e21099c0a787de [file] [log] [blame]
// 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/feature_list.h"
#import "base/memory/raw_ptr.h"
#import "base/metrics/histogram_functions.h"
#import "base/not_fatal_until.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/core/common/features.h"
#import "components/prefs/pref_service.h"
#import "ios/chrome/browser/autofill/model/features.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/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(FormSuggestion* suggestion) {
switch (suggestion.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);
return isPlusAddressFeaturesEnabled
? SymbolWithPalette(
DefaultSymbolWithPointSize(kShieldedEnvelope, kSymbolPointSize),
@[
[UIColor colorNamed:kTextPrimaryColor],
])
: nil;
}
case autofill::SuggestionType::kAddressEntry: {
switch (suggestion.suggestionIconType) {
case SuggestionIconType::kAccountHome:
return SymbolWithPalette(
DefaultSymbolWithPointSize(kHomeSymbol, kSymbolPointSize), @[
[UIColor colorNamed:kTextPrimaryColor],
]);
case SuggestionIconType::kAccountWork:
return SymbolWithPalette(
DefaultSymbolWithPointSize(kWorkSymbol, kSymbolPointSize), @[
[UIColor colorNamed:kTextPrimaryColor],
]);
default:
return nil;
}
}
case autofill::SuggestionType::kAutocompleteEntry:
default:
return nil;
}
}
// Make a copy of suggestions with `params` and `provider` set in the copies.
NSArray<FormSuggestion*>* SetParamsAndProviderInSuggestions(
NSArray<FormSuggestion*>* suggestions,
const autofill::FormActivityParams& params,
id<FormSuggestionProvider> provider) {
NSMutableArray<FormSuggestion*>* suggestionsCopy =
[NSMutableArray<FormSuggestion*> array];
for (FormSuggestion* suggestion in suggestions) {
[suggestionsCopy addObject:[FormSuggestion copy:suggestion
andSetParams:params
provider:provider]];
}
return suggestionsCopy;
}
// Returns true if the form suggestion controller is stateless.
bool IsStateless() {
return base::FeatureList::IsEnabled(kStatelessFormSuggestionController);
}
// Returns true if deduping requests is allowed.
bool IsRequestDedupingAllowed() {
return !IsStateless() ||
base::FeatureList::IsEnabled(
kStatelessFormSuggestionControllerWithRequestDeduping);
}
} // 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
accessoryViewUpdateBlock:
(FormSuggestionsReadyCompletion)accessoryViewUpdateBlock {
[self processPage:webState];
_suggestionState = std::make_unique<AutofillSuggestionState>(params);
_accessoryViewUpdateBlock = [accessoryViewUpdateBlock copy];
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(params.value)
frameID:base::SysUTF8ToNSString(params.frame_id)
onlyPassword:NO];
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];
}
// Push the retrieved suggestions to the caller.
SuggestionsReadyCompletion readyCompletion = CallbackToBlock(base::BindOnce(
[](__weak __typeof(self) weakSelf,
const autofill::FormActivityParams& params,
FormSuggestionsReadyCompletion accessoryViewUpdateBlock,
NSArray<FormSuggestion*>* suggestions,
id<FormSuggestionProvider> provider) {
[weakSelf onSuggestionsReady:suggestions
provider:provider
params:params
completion:accessoryViewUpdateBlock];
},
weakSelf, params, accessoryViewUpdateBlock));
// Once a provider is found, use it to retrieve suggestions.
PipelineCompletionBlock completion = ^(NSUInteger providerIndex) {
// Ignore outdated results if allowed.
if (weakSelf.requestIdentifier != requestIdentifier &&
IsRequestDedupingAllowed()) {
return;
}
if (providerIndex == NSNotFound) {
if (IsStateless() && accessoryViewUpdateBlock) {
accessoryViewUpdateBlock(@[], weakSelf);
} else if (!IsStateless()) {
[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 {
// Do not call this no suggestions handler when stateless.
CHECK(!IsStateless());
// 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
params:(const autofill::FormActivityParams&)params
completion:(FormSuggestionsReadyCompletion)completion {
// 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 && !IsStateless()) {
// 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;
}
NSArray<FormSuggestion*>* suggestionsCopy =
[self copyAndAdjustSuggestions:suggestions];
// Cache the provider and state for the stateful controller. Those should not
// be used when the provider is stateless.
_provider = provider;
if (_suggestionState) {
// This case is only reached when using the stateless suggestion controller
// where the `_suggestionState` isn't required, so updating the suggestions
// can be skipped when no `_suggestionState`.
_suggestionState->suggestions = [self copyAndAdjustSuggestions:suggestions];
}
if (IsStateless()) {
// Use the stateless way for passing suggestions when the feature is
// enabled.
if (completion) {
suggestionsCopy =
SetParamsAndProviderInSuggestions(suggestionsCopy, params, provider);
completion(suggestionsCopy, self);
}
} else {
// Call the accessory view update block.
[self updateKeyboard:_suggestionState.get()];
}
}
- (void)resetSuggestionState {
_provider = nil;
_suggestionState.reset();
}
- (void)updateKeyboard:(AutofillSuggestionState*)suggestionState {
CHECK(!IsStateless());
if (!suggestionState) {
if (_accessoryViewUpdateBlock) {
_accessoryViewUpdateBlock(nil, self);
}
} else {
[self updateKeyboardWithSuggestions:suggestionState->suggestions];
}
}
- (void)updateKeyboardWithSuggestions:(NSArray<FormSuggestion*>*)suggestions {
CHECK(!IsStateless());
if (_accessoryViewUpdateBlock) {
_accessoryViewUpdateBlock(suggestions, self);
}
}
#pragma mark - FormSuggestionClient
- (void)didSelectSuggestion:(FormSuggestion*)suggestion
atIndex:(NSInteger)index {
if (IsStateless()) {
// Check that there are always params attached to the suggestion when no
// params are provided by the -didSelectSuggestion caller itself.
CHECK(suggestion.params);
if (!suggestion.params) {
// Just skip if the check isn't triggered. This is to handle the absence
// of params when the CHECK isn't fatal.
return;
}
[self didSelectSuggestion:suggestion
atIndex:index
state:AutofillSuggestionState(*suggestion.params)];
} else 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)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 {
NSMutableArray<FormSuggestion*>* suggestionsCopy = [NSMutableArray array];
for (FormSuggestion* suggestion : suggestions) {
UIImage* defaultIcon = defaultIconForType(suggestion);
// If there are no icons, but we have a default icon for this suggestion,
// copy the suggestion and add the default icon, otherwise, update the icon
// for this suggestion.
BOOL shouldUpdateIcon = !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
payload:suggestion.payload
fieldByFieldFillingTypeUsed:suggestion.fieldByFieldFillingTypeUsed
requiresReauth:suggestion.requiresReauth
acceptanceA11yAnnouncement:suggestion.acceptanceA11yAnnouncement];
// TODO(crbug.com/452315148): 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 {
id<FormSuggestionProvider> provider = suggestion.provider ?: _provider;
// If a password related suggestion was selected, reset the credential bottom
// sheet dismiss count to 0.
if (provider.type == SuggestionProviderTypePassword) {
[self resetCredentialBottomSheetDismissCount];
}
// 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 credential bottom sheet dismiss count to 0.
- (void)resetCredentialBottomSheetDismissCount {
ProfileIOS* profile =
_webState ? ProfileIOS::FromBrowserState(_webState->GetBrowserState())
: nullptr;
if (profile) {
int dismissCount = profile->GetPrefs()->GetInteger(
prefs::kIosPasswordBottomSheetDismissCount);
profile->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