// Copyright 2017 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/omnibox/popup/omnibox_popup_mediator.h"
#import "base/feature_list.h"
#import "base/ios/ios_util.h"
#import "base/memory/raw_ptr.h"
#import "base/metrics/histogram_functions.h"
#import "base/metrics/histogram_macros.h"
#import "base/metrics/user_metrics.h"
#import "base/metrics/user_metrics_action.h"
#import "base/strings/sys_string_conversions.h"
#import "base/strings/utf_string_conversions.h"
#import "components/feature_engagement/public/tracker.h"
#import "components/image_fetcher/core/image_data_fetcher.h"
#import "components/omnibox/browser/actions/omnibox_action_concepts.h"
#import "components/omnibox/browser/autocomplete_controller.h"
#import "components/omnibox/browser/autocomplete_input.h"
#import "components/omnibox/browser/autocomplete_match.h"
#import "components/omnibox/browser/autocomplete_match_classification.h"
#import "components/omnibox/browser/autocomplete_result.h"
#import "components/omnibox/browser/remote_suggestions_service.h"
#import "components/omnibox/common/omnibox_features.h"
#import "components/password_manager/core/browser/manage_passwords_referrer.h"
#import "components/strings/grit/components_strings.h"
#import "components/variations/variations_associated_data.h"
#import "components/variations/variations_ids_provider.h"
#import "ios/chrome/browser/default_browser/model/default_browser_interest_signals.h"
#import "ios/chrome/browser/download/model/external_app_util.h"
#import "ios/chrome/browser/favicon/model/favicon_loader.h"
#import "ios/chrome/browser/net/model/crurl.h"
#import "ios/chrome/browser/ntp/model/new_tab_page_util.h"
#import "ios/chrome/browser/shared/model/application_context/application_context.h"
#import "ios/chrome/browser/shared/model/prefs/pref_backed_boolean.h"
#import "ios/chrome/browser/shared/model/prefs/pref_names.h"
#import "ios/chrome/browser/shared/public/commands/application_commands.h"
#import "ios/chrome/browser/shared/public/commands/open_new_tab_command.h"
#import "ios/chrome/browser/shared/public/features/features.h"
#import "ios/chrome/browser/shared/public/features/system_flags.h"
#import "ios/chrome/browser/shared/ui/util/pasteboard_util.h"
#import "ios/chrome/browser/shared/ui/util/uikit_ui_util.h"
#import "ios/chrome/browser/ui/menu/browser_action_factory.h"
#import "ios/chrome/browser/ui/omnibox/popup/autocomplete_controller_observer_bridge.h"
#import "ios/chrome/browser/ui/omnibox/popup/autocomplete_match_formatter.h"
#import "ios/chrome/browser/ui/omnibox/popup/autocomplete_suggestion_group_impl.h"
#import "ios/chrome/browser/ui/omnibox/popup/carousel/carousel_item.h"
#import "ios/chrome/browser/ui/omnibox/popup/carousel/carousel_item_menu_provider.h"
#import "ios/chrome/browser/ui/omnibox/popup/omnibox_pedal_annotator.h"
#import "ios/chrome/browser/ui/omnibox/popup/omnibox_popup_mediator+Testing.h"
#import "ios/chrome/browser/ui/omnibox/popup/omnibox_popup_presenter.h"
#import "ios/chrome/browser/ui/omnibox/popup/pedal_section_extractor.h"
#import "ios/chrome/browser/ui/omnibox/popup/pedal_suggestion_wrapper.h"
#import "ios/chrome/browser/ui/omnibox/popup/popup_debug_info_consumer.h"
#import "ios/chrome/browser/ui/omnibox/popup/popup_swift.h"
#import "ios/chrome/browser/ui/omnibox/popup/remote_suggestions_service_observer_bridge.h"
#import "ios/chrome/browser/ui/omnibox/popup/row/actions/suggest_action.h"
#import "ios/chrome/browser/ui/toolbar/public/toolbar_omnibox_consumer.h"
#import "ios/chrome/common/ui/favicon/favicon_attributes.h"
#import "net/base/apple/url_conversions.h"
#import "third_party/omnibox_proto/groups.pb.h"
#import "ui/base/l10n/l10n_util.h"
namespace {
const CGFloat kOmniboxIconSize = 16;
/// Maximum number of suggest tile types we want to record. Anything beyond this
/// will be reported in the overflow bucket.
const NSUInteger kMaxSuggestTileTypePosition = 15;
} // namespace
@interface OmniboxPopupMediator () <BooleanObserver,
PedalSectionExtractorDelegate>
// FET reference.
@property(nonatomic, assign) feature_engagement::Tracker* tracker;
/// Extracts pedals from AutocompleSuggestions.
@property(nonatomic, strong) PedalSectionExtractor* pedalSectionExtractor;
/// List of suggestions without the pedal group. Used to debouce pedals.
@property(nonatomic, strong)
NSArray<id<AutocompleteSuggestionGroup>>* nonPedalSuggestions;
/// Holds the currently displayed pedals group, if any.
@property(nonatomic, strong) id<AutocompleteSuggestionGroup> currentPedals;
/// Index of the group containing AutocompleteSuggestion, first group to be
/// highlighted on down arrow key.
@property(nonatomic, assign) NSInteger preselectedGroupIndex;
// Autocomplete controller backing this mediator.
// It is observed through OmniboxPopupViewIOS.
@property(nonatomic, assign) AutocompleteController* autocompleteController;
// Remote suggestions service backing `autocompleteController`. Observed in
// debug mode.
@property(nonatomic, assign) RemoteSuggestionsService* remoteSuggestionsService;
@end
@implementation OmniboxPopupMediator {
// Fetcher for Answers in Suggest images.
std::unique_ptr<image_fetcher::ImageDataFetcher> _imageFetcher;
std::unique_ptr<AutocompleteControllerObserverBridge>
_autocompleteObserverBridge;
std::unique_ptr<RemoteSuggestionsServiceObserverBridge>
_remoteSuggestionsServiceObserverBridge;
raw_ptr<OmniboxPopupMediatorDelegate> _delegate; // weak
/// Preferred omnibox position, logged in omnibox logs.
metrics::OmniboxEventProto::OmniboxPosition _preferredOmniboxPosition;
/// Pref tracking if bottom omnibox is enabled.
PrefBackedBoolean* _bottomOmniboxEnabled;
/// Holds cached images keyed by their URL. The cache is purged when the popup
/// is closed.
NSCache<NSString*, UIImage*>* _cachedImages;
}
@synthesize consumer = _consumer;
@synthesize hasResults = _hasResults;
@synthesize incognito = _incognito;
@synthesize open = _open;
@synthesize presenter = _presenter;
- (instancetype)
initWithFetcher:
(std::unique_ptr<image_fetcher::ImageDataFetcher>)imageFetcher
faviconLoader:(FaviconLoader*)faviconLoader
autocompleteController:(AutocompleteController*)autocompleteController
remoteSuggestionsService:(RemoteSuggestionsService*)remoteSuggestionsService
delegate:(OmniboxPopupMediatorDelegate*)delegate
tracker:(feature_engagement::Tracker*)tracker {
self = [super init];
if (self) {
DCHECK(delegate);
DCHECK(autocompleteController);
_delegate = delegate;
_imageFetcher = std::move(imageFetcher);
_faviconLoader = faviconLoader;
_open = NO;
_pedalSectionExtractor = [[PedalSectionExtractor alloc] init];
_pedalSectionExtractor.delegate = self;
_preselectedGroupIndex = 0;
_autocompleteController = autocompleteController;
_remoteSuggestionsService = remoteSuggestionsService;
_tracker = tracker;
_cachedImages = [[NSCache alloc] init];
// This is logged only when `IsBottomOmniboxAvailable`.
_preferredOmniboxPosition = metrics::OmniboxEventProto::UNKNOWN_POSITION;
_bottomOmniboxEnabled = [[PrefBackedBoolean alloc]
initWithPrefService:GetApplicationContext()->GetLocalState()
prefName:prefs::kBottomOmnibox];
[_bottomOmniboxEnabled setObserver:self];
// Initialize to the correct value.
[self booleanDidChange:_bottomOmniboxEnabled];
}
return self;
}
- (void)disconnect {
[_bottomOmniboxEnabled stop];
[_bottomOmniboxEnabled setObserver:nil];
_bottomOmniboxEnabled = nil;
if (_remoteSuggestionsServiceObserverBridge) {
self.remoteSuggestionsService->RemoveObserver(
_remoteSuggestionsServiceObserverBridge.get());
_remoteSuggestionsServiceObserverBridge.reset();
}
}
- (void)updateMatches:(const AutocompleteResult&)result {
self.nonPedalSuggestions = nil;
self.currentPedals = nil;
self.hasResults = !self.autocompleteResult.empty();
[self.consumer newResultsAvailable];
if (self.debugInfoConsumer) {
DCHECK(experimental_flags::IsOmniboxDebuggingEnabled());
[self.debugInfoConsumer
setVariationIDString:
base::SysUTF8ToNSString(
variations::VariationsIdsProvider::GetInstance()
->GetTriggerVariationsString())];
}
}
- (void)updateWithResults:(const AutocompleteResult&)result {
[self updateMatches:result];
self.open = !result.empty();
if (!self.open) {
[_cachedImages removeAllObjects];
}
metrics::OmniboxFocusType inputFocusType =
self.autocompleteController->input().focus_type();
BOOL isFocusing =
inputFocusType == metrics::OmniboxFocusType::INTERACTION_FOCUS;
[self.presenter updatePopupOnFocus:isFocusing];
}
- (void)setTextAlignment:(NSTextAlignment)alignment {
[self.consumer setTextAlignment:alignment];
}
- (void)setSemanticContentAttribute:
(UISemanticContentAttribute)semanticContentAttribute {
[self.consumer setSemanticContentAttribute:semanticContentAttribute];
}
- (void)setDebugInfoConsumer:
(id<PopupDebugInfoConsumer,
RemoteSuggestionsServiceObserver,
AutocompleteControllerObserver>)debugInfoConsumer {
DCHECK(experimental_flags::IsOmniboxDebuggingEnabled());
_autocompleteObserverBridge =
std::make_unique<AutocompleteControllerObserverBridge>(debugInfoConsumer);
self.autocompleteController->AddObserver(_autocompleteObserverBridge.get());
// Observe the remote suggestions service if it's available. It might not
// be available e.g. in incognito.
if (self.remoteSuggestionsService) {
_remoteSuggestionsServiceObserverBridge =
std::make_unique<RemoteSuggestionsServiceObserverBridge>(
debugInfoConsumer, self.remoteSuggestionsService);
self.remoteSuggestionsService->AddObserver(
_remoteSuggestionsServiceObserverBridge.get());
}
_debugInfoConsumer = debugInfoConsumer;
}
#pragma mark - AutocompleteResultDataSource
- (void)requestResultsWithVisibleSuggestionCount:
(NSUInteger)visibleSuggestionCount {
// If no suggestions are visible, consider all of them visible.
if (visibleSuggestionCount == 0) {
visibleSuggestionCount = self.autocompleteResult.size();
}
NSUInteger visibleSuggestions =
MIN(visibleSuggestionCount, self.autocompleteResult.size());
if (visibleSuggestions > 0) {
// Groups visible suggestions by search vs url. Skip the first suggestion
// because it's the omnibox content.
[self groupCurrentSuggestionsFrom:1 to:visibleSuggestions];
}
// Groups hidden suggestions by search vs url.
[self groupCurrentSuggestionsFrom:visibleSuggestions
to:self.autocompleteResult.size()];
NSArray<id<AutocompleteSuggestionGroup>>* groups = [self wrappedMatches];
[self.consumer updateMatches:groups
preselectedMatchGroupIndex:self.preselectedGroupIndex];
}
#pragma mark - AutocompleteResultConsumerDelegate
- (void)autocompleteResultConsumerDidChangeTraitCollection:
(id<AutocompleteResultConsumer>)sender {
[self.presenter updatePopupAfterTraitCollectionChange];
}
- (void)autocompleteResultConsumer:(id<AutocompleteResultConsumer>)sender
didSelectSuggestion:(id<AutocompleteSuggestion>)suggestion
inRow:(NSUInteger)row {
[self logPedalShownForCurrentResult];
// Log the suggest actions that were shown and not used.
if (suggestion.actionsInSuggest.count == 0) {
[self logActionsInSuggestShownForCurrentResult];
}
if ([suggestion isKindOfClass:[PedalSuggestionWrapper class]]) {
PedalSuggestionWrapper* pedalSuggestionWrapper =
(PedalSuggestionWrapper*)suggestion;
if (pedalSuggestionWrapper.innerPedal.action) {
base::UmaHistogramEnumeration(
"Omnibox.SuggestionUsed.Pedal",
(OmniboxPedalId)pedalSuggestionWrapper.innerPedal.type,
OmniboxPedalId::TOTAL_COUNT);
if ((OmniboxPedalId)pedalSuggestionWrapper.innerPedal.type ==
OmniboxPedalId::MANAGE_PASSWORDS) {
base::UmaHistogramEnumeration(
"PasswordManager.ManagePasswordsReferrer",
password_manager::ManagePasswordsReferrer::kOmniboxPedalSuggestion);
}
pedalSuggestionWrapper.innerPedal.action();
}
} else if ([suggestion isKindOfClass:[AutocompleteMatchFormatter class]]) {
AutocompleteMatchFormatter* autocompleteMatchFormatter =
(AutocompleteMatchFormatter*)suggestion;
const AutocompleteMatch& match =
autocompleteMatchFormatter.autocompleteMatch;
// A search using clipboard link or text is activity that should indicate a
// user that would be interested in setting the browser as the default.
if (match.type == AutocompleteMatchType::CLIPBOARD_URL) {
default_browser::NotifyOmniboxURLCopyPasteAndNavigate(
self.incognito, self.tracker, self.sceneState);
}
if (match.type == AutocompleteMatchType::CLIPBOARD_TEXT) {
default_browser::NotifyOmniboxTextCopyPasteAndNavigate(self.tracker);
}
if (!self.incognito &&
match.type == AutocompleteMatchType::TILE_NAVSUGGEST) {
[self logSelectedAutocompleteTile:match];
}
_delegate->OnMatchSelected(match, row, WindowOpenDisposition::CURRENT_TAB);
} else {
DUMP_WILL_BE_NOTREACHED()
<< "Suggestion type " << NSStringFromClass(suggestion.class)
<< " not handled for selection.";
}
}
- (void)autocompleteResultConsumer:(id<AutocompleteResultConsumer>)sender
didSelectSuggestionAction:(SuggestAction*)action
suggestion:(id<AutocompleteSuggestion>)suggestion
inRow:(NSUInteger)row {
OmniboxActionInSuggest::RecordShownAndUsedMetrics(action.type,
true /* used */);
switch (action.type) {
case omnibox::ActionInfo_ActionType_CALL: {
NSURL* URL = net::NSURLWithGURL(action.actionURI);
__weak __typeof__(self) weakSelf = self;
[[UIApplication sharedApplication] openURL:URL
options:@{}
completionHandler:^(BOOL success) {
if (success) {
[weakSelf callActionTapped];
}
}];
break;
}
case omnibox::ActionInfo_ActionType_DIRECTIONS: {
NSURL* URL = net::NSURLWithGURL(action.actionURI);
if (IsGoogleMapsAppInstalled() && !self.incognito) {
[[UIApplication sharedApplication] openURL:URL
options:@{}
completionHandler:nil];
} else {
[self openNewTabWithSuggestAction:action];
}
break;
}
case omnibox::ActionInfo_ActionType_REVIEWS: {
[self openNewTabWithSuggestAction:action];
break;
}
default:
break;
}
}
- (void)autocompleteResultConsumer:(id<AutocompleteResultConsumer>)sender
didTapTrailingButtonOnSuggestion:(id<AutocompleteSuggestion>)suggestion
inRow:(NSUInteger)row {
if ([suggestion isKindOfClass:[AutocompleteMatchFormatter class]]) {
AutocompleteMatchFormatter* autocompleteMatchFormatter =
(AutocompleteMatchFormatter*)suggestion;
const AutocompleteMatch& match =
autocompleteMatchFormatter.autocompleteMatch;
if (match.has_tab_match.value_or(false)) {
_delegate->OnMatchSelected(match, row,
WindowOpenDisposition::SWITCH_TO_TAB);
} else {
if (AutocompleteMatch::IsSearchType(match.type)) {
base::RecordAction(
base::UserMetricsAction("MobileOmniboxRefineSuggestion.Search"));
} else {
base::RecordAction(
base::UserMetricsAction("MobileOmniboxRefineSuggestion.Url"));
}
_delegate->OnMatchSelectedForAppending(match);
}
} else {
NOTREACHED_IN_MIGRATION()
<< "Suggestion type " << NSStringFromClass(suggestion.class)
<< " not handled for trailing button tap.";
}
}
- (void)autocompleteResultConsumer:(id<AutocompleteResultConsumer>)sender
didSelectSuggestionForDeletion:(id<AutocompleteSuggestion>)suggestion
inRow:(NSUInteger)row {
if ([suggestion isKindOfClass:[AutocompleteMatchFormatter class]]) {
AutocompleteMatchFormatter* autocompleteMatchFormatter =
(AutocompleteMatchFormatter*)suggestion;
const AutocompleteMatch& match =
autocompleteMatchFormatter.autocompleteMatch;
_delegate->OnMatchSelectedForDeletion(match);
} else {
DUMP_WILL_BE_NOTREACHED()
<< "Suggestion type " << NSStringFromClass(suggestion.class)
<< " not handled for deletion.";
}
}
- (void)autocompleteResultConsumerDidScroll:
(id<AutocompleteResultConsumer>)sender {
_delegate->OnScroll();
}
#pragma mark AutocompleteResultConsumerDelegate Private
/// Logs selected tile index and type.
- (void)logSelectedAutocompleteTile:(const AutocompleteMatch&)match {
DCHECK(match.type == AutocompleteMatchType::TILE_NAVSUGGEST);
for (size_t i = 0; i < match.suggest_tiles.size(); ++i) {
const AutocompleteMatch::SuggestTile& tile = match.suggest_tiles[i];
// AutocompleteMatch contains all tiles, find the tile corresponding to the
// match. See how tiles are unwrapped in `extractMatches`.
if (match.destination_url == tile.url) {
// Log selected tile index. Note: When deleting a tile, the index may
// shift, this is not taken into account.
base::UmaHistogramExactLinear("Omnibox.SuggestTiles.SelectedTileIndex", i,
kMaxSuggestTileTypePosition);
int tileType =
tile.is_search ? SuggestTileType::kSearch : SuggestTileType::kURL;
base::UmaHistogramExactLinear("Omnibox.SuggestTiles.SelectedTileType",
tileType, SuggestTileType::kCount);
return;
}
}
}
#pragma mark - Boolean Observer
- (void)booleanDidChange:(id<ObservableBoolean>)observableBoolean {
if (observableBoolean == _bottomOmniboxEnabled) {
_preferredOmniboxPosition =
_bottomOmniboxEnabled.value
? metrics::OmniboxEventProto::BOTTOM_POSITION
: metrics::OmniboxEventProto::TOP_POSITION;
if (self.autocompleteController) {
self.autocompleteController->SetSteadyStateOmniboxPosition(
_preferredOmniboxPosition);
}
}
}
#pragma mark - ImageFetcher
- (void)fetchImage:(GURL)imageURL completion:(void (^)(UIImage*))completion {
NSString* URL = base::SysUTF8ToNSString(imageURL.spec());
UIImage* cachedImage = [_cachedImages objectForKey:URL];
if (cachedImage) {
completion(cachedImage);
return;
}
__weak NSCache<NSString*, UIImage*>* weakCachedImages = _cachedImages;
auto callback =
base::BindOnce(^(const std::string& image_data,
const image_fetcher::RequestMetadata& metadata) {
NSData* data = [NSData dataWithBytes:image_data.data()
length:image_data.size()];
UIImage* image = [UIImage imageWithData:data
scale:[UIScreen mainScreen].scale];
if (image) {
[weakCachedImages setObject:image forKey:URL];
}
completion(image);
});
_imageFetcher->FetchImageData(imageURL, std::move(callback),
NO_TRAFFIC_ANNOTATION_YET);
}
#pragma mark - FaviconRetriever
- (void)fetchFavicon:(GURL)pageURL completion:(void (^)(UIImage*))completion {
if (!self.faviconLoader) {
return;
}
self.faviconLoader->FaviconForPageUrl(
pageURL, kOmniboxIconSize, kOmniboxIconSize,
/*fallback_to_google_server=*/false, ^(FaviconAttributes* attributes) {
if (attributes.faviconImage && !attributes.usesDefaultImage)
completion(attributes.faviconImage);
});
}
#pragma mark - PedalSectionExtractorDelegate
/// Removes the pedal group from suggestions. Pedal are removed from suggestions
/// with a debouce timer in `PedalSectionExtractor`. When the timer ends the
/// pedal group is removed.
- (void)invalidatePedals {
if (self.nonPedalSuggestions) {
self.currentPedals = nil;
[self.consumer updateMatches:self.nonPedalSuggestions
preselectedMatchGroupIndex:0];
}
}
#pragma mark - Private methods
- (void)logPedalShownForCurrentResult {
for (PedalSuggestionWrapper* pedalMatch in self.currentPedals.suggestions) {
base::UmaHistogramEnumeration("Omnibox.PedalShown",
(OmniboxPedalId)pedalMatch.innerPedal.type,
OmniboxPedalId::TOTAL_COUNT);
}
}
- (void)logActionsInSuggestShownForCurrentResult {
NSArray<id<AutocompleteSuggestion>>* allMatches =
[self extractMatches:self.autocompleteResult];
for (id<AutocompleteSuggestion> match in allMatches) {
if (match.actionsInSuggest.count == 0) {
continue;
}
for (SuggestAction* action in match.actionsInSuggest) {
OmniboxActionInSuggest::RecordShownAndUsedMetrics(action.type,
false /* used */);
}
}
}
/// Wraps `match` with AutocompleteMatchFormatter.
- (AutocompleteMatchFormatter*)wrapMatch:(const AutocompleteMatch&)match
fromResult:(const AutocompleteResult&)result {
AutocompleteMatchFormatter* formatter =
[AutocompleteMatchFormatter formatterWithMatch:match];
formatter.starred = _delegate->IsStarredMatch(match);
formatter.incognito = _incognito;
formatter.defaultSearchEngineIsGoogle = self.defaultSearchEngineIsGoogle;
formatter.pedalData = [self.pedalAnnotator pedalForMatch:match];
if (formatter.suggestionGroupId) {
omnibox::GroupId groupId =
static_cast<omnibox::GroupId>(formatter.suggestionGroupId.intValue);
omnibox::GroupSection sectionId =
result.GetSectionForSuggestionGroup(groupId);
formatter.suggestionSectionId =
[NSNumber numberWithInt:static_cast<int>(sectionId)];
}
NSMutableArray* actions = [[NSMutableArray alloc] init];
for (auto& action : match.actions) {
SuggestAction* suggestAction =
[SuggestAction actionWithOmniboxAction:action.get()];
if (!suggestAction) {
continue;
}
if (suggestAction.type != omnibox::ActionInfo_ActionType_CALL) {
[actions addObject:suggestAction];
continue;
}
BOOL hasDialApp = [[UIApplication sharedApplication]
canOpenURL:net::NSURLWithGURL(suggestAction.actionURI)];
if (hasDialApp) {
[actions addObject:suggestAction];
}
}
formatter.actionsInSuggest = actions;
return formatter;
}
/// Extract normal (non-tile) matches from `autocompleteResult`.
- (NSMutableArray<id<AutocompleteSuggestion>>*)extractMatches:
(const AutocompleteResult&)autocompleteResult {
NSMutableArray<id<AutocompleteSuggestion>>* wrappedMatches =
[[NSMutableArray alloc] init];
for (size_t i = 0; i < self.autocompleteResult.size(); i++) {
const AutocompleteMatch& match =
self.autocompleteResult.match_at((NSUInteger)i);
if (match.type == AutocompleteMatchType::TILE_NAVSUGGEST) {
DCHECK(match.type == AutocompleteMatchType::TILE_NAVSUGGEST);
for (const AutocompleteMatch::SuggestTile& tile : match.suggest_tiles) {
AutocompleteMatch tileMatch = AutocompleteMatch(match);
// TODO(crbug.com/1363546): replace with a new wrapper.
tileMatch.destination_url = tile.url;
tileMatch.fill_into_edit = base::UTF8ToUTF16(tile.url.spec());
tileMatch.description = tile.title;
tileMatch.description_class = ClassifyTermMatches(
{}, tileMatch.description.length(), 0, ACMatchClassification::NONE);
#if DCHECK_IS_ON()
tileMatch.Validate();
#endif // DCHECK_IS_ON()
AutocompleteMatchFormatter* formatter =
[self wrapMatch:tileMatch fromResult:autocompleteResult];
[wrappedMatches addObject:formatter];
}
} else {
[wrappedMatches addObject:[self wrapMatch:match
fromResult:autocompleteResult]];
}
}
return wrappedMatches;
}
/// Take a list of suggestions and break it into groups determined by sectionId
/// field. Use `headerMap` to extract group names.
- (NSArray<id<AutocompleteSuggestionGroup>>*)
groupSuggestions:(NSArray<id<AutocompleteSuggestion>>*)suggestions
usingACResultAsHeaderMap:(const AutocompleteResult&)headerMap {
__block NSMutableArray<id<AutocompleteSuggestion>>* currentGroup =
[[NSMutableArray alloc] init];
NSMutableArray<id<AutocompleteSuggestionGroup>>* groups =
[[NSMutableArray alloc] init];
if (suggestions.count == 0) {
return @[];
}
id<AutocompleteSuggestion> firstSuggestion = suggestions.firstObject;
__block NSNumber* currentSectionId = firstSuggestion.suggestionSectionId;
__block NSNumber* currentGroupId = firstSuggestion.suggestionGroupId;
[currentGroup addObject:firstSuggestion];
void (^startNewGroup)() = ^{
if (currentGroup.count == 0) {
return;
}
NSString* groupTitle =
currentGroupId
? base::SysUTF16ToNSString(headerMap.GetHeaderForSuggestionGroup(
static_cast<omnibox::GroupId>([currentGroupId intValue])))
: nil;
SuggestionGroupDisplayStyle displayStyle =
SuggestionGroupDisplayStyleDefault;
if (base::FeatureList::IsEnabled(
omnibox::kMostVisitedTilesHorizontalRenderGroup)) {
omnibox::GroupConfig_RenderType renderType =
headerMap.GetRenderTypeForSuggestionGroup(
static_cast<omnibox::GroupId>([currentGroupId intValue]));
displayStyle = (renderType == omnibox::GroupConfig_RenderType_HORIZONTAL)
? SuggestionGroupDisplayStyleCarousel
: SuggestionGroupDisplayStyleDefault;
} else if (currentSectionId &&
static_cast<omnibox::GroupSection>(currentSectionId.intValue) ==
omnibox::SECTION_MOBILE_MOST_VISITED) {
displayStyle = SuggestionGroupDisplayStyleCarousel;
}
[groups addObject:[AutocompleteSuggestionGroupImpl
groupWithTitle:groupTitle
suggestions:currentGroup
displayStyle:displayStyle]];
currentGroup = [[NSMutableArray alloc] init];
};
for (NSUInteger i = 1; i < suggestions.count; i++) {
id<AutocompleteSuggestion> suggestion = suggestions[i];
if ((!suggestion.suggestionSectionId && !currentSectionId) ||
[suggestion.suggestionSectionId isEqual:currentSectionId]) {
[currentGroup addObject:suggestion];
} else {
startNewGroup();
currentGroupId = suggestion.suggestionGroupId;
currentSectionId = suggestion.suggestionSectionId;
[currentGroup addObject:suggestion];
}
}
startNewGroup();
return groups;
}
/// Unpacks AutocompleteMatch into wrapped AutocompleteSuggestion and
/// AutocompleteSuggestionGroup. Sets `preselectedGroupIndex`.
- (NSArray<id<AutocompleteSuggestionGroup>>*)wrappedMatches {
NSMutableArray<id<AutocompleteSuggestionGroup>>* groups =
[[NSMutableArray alloc] init];
// Group the suggestions by the section Id.
NSMutableArray<id<AutocompleteSuggestion>>* allMatches =
[self extractMatches:self.autocompleteResult];
NSArray<id<AutocompleteSuggestionGroup>>* allGroups =
[self groupSuggestions:allMatches
usingACResultAsHeaderMap:self.autocompleteResult];
[groups addObjectsFromArray:allGroups];
// Before inserting pedals above all, back up non-pedal suggestions for
// debouncing.
self.nonPedalSuggestions = groups;
// Get pedals, if any. They go at the very top of the list.
self.currentPedals = [self.pedalSectionExtractor extractPedals:allMatches];
if (self.currentPedals) {
[groups insertObject:self.currentPedals atIndex:0];
}
// Preselect the verbatim match. It's the top match, unless we inserted pedals
// and pushed it one section down.
self.preselectedGroupIndex = self.currentPedals ? MIN(1, groups.count) : 0;
return groups;
}
- (const AutocompleteResult&)autocompleteResult {
DCHECK(self.autocompleteController);
return self.autocompleteController->result();
}
- (void)groupCurrentSuggestionsFrom:(NSUInteger)begin to:(NSUInteger)end {
DCHECK(begin <= self.autocompleteResult.size());
DCHECK(end <= self.autocompleteResult.size());
self.autocompleteController->GroupSuggestionsBySearchVsURL(begin, end);
}
- (void)callActionTapped {
_delegate->OnCallActionTap();
}
#pragma mark - CarouselItemMenuProvider
/// Context Menu for carousel `item` in `view`.
- (UIContextMenuConfiguration*)
contextMenuConfigurationForCarouselItem:(CarouselItem*)carouselItem
fromView:(UIView*)view {
__weak __typeof(self) weakSelf = self;
__weak CarouselItem* weakItem = carouselItem;
GURL copyURL = carouselItem.URL.gurl;
UIContextMenuActionProvider actionProvider =
^(NSArray<UIMenuElement*>* suggestedActions) {
DCHECK(weakSelf);
__typeof(self) strongSelf = weakSelf;
BrowserActionFactory* actionFactory =
strongSelf.mostVisitedActionFactory;
// Record that this context menu was shown to the user.
RecordMenuShown(kMenuScenarioHistogramOmniboxMostVisitedEntry);
NSMutableArray<UIMenuElement*>* menuElements =
[[NSMutableArray alloc] init];
[menuElements
addObject:[actionFactory actionToOpenInNewTabWithURL:copyURL
completion:nil]];
UIAction* incognitoAction =
[actionFactory actionToOpenInNewIncognitoTabWithURL:copyURL
completion:nil];
if (!self.allowIncognitoActions) {
// Disable the "Open in Incognito" option if the incognito mode is
// disabled.
incognitoAction.attributes = UIMenuElementAttributesDisabled;
}
[menuElements addObject:incognitoAction];
if (base::ios::IsMultipleScenesSupported()) {
UIAction* newWindowAction = [actionFactory
actionToOpenInNewWindowWithURL:copyURL
activityOrigin:
WindowActivityContentSuggestionsOrigin];
[menuElements addObject:newWindowAction];
}
CrURL* URL = [[CrURL alloc] initWithGURL:copyURL];
[menuElements addObject:[actionFactory actionToCopyURL:URL]];
[menuElements addObject:[actionFactory actionToShareWithBlock:^{
[weakSelf.sharingDelegate
popupMediator:weakSelf
shareURL:copyURL
title:carouselItem.title
originView:view];
}]];
[menuElements addObject:[actionFactory actionToRemoveWithBlock:^{
[weakSelf removeMostVisitedForURL:copyURL
withCarouselItem:weakItem];
}]];
return [UIMenu menuWithTitle:@"" children:menuElements];
};
return
[UIContextMenuConfiguration configurationWithIdentifier:nil
previewProvider:nil
actionProvider:actionProvider];
}
- (NSArray<UIAccessibilityCustomAction*>*)
accessibilityActionsForCarouselItem:(CarouselItem*)carouselItem
fromView:(UIView*)view {
__weak __typeof(self) weakSelf = self;
__weak CarouselItem* weakItem = carouselItem;
__weak UIView* weakView = view;
GURL copyURL = carouselItem.URL.gurl;
NSMutableArray* actions = [[NSMutableArray alloc] init];
{ // Open in new tab
UIAccessibilityCustomActionHandler openInNewTabBlock =
^BOOL(UIAccessibilityCustomAction*) {
[weakSelf openNewTabWithMostVisitedItem:weakItem incognito:NO];
return YES;
};
UIAccessibilityCustomAction* openInNewTab =
[[UIAccessibilityCustomAction alloc]
initWithName:l10n_util::GetNSString(
IDS_IOS_CONTENT_CONTEXT_OPENLINKNEWTAB)
actionHandler:openInNewTabBlock];
[actions addObject:openInNewTab];
}
{ // Remove
UIAccessibilityCustomActionHandler removeBlock =
^BOOL(UIAccessibilityCustomAction*) {
[weakSelf removeMostVisitedForURL:copyURL withCarouselItem:weakItem];
return YES;
};
UIAccessibilityCustomAction* removeMostVisited =
[[UIAccessibilityCustomAction alloc]
initWithName:l10n_util::GetNSString(
IDS_IOS_CONTENT_SUGGESTIONS_REMOVE)
actionHandler:removeBlock];
[actions addObject:removeMostVisited];
}
if (self.allowIncognitoActions) { // Open in new incognito tab
UIAccessibilityCustomActionHandler openInNewIncognitoTabBlock =
^BOOL(UIAccessibilityCustomAction*) {
[weakSelf openNewTabWithMostVisitedItem:weakItem incognito:YES];
return YES;
};
UIAccessibilityCustomAction* openInIncognitoNewTab =
[[UIAccessibilityCustomAction alloc]
initWithName:l10n_util::GetNSString(
IDS_IOS_CONTENT_CONTEXT_OPENLINKNEWINCOGNITOTAB)
actionHandler:openInNewIncognitoTabBlock];
[actions addObject:openInIncognitoNewTab];
}
if (base::ios::IsMultipleScenesSupported()) { // Open in new window
UIAccessibilityCustomActionHandler openInNewWindowBlock = ^BOOL(
UIAccessibilityCustomAction*) {
NSUserActivity* activity =
ActivityToLoadURL(WindowActivityContentSuggestionsOrigin, copyURL);
[weakSelf.applicationCommandsHandler openNewWindowWithActivity:activity];
return YES;
};
UIAccessibilityCustomAction* newWindowAction =
[[UIAccessibilityCustomAction alloc]
initWithName:l10n_util::GetNSString(
IDS_IOS_CONTENT_CONTEXT_OPENINNEWWINDOW)
actionHandler:openInNewWindowBlock];
[actions addObject:newWindowAction];
}
{ // Copy
UIAccessibilityCustomActionHandler copyBlock =
^BOOL(UIAccessibilityCustomAction*) {
StoreURLInPasteboard(copyURL);
return YES;
};
UIAccessibilityCustomAction* copyAction =
[[UIAccessibilityCustomAction alloc]
initWithName:l10n_util::GetNSString(IDS_IOS_COPY_LINK_ACTION_TITLE)
actionHandler:copyBlock];
[actions addObject:copyAction];
}
{ // Share
UIAccessibilityCustomActionHandler shareBlock =
^BOOL(UIAccessibilityCustomAction*) {
[weakSelf.sharingDelegate popupMediator:weakSelf
shareURL:copyURL
title:weakItem.title
originView:weakView];
return YES;
};
UIAccessibilityCustomAction* shareAction =
[[UIAccessibilityCustomAction alloc]
initWithName:l10n_util::GetNSString(IDS_IOS_SHARE_BUTTON_LABEL)
actionHandler:shareBlock];
[actions addObject:shareAction];
}
return actions;
}
#pragma mark CarouselItemMenuProvider Private
/// Blocks `URL` so it won't appear in most visited URLs.
- (void)blockMostVisitedURL:(GURL)URL {
scoped_refptr<history::TopSites> top_sites = [self.protocolProvider topSites];
if (top_sites) {
top_sites->AddBlockedUrl(URL);
}
}
/// Blocks `URL` in most visited sites and hides `CarouselItem` if it still
/// exist.
- (void)removeMostVisitedForURL:(GURL)URL
withCarouselItem:(CarouselItem*)carouselItem {
if (!carouselItem) {
return;
}
base::RecordAction(
base::UserMetricsAction("MostVisited_UrlBlocklisted_Omnibox"));
[self blockMostVisitedURL:URL];
[self.carouselItemConsumer deleteCarouselItem:carouselItem];
}
/// Opens `carouselItem` in a new tab.
/// `incognito`: open in incognito tab.
- (void)openNewTabWithMostVisitedItem:(CarouselItem*)carouselItem
incognito:(BOOL)incognito {
DCHECK(self.applicationCommandsHandler);
OpenNewTabCommand* command =
[OpenNewTabCommand commandWithURLFromChrome:carouselItem.URL.gurl
inIncognito:incognito];
[self.applicationCommandsHandler openURLInNewTab:command];
}
/// Opens suggestAction in a new tab.
- (void)openNewTabWithSuggestAction:(SuggestAction*)suggestAction {
DCHECK(self.applicationCommandsHandler);
OpenNewTabCommand* command =
[OpenNewTabCommand commandWithURLFromChrome:suggestAction.actionURI
inIncognito:NO];
[self.applicationCommandsHandler openURLInNewTab:command];
}
@end