blob: 9bc39562cadfd27ad49c9bb5fe91e5df00d4d463 [file] [log] [blame]
// Copyright 2014 The Chromium Authors. All rights reserved.
// 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/contextual_search/contextual_search_controller.h"
#include <memory>
#include <utility>
#include "base/ios/ios_util.h"
#include "base/json/json_reader.h"
#include "base/logging.h"
#import "base/mac/bind_objc_block.h"
#include "base/mac/foundation_util.h"
#include "base/mac/scoped_block.h"
#include "base/strings/sys_string_conversions.h"
#include "base/strings/utf_string_conversions.h"
#include "base/time/time.h"
#include "base/values.h"
#include "components/google/core/browser/google_util.h"
#include "components/search_engines/template_url_service.h"
#include "ios/chrome/browser/application_context.h"
#import "ios/chrome/browser/procedural_block_types.h"
#import "ios/chrome/browser/tabs/tab.h"
#import "ios/chrome/browser/ui/commands/UIKit+ChromeExecuteCommand.h"
#import "ios/chrome/browser/ui/commands/generic_chrome_command.h"
#include "ios/chrome/browser/ui/commands/ios_command_ids.h"
#include "ios/chrome/browser/ui/contextual_search/contextual_search_context.h"
#include "ios/chrome/browser/ui/contextual_search/contextual_search_delegate.h"
#import "ios/chrome/browser/ui/contextual_search/contextual_search_header_view.h"
#import "ios/chrome/browser/ui/contextual_search/contextual_search_highlighter_view.h"
#import "ios/chrome/browser/ui/contextual_search/contextual_search_metrics.h"
#import "ios/chrome/browser/ui/contextual_search/contextual_search_panel_protocols.h"
#import "ios/chrome/browser/ui/contextual_search/contextual_search_panel_view.h"
#import "ios/chrome/browser/ui/contextual_search/contextual_search_promo_view.h"
#import "ios/chrome/browser/ui/contextual_search/contextual_search_results_view.h"
#include "ios/chrome/browser/ui/contextual_search/contextual_search_web_state_observer.h"
#import "ios/chrome/browser/ui/contextual_search/js_contextual_search_manager.h"
#import "ios/chrome/browser/ui/contextual_search/touch_to_search_permissions_mediator.h"
#import "ios/chrome/browser/ui/contextual_search/window_gesture_observer.h"
#import "ios/chrome/browser/ui/show_privacy_settings_util.h"
#include "ios/chrome/browser/ui/ui_util.h"
#import "ios/chrome/browser/ui/uikit_ui_util.h"
#import "ios/chrome/browser/web/dom_altering_lock.h"
#include "ios/chrome/common/string_util.h"
#include "ios/chrome/grit/ios_strings.h"
#import "ios/third_party/material_components_ios/src/components/Snackbar/src/MaterialSnackbar.h"
#include "ios/web/public/browser_state.h"
#include "ios/web/public/load_committed_details.h"
#include "ios/web/public/referrer.h"
#import "ios/web/public/web_state/crw_web_view_proxy.h"
#import "ios/web/public/web_state/crw_web_view_scroll_view_proxy.h"
#import "ios/web/public/web_state/js/crw_js_injection_receiver.h"
#include "ios/web/public/web_state/web_state.h"
#include "ios/web/public/web_state/web_state_observer.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/l10n/l10n_util_mac.h"
// Returns |value| clamped so that min <= value <= max
#define CLAMP(min, value, max) MAX(min, MIN(value, max))
#if !defined(__has_feature) || !__has_feature(objc_arc)
#error "This file requires ARC support."
#endif
namespace {
// command prefix for injected JavaScript.
const std::string kCommandPrefix = "contextualSearch";
// Distance from edges of frame when scrolling to show selection.
const CGFloat kYScrollMargin = 30.0;
const CGFloat kXScrollMargin = 10.0;
// Delay to check if there is a DOM modification (in second).
// If delay is too short, JavaScript won't have time to handle the event and the
// DOM tree will be modified after the highlight.
// If delay is too long, the user experience will be degraded.
// Experiments on some websites (e.g. belgianrails.be) show that delay must be
// over 0.35 second.
// Timeline is as follow:
// t: tap happens,
// t + doubleTapDelay (t1): tap is triggered
// t1: JavaScript handles tap,
// t1 + delta1: DOM may be modified,
// t1 + kDOMModificationDelaySeconds: |handleTapFrom:| starts handling tap,
// t1 + kDOMModificationDelaySeconds + delta2: JavaScript handleTap is called.
//
// The delay between DOM mutation and contextual search tap handling is really
// kDOMModificationDelaySeconds + delta2 - delta1.
// To handle this random delta value, the timeout passed to JavaScript is
// doubled
// The body touch event timeout must include the double tap delay.
const CGFloat kDOMModificationDelaySeconds = 0.1;
const CGFloat kDOMModificationDelayForJavaScriptMilliseconds =
2 * 1000 * kDOMModificationDelaySeconds;
// The touch delay disables CS on many sites, so for now it is disabled.
// The previous value used was:
// kDOMModificationDelayForJavaScriptMilliseconds + 300
const CGFloat kBodyTouchDelayForJavaScriptMilliseconds = 0;
// After a dismiss, do not allow retrigger before a delay to prevent triggering
// on double tap, and to prevent retrigger on the same event.
const NSTimeInterval kPreventTriggerAfterDismissDelaySeconds = 0.3;
CGRect StringValueToRect(NSString* rectString) {
double rectTop, rectBottom, rectLeft, rectRight;
NSArray* items = [rectString componentsSeparatedByString:@" "];
if ([items count] != 4) {
return CGRectNull;
}
rectTop = [[items objectAtIndex:0] doubleValue];
rectBottom = [[items objectAtIndex:1] doubleValue];
rectLeft = [[items objectAtIndex:2] doubleValue];
rectRight = [[items objectAtIndex:3] doubleValue];
if (isnan(rectTop) || isinf(rectTop) || isnan(rectBottom) ||
isinf(rectBottom) || isnan(rectLeft) || isinf(rectLeft) ||
isnan(rectRight) || isinf(rectRight) || rectRight <= rectLeft ||
rectBottom <= rectTop) {
return CGRectNull;
}
CGRect rect =
CGRectMake(rectLeft, rectTop, rectRight - rectLeft, rectBottom - rectTop);
return rect;
}
NSArray* StringValueToRectArray(const std::string& list) {
NSString* nsList = base::SysUTF8ToNSString(list);
NSMutableArray* rectsArray = [[NSMutableArray alloc] init];
NSArray* items = [nsList componentsSeparatedByString:@","];
for (NSString* item : items) {
CGRect rect = StringValueToRect(item);
if (CGRectIsNull(rect)) {
return nil;
}
[rectsArray addObject:[NSValue valueWithCGRect:rect]];
}
return rectsArray;
}
} // namespace
@interface ContextualSearchController ()<DOMAltering,
CRWWebViewScrollViewProxyObserver,
UIGestureRecognizerDelegate,
ContextualSearchHighlighterDelegate,
ContextualSearchPromoViewDelegate,
ContextualSearchPanelMotionObserver,
ContextualSearchPanelTapHandler,
ContextualSearchPreloadChecker,
ContextualSearchTabPromoter,
ContextualSearchWebStateDelegate,
TouchToSearchPermissionsChangeAudience>
// Controller delegate for the controller to call back to.
@property(nonatomic, readwrite, weak) id<ContextualSearchControllerDelegate>
controllerDelegate;
// Permissions interface for this feature. Property is readwrite for testing.
@property(nonatomic, readwrite, strong)
TouchToSearchPermissionsMediator* permissions;
// Synchronous method executed by -asynchronouslyEnableContextualSearch:
- (void)doEnableContextualSearch:(BOOL)enabled;
// Handler for injected JavaScript callbacks.
- (BOOL)handleScriptCommand:(const base::DictionaryValue&)JSONCommand;
// Handle the selection change event if the DOM lock is acquired.
// |selection| is the currently selected text in the webview.
// if |updated| is true, then the selection changed by the user moving one of
// the selection handles (not making a new selection).
// If |selectionValid| is false, the selection contains invalid chars or element
// and TTS should be dismissed. If selection is invalid, |selection| is empty.
- (void)handleSelectionChanged:(const std::string&)selection
selectionUpdated:(BOOL)update
selectionValid:(BOOL)selectionValid;
// Action for the tap gesture recognizer.
- (void)handleTapFrom:(UIGestureRecognizer*)gestureRecognizer;
// Handle a tap on a web view at |point|, extracting contextual search
// information from the tapped word and surrounding text.
- (void)handleTapAtPoint:(CGPoint)point;
// Initialize the contextual search JavaScript.
- (void)initializeWebViewForContextualSearch;
// Update the webViewProxy for the current tab to enable/disable scroll view
// observation.
- (void)updateWebViewProxy:(id<CRWWebViewProxy>)webViewProxy;
// Updates the UI to match the current state, setting the text label content
// if there is a current search context, and setting the panel state.
- (void)updateUI;
// Updates the UI for a resolved search.
- (void)updateForResolvedSearch:
(ContextualSearchDelegate::SearchResolution)resolution;
// State changes.
// Set the state of the panel, given |reason|. Handles metrics updates.
- (void)setState:(ContextualSearch::PanelState)state
reason:(ContextualSearch::StateChangeReason)reason;
// Dismiss pane for |reason|, invoking |completionHandler|, if any, after
// clearing any existing highlighted text in the webview, and finally releasing
// the DOM lock.
- (void)
dismissPaneWithJavascriptCompletionHandler:(ProceduralBlock)completionHandler
reason:(ContextualSearch::StateChangeReason)
reason;
// Clean-up the web state (release lock, clear highlight...) in case of a
// dismiss.
- (void)cleanUpWebStateForDismissWithCompletion:
(ProceduralBlock)completionHandler;
// Convenience method for dismissing the pane with no completion handler.
- (void)dismissPane:(ContextualSearch::StateChangeReason)reason;
// Peek (show at the bottom of the window) the pane for |reason|.
- (void)peekPane:(ContextualSearch::StateChangeReason)reason;
// Preview the pane (covering kPreviewingDisplayRatio of the webview) for
// |reason|.
- (void)previewPane:(ContextualSearch::StateChangeReason)reason;
// Cover the pane (covering the entire webview) for |reason|.
- (void)coverPane:(ContextualSearch::StateChangeReason)reason;
// Scroll the webview to show the highlighted text.
// Scroll the minimal distance to put |_highlightBoundingRect| at
// |kYScrollMargin| from top and bottom edges and |kXScrollMargin| from left and
// right edges.
// Overflow policy :
// - horizontal: center |_highlightBoundingRect|,
// - vertical: put |_highlightBoundingRect| at |kYScrollMargin| from top edge.
- (void)scrollToShowSelection:(CRWWebViewScrollViewProxy*)scrollView;
// Creates, enables or disables the dismiss recognizer based on state_.
- (void)updateDismissRecognizer;
@end
@implementation ContextualSearchController {
// Permissions interface for this feature.
TouchToSearchPermissionsMediator* _permissions;
// WebState for the tab this object is attached to.
web::WebState* _webState;
// Access to the web view from |_webState|.
id<CRWWebViewProxy> _webViewProxy;
// Observer for |_webState|.
std::unique_ptr<ContextualSearchWebStateObserver> _webStateObserver;
// Observer for search tab's web state.
std::unique_ptr<ContextualSearchWebStateObserver> _searchTabWebStateObserver;
// Object that manages find_in_page.js injection into the web view.
__weak JsContextualSearchManager* _contextualSearchJsManager;
// Gesture reccognizer for contextual search taps.
UITapGestureRecognizer* _tapRecognizer;
// Gesture reccognizer for double tap. It is used to prevent |_tapRecognizer|
// from firing if there is a double tap on the web view. It is disabled when
// the panel is displayed, since any tap will dismiss the panel in that case.
UITapGestureRecognizer* _doubleTapRecognizer;
// Gesture recognizer for long-tap copy.
UILongPressGestureRecognizer* _copyGestureRecognizer;
// Gesture recognizer to detect taps outside of the CS interface that would
// cause it to dismiss.
WindowGestureObserver* _dismissRecognizer;
// Context information retrieved from a search tap.
std::shared_ptr<ContextualSearchContext> _searchContext;
// Resolved search information generated from the context or text selection.
ContextualSearchDelegate::SearchResolution _resolvedSearch;
// Delegate for fetching search information.
std::unique_ptr<ContextualSearchDelegate> _delegate;
// The panel view controlled by this object; it is created externally and
// owned by its superview. There is no guarantee about its lifetime.
__weak ContextualSearchPanelView* _panelView;
// The view containing the highlighting of the search terms.
__weak ContextualSearchHighlighterView* _contextualHighlightView;
// Content view displayed in the peeking section of the panel.
ContextualSearchHeaderView* _headerView;
// Vertical constraints for layout of the search tab.
NSArray* _searchTabVerticalConstraints;
// Container view for the opt-out promo and the search tab view.
ContextualSearchResultsView* _searchResultsView;
// View for the opt-out promo.
ContextualSearchPromoView* _promoView;
// The tab that should be used as the opener for the search tab.
Tab* _opener;
// YES if a cancel event was received since last tap, meaning the current tap
// must not result in a search.
BOOL _currentTapCancelled;
// The current selection text.
std::string _selectedText;
// Boolean to track if the current WebState is enabled (has
// gesture recognizers and DOM lock set up).
BOOL _webStateEnabled;
// Boolean to distinguish selection-clearing taps on the webview from
// those on other UI elements.
BOOL _webViewTappedWithSelection;
// Metrics tracking variables and flags.
// Time the tap handler fires. The delay of doubleTap is not counted.
base::Time _tapTime;
// Has the user entered the previewing/covering state yet for the
// current search?
BOOL _enteredPreviewing;
BOOL _enteredCovering;
// Has the search results content been visible for the current search?
BOOL _resultsVisible;
// Has the user exited the peeking/previewing/covering state yet for the
// current search?
BOOL _exitedPeeking;
BOOL _exitedPreviewing;
BOOL _exitedCovering;
// Was the first run flow invoked during this search?
BOOL _searchInvolvedFirstRun;
// Did the first run panel become visible during this search?
BOOL _firstRunPanelBecameVisible;
// Was the search triggered by a long-press selection? Unlike other metrics-
// related flags, this is not reset when a search ends; instead it is set
// when a new search is started.
BOOL _searchTriggeredBySelection;
// Has the current search used SERP navigation (tapped on a link on the
// search results page)?
BOOL _usedSERPNavigation;
// Boolean to track if the script has been injected in the current page. This
// is a faster check than asking the JS controller.
BOOL _isScriptInjected;
// Boolean to track if the UIMenuControllerWillShowMenuNotification is
// observed (to prevent double observation).
BOOL _observingActionMenu;
// Boolean to track if the current search is triggered by selection, and
// action menu should be disabled.
BOOL _preventActionMenu;
// Boolean to track if a new text selection has been made (as opposed to an
// existing one being changed) which will trigger the appearance of the
// panel.
BOOL _newSelectionDisplaying;
// Boolean to temporarly disable preloading of search tab.
BOOL _preventPreload;
// Boolean to track if the search term has been resolved.
BOOL _searchTermResolved;
// True when closed has been called and contextual search controller
// has been destroyed.
BOOL _closed;
// When view is resized, JavaScript and UIView sizes are not updated at the
// same time. Computing a scroll delta to make selection visible in these
// conditions will likely scroll to a random position.
BOOL _preventScrollToShowSelection;
// The time of the last dismiss.
NSDate* _lastDismiss;
}
@synthesize enabled = _enabled;
@synthesize controllerDelegate = _controllerDelegate;
@synthesize webState = _webState;
- (instancetype)initWithBrowserState:(ios::ChromeBrowserState*)browserState
delegate:(id<ContextualSearchControllerDelegate>)
delegate {
if ((self = [super init])) {
_permissions = [[TouchToSearchPermissionsMediator alloc]
initWithBrowserState:browserState];
[_permissions setAudience:self];
self.controllerDelegate = delegate;
// Set up the web state observer. This lasts as long as this object does,
// but it will observe and un-observe the web tabs as it changes over time.
_webStateObserver.reset(new ContextualSearchWebStateObserver(self));
_copyGestureRecognizer = [[UILongPressGestureRecognizer alloc]
initWithTarget:self
action:@selector(handleLongPressFrom:)];
__weak ContextualSearchController* weakself = self;
auto callback = base::BindBlockArc(
^(ContextualSearchDelegate::SearchResolution resolution) {
[weakself updateForResolvedSearch:resolution];
});
_delegate.reset(new ContextualSearchDelegate(browserState, callback));
}
return self;
}
- (TouchToSearchPermissionsMediator*)permissions {
return _permissions;
}
- (void)setPermissions:(TouchToSearchPermissionsMediator*)permissions {
_permissions = permissions;
}
- (ContextualSearchPanelView*)panel {
return _panelView;
}
- (void)setPanel:(ContextualSearchPanelView*)panel {
DCHECK(!_panelView);
DCHECK(panel);
// Save the new panel, set up observation and delegation relationships.
_panelView = panel;
[_panelView addMotionObserver:self];
[_dismissRecognizer setViewToExclude:_panelView];
// Create new subviews.
NSMutableArray* panelContents = [NSMutableArray arrayWithCapacity:3];
_headerView = [[ContextualSearchHeaderView alloc]
initWithHeight:[_panelView configuration].peekingHeight];
[_headerView addGestureRecognizer:_copyGestureRecognizer];
[_headerView setTapHandler:self];
[panelContents addObject:_headerView];
if (self.permissions.preferenceState == TouchToSearch::UNDECIDED) {
_promoView = [[ContextualSearchPromoView alloc] initWithFrame:CGRectZero
delegate:self];
[panelContents addObject:_promoView];
}
_searchResultsView =
[[ContextualSearchResultsView alloc] initWithFrame:CGRectZero];
[_searchResultsView setPromoter:self];
[_searchResultsView setPreloadChecker:self];
[panelContents addObject:_searchResultsView];
[_panelView addContentViews:panelContents];
}
- (void)enableContextualSearch:(BOOL)enabled {
// Asynchronously enables contextual search, so that some preferences
// (UIAccessibilityIsVoiceOverRunning(), for example) have time to synchronize
// with their own notifications.
__weak ContextualSearchController* weakSelf = self;
dispatch_async(dispatch_get_main_queue(), ^{
[weakSelf doEnableContextualSearch:enabled];
});
}
- (void)doEnableContextualSearch:(BOOL)enabled {
enabled = enabled && [self.permissions canEnable];
BOOL changing = _enabled != enabled;
if (changing) {
if (!enabled) {
[self dismissPane:ContextualSearch::RESET];
}
_enabled = enabled;
[self enableCurrentWebState];
}
}
- (void)updateWebViewProxy:(id<CRWWebViewProxy>)webViewProxy {
if (_webViewProxy) {
[[_webViewProxy scrollViewProxy] removeObserver:self];
}
_webViewProxy = webViewProxy;
if (_webViewProxy) {
[[_webViewProxy scrollViewProxy] addObserver:self];
}
}
- (void)setTab:(Tab*)tab {
[self setWebState:tab.webState];
[_searchResultsView setOpener:tab];
}
- (void)setWebState:(web::WebState*)webState {
[self disconnectWebState];
if (webState) {
_contextualSearchJsManager = static_cast<JsContextualSearchManager*>(
[webState->GetJSInjectionReceiver()
instanceOfClass:[JsContextualSearchManager class]]);
_webState = webState;
_webStateObserver->ObserveWebState(webState);
[self updateWebViewProxy:webState->GetWebViewProxy()];
[self enableCurrentWebState];
} else {
_webState = nullptr;
}
}
- (void)enableCurrentWebState {
if (![self webState])
return;
if (_enabled && [self webState]->ContentIsHTML()) {
if (!_webStateEnabled) {
DOMAlteringLock::CreateForWebState([self webState]);
__weak ContextualSearchController* weakSelf = self;
auto callback = base::BindBlockArc(
^bool(const base::DictionaryValue& JSON, const GURL& originURL,
bool userIsInteracting) {
ContextualSearchController* strongSelf = weakSelf;
// |originURL| and |isInteracting| aren't used.
return [strongSelf handleScriptCommand:JSON];
});
[self webState]->AddScriptCommandCallback(callback, kCommandPrefix);
// |_doubleTapRecognizer| should be added to the web view before
// |_tapRecognizer| so |_tapRecognizer| can require it to fail.
_doubleTapRecognizer =
[[UITapGestureRecognizer alloc] initWithTarget:self
action:@selector(ignoreTap:)];
[_doubleTapRecognizer setDelegate:self];
[_doubleTapRecognizer setNumberOfTapsRequired:2];
[_webViewProxy addGestureRecognizer:_doubleTapRecognizer];
_tapRecognizer = [[UITapGestureRecognizer alloc]
initWithTarget:self
action:@selector(handleTapFrom:)];
[_tapRecognizer setDelegate:self];
[_webViewProxy addGestureRecognizer:_tapRecognizer];
// Make sure that |_tapRecogngizer| doesn't fire if the web view's other
// non-single-finger non-single-tap recognizers fire.
for (UIGestureRecognizer* recognizer in
[[_tapRecognizer view] gestureRecognizers]) {
if ([recognizer isKindOfClass:[UITapGestureRecognizer class]] &&
([static_cast<UITapGestureRecognizer*>(recognizer)
numberOfTapsRequired] > 1 ||
[static_cast<UITapGestureRecognizer*>(recognizer)
numberOfTouchesRequired] > 1)) {
[_tapRecognizer requireGestureRecognizerToFail:recognizer];
}
}
_webStateEnabled = YES;
}
[self initializeWebViewForContextualSearch];
} else {
[self disableCurrentWebState];
}
}
- (void)disableCurrentWebState {
if (_webStateEnabled) {
if ([self webState]->ContentIsHTML()) {
[self highlightRects:nil];
[_contextualHighlightView removeFromSuperview];
[_contextualSearchJsManager clearHighlight];
[_contextualSearchJsManager disableListeners];
}
_webState->RemoveScriptCommandCallback(kCommandPrefix);
DOMAlteringLock::FromWebState(_webState)->Release(self);
[_webViewProxy removeGestureRecognizer:_tapRecognizer];
[_webViewProxy removeGestureRecognizer:_doubleTapRecognizer];
_webStateEnabled = NO;
}
}
- (void)disconnectWebState {
if (_webState) {
_contextualSearchJsManager = nil;
_webStateObserver->ObserveWebState(nullptr);
[self updateWebViewProxy:nil];
[self disableCurrentWebState];
}
}
- (void)updateDismissRecognizer {
if (!_panelView)
return;
if (!_dismissRecognizer) {
_dismissRecognizer = [[WindowGestureObserver alloc]
initWithTarget:self
action:@selector(handleWindowGesture:)];
[_dismissRecognizer setViewToExclude:_panelView];
[[_panelView window] addGestureRecognizer:_dismissRecognizer];
}
[_dismissRecognizer
setEnabled:[_panelView state] >= ContextualSearch::PEEKING];
}
- (void)showLearnMore {
[self dismissPane:ContextualSearch::UNKNOWN];
GURL learnMoreUrl = google_util::AppendGoogleLocaleParam(
GURL(l10n_util::GetStringUTF8(IDS_IOS_CONTEXTUAL_SEARCH_LEARN_MORE_URL)),
GetApplicationContext()->GetApplicationLocale());
[_controllerDelegate createTabFromContextualSearchController:learnMoreUrl];
}
- (void)dealloc {
[self close];
}
- (void)handleWindowGesture:(UIGestureRecognizer*)recognizer {
DCHECK(recognizer == _dismissRecognizer);
[self dismissPane:ContextualSearch::BASE_PAGE_TAP];
}
- (BOOL)canExtractTapContext {
web::URLVerificationTrustLevel trustLevel = web::kNone;
GURL pageURL = [self webState]->GetCurrentURL(&trustLevel);
return [self.permissions canExtractTapContextForURL:pageURL];
}
- (void)initializeWebViewForContextualSearch {
DCHECK(_webStateEnabled);
[_contextualSearchJsManager inject];
_isScriptInjected = YES;
[_contextualSearchJsManager
enableEventListenersWithMutationDelay:
kDOMModificationDelayForJavaScriptMilliseconds
bodyTouchDelay:
kBodyTouchDelayForJavaScriptMilliseconds];
}
- (void)handleSelectionChanged:(const std::string&)selection
selectionUpdated:(BOOL)updated
selectionValid:(BOOL)selectionValid {
if (!selectionValid) {
[self dismissPane:ContextualSearch::INVALID_SELECTION];
return;
}
std::string selectedText = CleanStringForDisplay(selection, true);
if (selectedText == _selectedText)
return;
_newSelectionDisplaying = !updated && !selectedText.empty();
_selectedText = selectedText;
_searchContext.reset();
[self highlightRects:nil];
[_contextualSearchJsManager clearHighlight];
_delegate->CancelSearchTermRequest();
if (selectedText.length() == 0) {
if (_webViewTappedWithSelection) {
[self dismissPane:ContextualSearch::BASE_PAGE_TAP];
}
} else {
// TODO(crbug.com/546220): Detect and use actual page encoding.
std::string encoding = "UTF-8";
_searchContext.reset(
new ContextualSearchContext(selectedText, true, GURL(), encoding));
_searchTriggeredBySelection = YES;
_preventPreload = YES;
_delegate->PostSearchTermRequest(_searchContext);
ContextualSearch::RecordSelectionIsValid(true);
_preventActionMenu = YES;
if (!_observingActionMenu) {
_observingActionMenu = YES;
[[NSNotificationCenter defaultCenter]
addObserver:self
selector:@selector(willShowMenuNotification)
name:UIMenuControllerWillShowMenuNotification
object:nil];
}
[self peekPane:ContextualSearch::TEXT_SELECT_LONG_PRESS];
[_headerView
setSearchTerm:base::SysUTF8ToNSString(selectedText)
animated:[_panelView state] != ContextualSearch::DISMISSED];
}
_webViewTappedWithSelection = NO;
}
- (BOOL)handleScriptCommand:(const base::DictionaryValue&)JSONCommand {
std::string command;
if (!JSONCommand.GetString("command", &command))
return NO;
if (command == "contextualSearch.selectionChanged") {
std::string selectedText;
if (!JSONCommand.GetString("text", &selectedText))
return NO;
bool selectionUpdated;
if (!JSONCommand.GetBoolean("updated", &selectionUpdated))
selectionUpdated = false;
bool selectionValid;
if (!JSONCommand.GetBoolean("valid", &selectionValid))
selectionValid = true;
__weak ContextualSearchController* weakSelf = self;
ProceduralBlockWithBool lockAction = ^(BOOL lockAcquired) {
if (lockAcquired) {
[weakSelf handleSelectionChanged:selectedText
selectionUpdated:selectionUpdated
selectionValid:selectionValid];
}
};
DOMAlteringLock::FromWebState([self webState])->Acquire(self, lockAction);
return YES;
}
if (command == "contextualSearch.mutationEvent") {
if ([_panelView state] <= ContextualSearch::PEEKING &&
!_searchTermResolved) {
[self dismissPane:ContextualSearch::UNKNOWN];
}
return YES;
}
return NO;
}
- (void)ignoreTap:(UIGestureRecognizer*)recognizer {
// This method is intentionally empty. It is intended to ignore the tap.
}
- (void)handleTapFrom:(UIGestureRecognizer*)recognizer {
DCHECK(recognizer == _tapRecognizer);
// Taps will be triggered by long-presses to make a selection in the webview,
// as well as 'regular' taps. Long-presses that create a selection will set
// |_newSelectionDisplaying| as well as populating _selectedText (this happens
// in -handleScriptCommand:).
// If we just dismissed, do not consider this tap.
NSTimeInterval dismissTimeout = [_lastDismiss timeIntervalSinceNow] +
kPreventTriggerAfterDismissDelaySeconds;
// If the panel is already displayed, just dismiss it and return, unless the
// tap was from displaying a new selection.
if (([_panelView state] != ContextualSearch::DISMISSED &&
!_newSelectionDisplaying) ||
dismissTimeout > 0) {
[self dismissPane:ContextualSearch::BASE_PAGE_TAP];
return;
}
// Otherwise handle the tap.
[_tapRecognizer setEnabled:NO];
_currentTapCancelled = NO;
_newSelectionDisplaying = NO;
ProceduralBlockWithBool lockAction = ^(BOOL lockAcquired) {
if (!lockAcquired || !_isScriptInjected || _currentTapCancelled ||
[recognizer state] != UIGestureRecognizerStateEnded ||
!_selectedText.empty()) {
[_tapRecognizer setEnabled:YES];
if (!_selectedText.empty())
_webViewTappedWithSelection = YES;
return;
}
CGPoint tapPoint = [recognizer locationInView:recognizer.view];
// tapPoint is the coordinate of the tap in the webView. If the view is
// currently offset because a header is displayed, offset the tapPoint.
tapPoint.y -= [_controllerDelegate currentHeaderHeight];
// Handle tap asynchronously to monitor DOM modifications. See comment
// of |kDOMModificationDelaySeconds| for details.
dispatch_time_t dispatch = dispatch_time(
DISPATCH_TIME_NOW,
static_cast<int64_t>(kDOMModificationDelaySeconds * NSEC_PER_SEC));
__weak ContextualSearchController* weakSelf = self;
dispatch_after(dispatch, dispatch_get_main_queue(), ^{
[weakSelf handleTapAtPoint:tapPoint];
});
};
DOMAlteringLock::FromWebState([self webState])->Acquire(self, lockAction);
}
- (void)handleLongPressFrom:(UIGestureRecognizer*)recognizer {
DCHECK(recognizer == _copyGestureRecognizer);
if (recognizer.state != UIGestureRecognizerStateEnded)
return;
// Put the resolved search term (or the current selected text) into the
// pasteboard.
std::string text;
if (!_resolvedSearch.display_text.empty()) {
text = _resolvedSearch.display_text;
}
if (!text.empty()) {
UIPasteboard* pasteboard = [UIPasteboard generalPasteboard];
pasteboard.string = base::SysUTF8ToNSString(_resolvedSearch.display_text);
// Let the user know.
NSString* messageText = l10n_util::GetNSString(IDS_IOS_SEARCH_COPIED);
MDCSnackbarMessage* message =
[MDCSnackbarMessage messageWithText:messageText];
message.duration = 1.0;
message.category = @"search term copied";
[MDCSnackbarManager showMessage:message];
}
}
- (void)handleTapAtPoint:(CGPoint)point {
_tapTime = base::Time::Now();
if (_currentTapCancelled) {
[_tapRecognizer setEnabled:YES];
return;
}
_searchTriggeredBySelection = NO;
// TODO(crbug.com/546220): Detect and use actual page encoding.
std::string encoding = "UTF-8";
CGPoint relativeTapPoint = point;
CGSize contentSize = [_webViewProxy scrollViewProxy].contentSize;
relativeTapPoint.x += [_webViewProxy scrollViewProxy].contentOffset.x;
relativeTapPoint.y += [_webViewProxy scrollViewProxy].contentOffset.y;
relativeTapPoint.x /= contentSize.width;
relativeTapPoint.y /= contentSize.height;
__weak id<CRWWebViewProxy> weakWebViewProxy = _webViewProxy;
void (^handler)(NSString*) = ^(NSString* result) {
[_tapRecognizer setEnabled:YES];
// If there has been an error in the javascript, return can be nil.
if (!result || _currentTapCancelled)
return;
// Parse JSON.
const std::string json = base::SysNSStringToUTF8(result);
std::unique_ptr<base::Value> parsedResult(
base::JSONReader::Read(json, false));
if (!parsedResult.get() ||
!parsedResult->IsType(base::Value::Type::DICTIONARY)) {
return;
}
base::DictionaryValue* resultDict =
static_cast<base::DictionaryValue*>(parsedResult.get());
const base::DictionaryValue* context = nullptr;
BOOL contextError = NO;
if (!resultDict->GetDictionary("context", &context)) {
// No context returned -- the tap wasn't on a word.
DVLOG(1) << "Contextual search results did not include a context.";
contextError = YES;
} else {
std::string error;
context->GetString("error", &error);
if (!error.empty()) {
// Something went wrong!
DVLOG(0) << "Contextual search error: " << error;
contextError = YES;
}
}
if (contextError) {
_searchContext.reset();
[self updateUI];
// The JavaScript will have taken care of clearing the highlighting.
return;
}
// Marshall the retrieved context.
std::string url, selectedText;
BOOL marshallingOK = YES;
GURL sentUrl;
if ([self.permissions canSendPageURLs]) {
marshallingOK = marshallingOK && context->GetString("url", &url);
sentUrl = GURL(url);
}
marshallingOK =
marshallingOK && context->GetString("selectedText", &selectedText);
if (!marshallingOK) {
_searchContext.reset();
[self updateUI];
// The JavaScript will have taken care of clearing the highlighting.
return;
}
_searchContext.reset(
new ContextualSearchContext(selectedText, true, sentUrl, encoding));
if ([self canExtractTapContext]) {
marshallingOK =
marshallingOK &&
context->GetString("surroundingText",
&_searchContext->surrounding_text) &&
context->GetInteger("offsetStart", &_searchContext->start_offset) &&
context->GetInteger("offsetEnd", &_searchContext->end_offset);
}
if (!marshallingOK) {
_searchContext.reset();
[self updateUI];
// The JavaScript will have taken care of clearing the highlighting.
return;
}
DVLOG(1) << "Contextual search results:\n"
<< " URL: " << _searchContext->page_url.spec() << "\n"
<< " selectedText: " << _searchContext->selected_text << "\n"
<< " offsets: " << _searchContext->start_offset << "-"
<< _searchContext->end_offset << "\n"
<< " surroundingText: " << _searchContext->surrounding_text;
std::string rects;
if (!context->GetString("rects", &rects)) {
_searchContext.reset();
[self updateUI];
return;
}
NSArray* rectsArray = StringValueToRectArray(rects);
if (!rectsArray) {
_searchContext.reset();
[self updateUI];
return;
}
[self highlightRects:rectsArray];
[self scrollToShowSelection:[weakWebViewProxy scrollViewProxy]];
// Update the content view and the state of the UI.
[self updateUI];
_preventPreload = NO;
_delegate->PostSearchTermRequest(_searchContext);
_searchTriggeredBySelection = NO;
};
[_contextualSearchJsManager fetchContextFromSelectionAtPoint:relativeTapPoint
completionHandler:handler];
}
- (void)handleHighlightJSResult:(id)result withError:(NSError*)error {
if (error) {
[self highlightRects:nil];
[_contextualSearchJsManager clearHighlight];
return;
}
std::string JSON(
base::SysNSStringToUTF8(base::mac::ObjCCastStrict<NSString>(result)));
// |json| is a JSON dicionary containing at list 2 entries:
// - 'rects': containing a list of rect dictionaries representing the zone of
// the page to highlight as a string in the format
// top1 bottom1 left1 right1,top2 bottom2 left2 right2,...,
// - 'size': containing a dictionary containing the size of the document as
// seen in JavaScript.
// As the 'rects' coordinates are based on a document which size is contained
// in 'size', if the web content view does not have the same size, they should
// not be considered.
std::unique_ptr<base::Value> parsedResult(
base::JSONReader::Read(JSON, false));
if (!parsedResult.get() ||
!parsedResult->IsType(base::Value::Type::DICTIONARY)) {
return;
}
base::DictionaryValue* resultDict =
static_cast<base::DictionaryValue*>(parsedResult.get());
CGSize contentSize = [_webViewProxy scrollViewProxy].contentSize;
const base::DictionaryValue* contentSizeDict;
if (resultDict->GetDictionary("size", &contentSizeDict)) {
double width, height;
if (!contentSizeDict->GetDouble("height", &height) ||
!contentSizeDict->GetDouble("width", &width)) {
// Value is not correctly formatted. Early return.
return;
}
width *= [_webViewProxy scrollViewProxy].zoomScale;
height *= [_webViewProxy scrollViewProxy].zoomScale;
if (fabsl(contentSize.width - width) > 2 ||
fabsl(contentSize.height - height) > 2) {
// The coords in of the UIView and in JavaScript are not synced. A scroll
// now would be almost random.
_preventScrollToShowSelection = YES;
dispatch_after(
dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)),
dispatch_get_main_queue(), ^{
[self updateHighlight];
});
return;
}
_preventScrollToShowSelection = NO;
}
std::string rectsList;
if (resultDict->GetString("rects", &rectsList)) {
NSArray* rects = StringValueToRectArray(rectsList);
if (rects) {
[self highlightRects:rects];
[self scrollToShowSelection:[_webViewProxy scrollViewProxy]];
}
}
}
- (void)updateForResolvedSearch:
(ContextualSearchDelegate::SearchResolution)resolution {
_resolvedSearch = resolution;
DVLOG(1) << "is invalid: " << _resolvedSearch.is_invalid << "\n"
<< "response code: " << _resolvedSearch.response_code << "\n"
<< "search term: " << _resolvedSearch.search_term << "\n"
<< "search term: " << _resolvedSearch.alternate_term << "\n"
<< "display text: " << _resolvedSearch.display_text << "\n"
<< "stop preload: " << _resolvedSearch.prevent_preload;
if (_resolvedSearch.is_invalid) {
[self dismissPane:ContextualSearch::UNKNOWN];
} else {
_searchTermResolved = YES;
[_headerView
setSearchTerm:base::SysUTF8ToNSString(_resolvedSearch.display_text)
animated:[_panelView state] != ContextualSearch::DISMISSED];
if (_resolvedSearch.start_offset != -1 &&
_resolvedSearch.end_offset != -1) {
__weak ContextualSearchController* weakSelf = self;
[_contextualSearchJsManager
expandHighlightToStartOffset:_resolvedSearch.start_offset
endOffset:_resolvedSearch.end_offset
completionHandler:^(id result, NSError* error) {
[weakSelf handleHighlightJSResult:result
withError:error];
}];
}
GURL url = _delegate->GetURLForResolvedSearch(_resolvedSearch, true);
[_searchResultsView createTabForSearch:url
preloadEnabled:!_resolvedSearch.prevent_preload];
// Record the tap-to-search interval.
ContextualSearch::RecordTimeToSearch(base::Time::Now() - _tapTime);
}
}
- (void)updateUI {
if (_searchContext) {
ContextualSearch::RecordSelectionIsValid(true);
[self peekPane:ContextualSearch::TEXT_SELECT_TAP];
_searchInvolvedFirstRun =
self.permissions.preferenceState == TouchToSearch::UNDECIDED;
if (_searchContext->surrounding_text.empty()) {
[_headerView
setSearchTerm:base::SysUTF8ToNSString(_searchContext->selected_text)
animated:[_panelView state] != ContextualSearch::DISMISSED];
} else {
NSString* surroundingText =
base::SysUTF16ToNSString(_searchContext->surrounding_text);
NSInteger startOffset =
CLAMP(0, _searchContext->start_offset,
static_cast<NSInteger>([surroundingText length]));
NSString* displayedText =
[surroundingText substringFromIndex:startOffset];
NSInteger adjusted_offset =
_searchContext->end_offset - _searchContext->start_offset;
NSInteger followingOffset = CLAMP(
0, adjusted_offset, static_cast<NSInteger>([surroundingText length]));
NSRange followingTextRange =
NSMakeRange(followingOffset, displayedText.length - followingOffset);
[_headerView setText:displayedText
followingTextRange:followingTextRange
animated:[_panelView state] != ContextualSearch::DISMISSED];
}
} else {
ContextualSearch::RecordSelectionIsValid(false);
[self dismissPane:ContextualSearch::INVALID_SELECTION];
}
}
- (void)scrollToShowSelection:(CRWWebViewScrollViewProxy*)scrollView {
if (!scrollView || _preventScrollToShowSelection)
return;
if (!_contextualHighlightView) {
return;
}
CGRect highlightBoundingRect = [_contextualHighlightView boundingRect];
if (CGRectIsNull(highlightBoundingRect)) {
return;
}
// Do the maths without the insets.
CGPoint scrollPoint = [scrollView contentOffset];
scrollPoint.y += scrollView.contentInset.top;
scrollPoint.x += scrollView.contentInset.left;
// Coordinates of the bounding box to show.
CGFloat top = CGRectGetMinY(highlightBoundingRect);
CGFloat bottom = CGRectGetMaxY(highlightBoundingRect);
CGFloat left = CGRectGetMinX(highlightBoundingRect);
CGFloat right = CGRectGetMaxX(highlightBoundingRect);
CGSize displaySize = [_contextualHighlightView frame].size;
CGFloat panelHeight = CGRectGetHeight(
CGRectIntersection([_panelView frame], [_panelView superview].bounds));
displaySize.height -= scrollView.contentInset.top +
scrollView.contentInset.bottom + panelHeight;
displaySize.width -=
scrollView.contentInset.left + scrollView.contentInset.right;
// Coordinates of the displayed frame in the same coordinates system.
CGFloat frameTop = scrollPoint.y;
CGFloat frameBottom = frameTop + displaySize.height;
CGFloat frameLeft = scrollPoint.x;
CGFloat frameRight = frameLeft + displaySize.width;
CGSize contentSize = scrollView.contentSize;
CGFloat maxOffsetY = MAX(contentSize.height - displaySize.height, 0);
CGFloat maxOffsetX = MAX(contentSize.width - displaySize.width, 0);
if (highlightBoundingRect.size.width + 2 * kXScrollMargin >
displaySize.width) {
// Selection does not fit in the screen. Center horizontal scroll.
if (contentSize.width > displaySize.width) {
scrollPoint.x = (left + right - displaySize.width) / 2;
}
} else {
// Make sure right is visible.
if (right + kXScrollMargin > frameRight) {
scrollPoint.x = right + kXScrollMargin - displaySize.width;
}
// Make sure left is visible.
if (left - kXScrollMargin < frameLeft) {
scrollPoint.x = left - kXScrollMargin;
}
}
// Make sure bottom is visible.
if (bottom + kYScrollMargin > frameBottom) {
scrollPoint.y = bottom + kYScrollMargin - displaySize.height;
}
// Make sure top is visible.
if (top - kYScrollMargin - [_controllerDelegate currentHeaderHeight] <
frameTop) {
scrollPoint.y =
top - kYScrollMargin - [_controllerDelegate currentHeaderHeight];
}
if (scrollPoint.x < 0)
scrollPoint.x = 0;
if (scrollPoint.x > maxOffsetX) {
scrollPoint.x = maxOffsetX;
}
if (scrollPoint.y < 0)
scrollPoint.y = 0;
if (scrollPoint.y > maxOffsetY)
scrollPoint.y = maxOffsetY;
scrollPoint.y -= scrollView.contentInset.top;
scrollPoint.x -= scrollView.contentInset.left;
[scrollView setContentOffset:scrollPoint animated:YES];
}
- (void)highlightRects:(NSArray*)rects {
if (![self webState]) {
return;
}
if (!_contextualHighlightView && [rects count]) {
CGRect frame = [[self webState]->GetWebViewProxy() frame];
ContextualSearchHighlighterView* highlightView =
[[ContextualSearchHighlighterView alloc] initWithFrame:frame
delegate:self];
_contextualHighlightView = highlightView;
[[self webState]->GetWebViewProxy() addSubview:highlightView];
}
CGPoint scroll = [[_webViewProxy scrollViewProxy] contentOffset];
[_contextualHighlightView
highlightRects:rects
withOffset:[_controllerDelegate currentHeaderHeight]
zoom:[[_webViewProxy scrollViewProxy] zoomScale]
scroll:scroll];
}
- (void)willShowMenuNotification {
if (!_preventActionMenu)
return;
BOOL dismiss = NO;
if ([_panelView state] > ContextualSearch::PEEKING) {
dismiss = YES;
}
if ([_panelView state] == ContextualSearch::PEEKING) {
CGPoint headerTop = [_headerView convertPoint:CGPointZero toView:nil];
CGRect menuRect = [[UIMenuController sharedMenuController] menuFrame];
if (headerTop.y < CGRectGetMaxY(menuRect)) {
dismiss = YES;
}
}
if (dismiss) {
dispatch_async(dispatch_get_main_queue(), ^{
[[UIMenuController sharedMenuController] setMenuVisible:NO];
});
}
}
- (void)close {
if (_closed)
return;
_closed = YES;
[self disableCurrentWebState];
[self setWebState:nil];
[_headerView removeGestureRecognizer:_copyGestureRecognizer];
[[_panelView window] removeGestureRecognizer:_dismissRecognizer];
_delegate.reset();
[_searchResultsView setActive:NO];
_searchResultsView = nil;
}
#pragma mark - Promo view management
- (void)userOptedInFromPromo:(BOOL)optIn {
if (optIn) {
self.permissions.preferenceState = TouchToSearch::ENABLED;
[_promoView closeAnimated:YES];
[_promoView setDisabled:YES];
} else {
[self dismissPane:ContextualSearch::OPTOUT];
self.permissions.preferenceState = TouchToSearch::DISABLED;
}
ContextualSearch::RecordFirstRunFlowOutcome(self.permissions.preferenceState);
}
#pragma mark - ContextualSearchPreloadChecker
- (BOOL)canPreloadSearchResults {
if (_preventPreload) {
return NO;
}
return [self.permissions canPreloadSearchResults];
}
#pragma mark - ContextualSearchTabPromoter
- (void)promoteTabHeaderPressed:(BOOL)headerPressed {
// Move the panel so it's covering before the transition.
if ([_panelView state] != ContextualSearch::COVERING) {
[self coverPane:ContextualSearch::SERP_NAVIGATION];
}
// TODO(crbug.com/455334): Make this transition look nicer.
[_searchResultsView scrollToTopAnimated:YES];
[self cleanUpWebStateForDismissWithCompletion:nil];
// Tell the BVC to handle the promotion, which will cause a new panel view
// to be created.
[_controllerDelegate promotePanelToTabProvidedBy:_searchResultsView
focusInput:NO];
}
#pragma mark - ContextualSearchPanelMotionObserver
- (void)panel:(ContextualSearchPanelView*)panel
didStopMovingWithMotion:(ContextualSearch::PanelMotion)motion {
if (motion.state == ContextualSearch::DISMISSED) {
[self dismissPane:ContextualSearch::SWIPE];
} else if (motion.state == ContextualSearch::PEEKING) {
// newOrigin is above peeking height but below preview height.
if ([_panelView state] >= ContextualSearch::PREVIEWING) {
// Dragged down from previewing or covering
[self peekPane:ContextualSearch::SWIPE];
} else {
// Dragged up or stayed the same.
[self previewPane:ContextualSearch::SWIPE];
}
} else {
if ([_panelView state] == ContextualSearch::COVERING) {
if (motion.state != ContextualSearch::COVERING) {
// Dragged down from covering.
[self previewPane:ContextualSearch::SWIPE];
}
} else {
// Dragged up.
[self coverPane:ContextualSearch::SWIPE];
}
}
[self updateHighlight];
}
- (void)panelWillPromote:(ContextualSearchPanelView*)panel {
DCHECK(panel == _panelView);
[panel removeMotionObserver:self];
_panelView = nil;
[self setState:ContextualSearch::DISMISSED
reason:ContextualSearch::TAB_PROMOTION];
}
#pragma mark - ContextualSearchPanelTapHandler
- (void)panelWasTapped:(UIGestureRecognizer*)gesture {
// Tapping when peeking switches to previewing.
// Tapping otherwise turns the panel into a tab.
if ([_panelView state] == ContextualSearch::PEEKING) {
[self previewPane:ContextualSearch::SEARCH_BAR_TAP];
} else {
[self promoteTabHeaderPressed:YES];
}
}
- (void)closePanel {
[self dismissPane:ContextualSearch::SEARCH_BAR_TAP];
}
#pragma mark - State change methods
- (void)setState:(ContextualSearch::PanelState)state
reason:(ContextualSearch::StateChangeReason)reason {
ContextualSearch::PanelState fromState = [_panelView state];
// If we're moving to PEEKING as a result of text selection, that's starting
// a new search.
BOOL startingSearch = state == ContextualSearch::PEEKING &&
(reason == ContextualSearch::TEXT_SELECT_TAP ||
reason == ContextualSearch::TEXT_SELECT_LONG_PRESS);
// If we're showing anything, then there's an ongoing search.
BOOL ongoingSearch = fromState > ContextualSearch::DISMISSED;
// If there's an ongoing search and we're dismissing or starting a search,
// then we're ending a search.
BOOL endingSearch =
ongoingSearch && (state == ContextualSearch::DISMISSED || startingSearch);
// If we're starting a search while there's one already there, it's chained.
BOOL chained = startingSearch && endingSearch;
BOOL sameState = fromState == state;
BOOL firstExitFromPeeking = fromState == ContextualSearch::PEEKING &&
!_exitedPeeking && (!sameState || startingSearch);
BOOL firstExitFromPreviewing = fromState == ContextualSearch::PREVIEWING &&
!_exitedPreviewing && !sameState;
BOOL firstExitFromCovering =
fromState == ContextualSearch::COVERING && !_exitedCovering && !sameState;
_resultsVisible = _resultsVisible || [_searchResultsView contentVisible];
if (endingSearch) {
if (_searchInvolvedFirstRun) {
// If the first run panel might have been shown, did the user see it?
ContextualSearch::RecordFirstRunPanelSeen(_firstRunPanelBecameVisible);
}
// Record search timing.
[_searchResultsView recordFinishedSearchChained:chained];
// Record if the user saw the search results.
if (_searchTriggeredBySelection) {
ContextualSearch::RecordSelectionResultsSeen(_resultsVisible);
} else {
ContextualSearch::RecordTapResultsSeen(_resultsVisible);
}
}
// Log state change. We only log the first transition to a state within a
// contextual search. Note that when a user clicks on a link on the search
// content view, this will trigger a transition to COVERING (SERP_NAVIGATION)
// followed by a transition to DISMISSED (TAB_PROMOTION). For the purpose of
// logging, the reason for the second transition is reinterpreted to
// SERP_NAVIGATION, in order to distinguish it from a tab promotion caused
// when tapping on the header when the panel is maximized.
ContextualSearch::StateChangeReason loggedReason =
_usedSERPNavigation ? ContextualSearch::SERP_NAVIGATION : reason;
if (startingSearch || endingSearch ||
(!sameState && !_enteredPreviewing &&
state == ContextualSearch::PREVIEWING) ||
(!sameState && !_enteredCovering &&
state == ContextualSearch::COVERING)) {
ContextualSearch::RecordFirstStateEntry(fromState, state, loggedReason);
}
if ((startingSearch && !chained) || firstExitFromPeeking ||
firstExitFromPreviewing || firstExitFromCovering) {
ContextualSearch::RecordFirstStateExit(fromState, state, loggedReason);
}
if (firstExitFromPeeking) {
_exitedPeeking = YES;
} else if (firstExitFromPreviewing) {
_exitedPreviewing = YES;
} else if (firstExitFromCovering) {
_exitedCovering = YES;
}
[_panelView setState:state];
// If the panel is now visible, enable the window-tap detector.
[self updateDismissRecognizer];
if (state == ContextualSearch::PREVIEWING) {
_enteredPreviewing = YES;
} else if (state == ContextualSearch::COVERING) {
_enteredCovering = YES;
}
if (reason == ContextualSearch::SERP_NAVIGATION) {
_usedSERPNavigation = YES;
}
if (endingSearch) {
_enteredPreviewing = NO;
_enteredCovering = NO;
_resultsVisible = NO;
_exitedPeeking = NO;
_exitedPreviewing = NO;
_exitedCovering = NO;
_searchInvolvedFirstRun = NO;
_firstRunPanelBecameVisible = NO;
_searchTermResolved = NO;
_usedSERPNavigation = NO;
}
}
- (void)
dismissPaneWithJavascriptCompletionHandler:(ProceduralBlock)completionHandler
reason:(ContextualSearch::StateChangeReason)
reason {
[self cleanUpWebStateForDismissWithCompletion:completionHandler];
[self setState:ContextualSearch::DISMISSED reason:reason];
}
- (void)cleanUpWebStateForDismissWithCompletion:
(ProceduralBlock)completionHandler {
_lastDismiss = [NSDate date];
_currentTapCancelled = YES;
ContextualSearch::PanelState originalState = [_panelView state];
if (originalState == ContextualSearch::DISMISSED) {
DCHECK(![_searchResultsView active]);
if ([self webState]) {
DOMAlteringLock* lock = DOMAlteringLock::FromWebState([self webState]);
if (lock) {
lock->Release(self);
}
}
if (completionHandler)
completionHandler();
return;
}
[_doubleTapRecognizer setEnabled:YES];
_searchContext.reset();
[_searchResultsView setActive:NO];
_delegate->CancelSearchTermRequest();
_selectedText = "";
ContextualSearchDelegate::SearchResolution blank;
_resolvedSearch = blank;
if (completionHandler) {
__weak ContextualSearchController* weakSelf = self;
ProceduralBlock javaScriptCompletion = ^{
if ([weakSelf webState]) {
DOMAlteringLock::FromWebState([weakSelf webState])->Release(weakSelf);
completionHandler();
}
};
[self highlightRects:nil];
[_contextualSearchJsManager clearHighlight];
javaScriptCompletion();
} else {
[self highlightRects:nil];
[_contextualSearchJsManager clearHighlight];
DOMAlteringLock::FromWebState([self webState])->Release(self);
}
_preventActionMenu = NO;
// If the tapped word was at the bottom of the webview, and it was scrolled
// up to be displayed over the pane, scroll it back down now.
// (Ideally this "overscrolling" should just happen as the pane moves).
// TODO(crbug.com/546227): Handle this with a constraint.
CGPoint contentOffset = [[_webViewProxy scrollViewProxy] contentOffset];
CGSize contentSize = [[_webViewProxy scrollViewProxy] contentSize];
CGSize viewSize = [[_webViewProxy scrollViewProxy] frame].size;
CGFloat maxOffset = contentSize.height - viewSize.height;
if (contentOffset.y > maxOffset) {
contentOffset.y = maxOffset;
[[_webViewProxy scrollViewProxy] setContentOffset:contentOffset
animated:YES];
}
}
- (void)dismissPane:(ContextualSearch::StateChangeReason)reason {
[self dismissPaneWithJavascriptCompletionHandler:nil reason:reason];
}
- (void)peekPane:(ContextualSearch::StateChangeReason)reason {
[self setState:ContextualSearch::PEEKING reason:reason];
[_doubleTapRecognizer setEnabled:NO];
[self scrollToShowSelection:[_webViewProxy scrollViewProxy]];
}
- (void)previewPane:(ContextualSearch::StateChangeReason)reason {
if (_searchInvolvedFirstRun) {
_firstRunPanelBecameVisible = YES;
}
[self setState:ContextualSearch::PREVIEWING reason:reason];
[_doubleTapRecognizer setEnabled:NO];
[self scrollToShowSelection:[_webViewProxy scrollViewProxy]];
_delegate->StartPendingSearchTermRequest();
}
- (void)coverPane:(ContextualSearch::StateChangeReason)reason {
[self setState:ContextualSearch::COVERING reason:reason];
}
- (void)movePanelOffscreen {
[self dismissPane:ContextualSearch::RESET];
}
#pragma mark - ContextualSearchPromoViewDelegate methods
- (void)promoViewAcceptTapped {
[self userOptedInFromPromo:YES];
}
- (void)promoViewDeclineTapped {
[self userOptedInFromPromo:NO];
}
- (void)promoViewSettingsTapped {
GenericChromeCommand* command = [[GenericChromeCommand alloc]
initWithTag:IDC_SHOW_CONTEXTUAL_SEARCH_SETTINGS];
UIWindow* main_window = [[UIApplication sharedApplication] keyWindow];
[main_window chromeExecuteCommand:command];
}
#pragma mark - ContextualSearchWebStateObserver methods
- (void)webState:(web::WebState*)webState
pageLoadedWithStatus:(web::PageLoadCompletionStatus)loadStatus {
if (loadStatus != web::PageLoadCompletionStatus::SUCCESS)
return;
[self movePanelOffscreen];
_isScriptInjected = NO;
[self enableCurrentWebState];
}
- (void)webStateDestroyed:(web::WebState*)webState {
[self updateWebViewProxy:nil];
}
#pragma mark - UIGestureRecognizerDelegate Methods
// Ensures that |_tapRecognizer| and |_doubleTapRecognizer| cooperate with all
// other gesture recognizers.
- (BOOL)gestureRecognizer:(UIGestureRecognizer*)gestureRecognizer
shouldRecognizeSimultaneouslyWithGestureRecognizer:
(UIGestureRecognizer*)otherGestureRecognizer {
return gestureRecognizer == _tapRecognizer ||
gestureRecognizer == _doubleTapRecognizer;
}
#pragma mark - CRWWebViewScrollViewObserver methods
- (void)webViewScrollViewWillBeginDragging:
(CRWWebViewScrollViewProxy*)webViewScrollViewProxy {
[self dismissPane:ContextualSearch::BASE_PAGE_SCROLL];
[_tapRecognizer setEnabled:NO];
}
- (void)webViewScrollViewDidEndDragging:
(CRWWebViewScrollViewProxy*)webViewScrollViewProxy
willDecelerate:(BOOL)decelerate {
if (!decelerate)
[_tapRecognizer setEnabled:YES];
}
- (void)webViewScrollViewDidEndDecelerating:
(CRWWebViewScrollViewProxy*)webViewScrollViewProxy {
[_tapRecognizer setEnabled:YES];
}
- (void)webViewScrollViewDidScroll:
(CRWWebViewScrollViewProxy*)webViewScrollViewProxy {
_currentTapCancelled = YES;
[_contextualHighlightView
setScroll:[webViewScrollViewProxy contentOffset]
zoom:[webViewScrollViewProxy zoomScale]
offset:[_controllerDelegate currentHeaderHeight]];
}
- (void)webViewScrollViewDidZoom:
(CRWWebViewScrollViewProxy*)webViewScrollViewProxy {
_currentTapCancelled = YES;
[_contextualHighlightView
setScroll:[webViewScrollViewProxy contentOffset]
zoom:[webViewScrollViewProxy zoomScale]
offset:[_controllerDelegate currentHeaderHeight]];
[self scrollToShowSelection:webViewScrollViewProxy];
}
#pragma mark - DOMAltering methods
- (BOOL)canReleaseDOMLock {
return YES;
}
- (void)releaseDOMLockWithCompletionHandler:(ProceduralBlock)completionHandler {
[self dismissPaneWithJavascriptCompletionHandler:completionHandler
reason:ContextualSearch::RESET];
}
#pragma mark - TouchToSearchPermissionsChangeAudience methods
- (void)touchToSearchDidChangePreferenceState:
(TouchToSearch::TouchToSearchPreferenceState)preferenceState {
if (preferenceState != TouchToSearch::UNDECIDED) {
ContextualSearch::RecordPreferenceChanged(preferenceState ==
TouchToSearch::ENABLED);
}
}
- (void)touchToSearchPermissionsUpdated {
// This method is already invoked asynchronously, so it's safe to
// synchronously attempt to enable the feature.
[self enableContextualSearch:YES];
}
#pragma mark - ContextualSearchHighlighterDelegate methods
- (void)updateHighlight {
__weak ContextualSearchController* weakSelf = self;
[_contextualSearchJsManager
highlightRectsWithCompletionHandler:^void(id result, NSError* error) {
[weakSelf handleHighlightJSResult:result withError:error];
}];
}
@end