blob: ffa82c961fad7884a5daaee77367d2611d6beb8b [file] [log] [blame]
// Copyright 2017 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/web/web_state/ui/crw_context_menu_controller.h"
#import <objc/runtime.h>
#include <stddef.h>
#include "base/feature_list.h"
#include "base/ios/ios_util.h"
#include "base/logging.h"
#include "base/mac/foundation_util.h"
#include "base/metrics/histogram_macros.h"
#include "base/strings/sys_string_conversions.h"
#include "base/unguessable_token.h"
#include "ios/web/public/features.h"
#import "ios/web/public/web_state/context_menu_params.h"
#import "ios/web/public/web_state/js/crw_js_injection_evaluator.h"
#import "ios/web/public/web_state/ui/crw_context_menu_delegate.h"
#import "ios/web/web_state/context_menu_constants.h"
#import "ios/web/web_state/context_menu_params_utils.h"
#import "ios/web/web_state/ui/crw_wk_script_message_router.h"
#import "ios/web/web_state/ui/html_element_fetch_request.h"
#import "ios/web/web_state/ui/wk_web_view_configuration_provider.h"
#if !defined(__has_feature) || !__has_feature(objc_arc)
#error "This file requires ARC support."
#endif
namespace {
// The long press detection duration must be shorter than the WKWebView's
// long click gesture recognizer's minimum duration. That is 0.55s.
// If our detection duration is shorter, our gesture recognizer will fire
// first in order to cancel the system context menu gesture recognizer.
const NSTimeInterval kLongPressDurationSeconds = 0.55 - 0.1;
// If there is a movement bigger than |kLongPressMoveDeltaPixels|, the context
// menu will not be triggered.
const CGFloat kLongPressMoveDeltaPixels = 10.0;
// Cancels touch events for the given gesture recognizer.
void CancelTouches(UIGestureRecognizer* gesture_recognizer) {
if (gesture_recognizer.enabled) {
gesture_recognizer.enabled = NO;
gesture_recognizer.enabled = YES;
}
}
// JavaScript message handler name installed in WKWebView for found element
// response.
NSString* const kFindElementResultHandlerName = @"FindElementResultHandler";
} // namespace
@interface CRWContextMenuController ()<UIGestureRecognizerDelegate>
// The |webView|.
@property(nonatomic, readonly, weak) WKWebView* webView;
// The delegate that allow execute javascript.
@property(nonatomic, readonly, weak) id<CRWJSInjectionEvaluator>
injectionEvaluator;
// The scroll view of |webView|.
@property(nonatomic, readonly, weak) id<CRWContextMenuDelegate> delegate;
// Returns the x, y offset the content has been scrolled.
@property(nonatomic, readonly) CGPoint scrollPosition;
// Returns a gesture recognizers with |fragment| in it's description.
- (UIGestureRecognizer*)gestureRecognizerWithDescriptionFragment:
(NSString*)fragment;
// Called when the |_contextMenuRecognizer| finishes recognizing a long press.
- (void)longPressDetectedByGestureRecognizer:
(UIGestureRecognizer*)gestureRecognizer;
// Called when the |_contextMenuRecognizer| begins recognizing a long press.
- (void)longPressGestureRecognizerBegan;
// Called when the |_contextMenuRecognizer| changes.
- (void)longPressGestureRecognizerChanged;
// Called when the context menu must be shown.
- (void)showContextMenu;
// Cancels all touch events in the web view (long presses, tapping, scrolling).
- (void)cancelAllTouches;
// Asynchronously fetches information about DOM element for the given point (in
// UIView coordinates). |handler| can not be nil. See |_DOMElementForLastTouch|
// for element format description.
- (void)fetchDOMElementAtPoint:(CGPoint)point
completionHandler:(void (^)(NSDictionary*))handler;
// Sets the value of |_DOMElementForLastTouch|.
- (void)setDOMElementForLastTouch:(NSDictionary*)element;
// Called to process a message received from JavaScript.
- (void)didReceiveScriptMessage:(WKScriptMessage*)message;
// Logs the time taken to fetch DOM element details.
- (void)logElementFetchDurationWithStartTime:
(base::TimeTicks)elementFetchStartTime;
// Cancels the display of the context menu and clears associated element fetch
// request state.
- (void)cancelContextMenuDisplay;
// Forwards the execution of |script| to |javaScriptDelegate| and if it is nil,
// to |webView|.
- (void)executeJavaScript:(NSString*)script
completionHandler:(web::JavaScriptResultBlock)completionHandler;
@end
@implementation CRWContextMenuController {
// Long press recognizer that allows showing context menus.
UILongPressGestureRecognizer* _contextMenuRecognizer;
// DOM element information for the point where the user made the last touch.
// Can be nil if has not been calculated yet. Precalculation is necessary
// because retreiving DOM element relies on async API so element info can not
// be built on demand. May contain the keys defined in
// ios/web/web_state/context_menu_constants.h. All values are strings.
NSDictionary* _DOMElementForLastTouch;
// Whether or not the cotext menu should be displayed as soon as the DOM
// element details are returned. Since fetching the details from the |webView|
// of the element the user long pressed is asyncrounous, it may not be
// complete by the time the context menu gesture recognizer is complete.
// |_contextMenuNeedsDisplay| is set to YES to indicate the
// |_contextMenuRecognizer| finished, but couldn't yet show the context menu
// becuase the DOM element details were not yet available.
BOOL _contextMenuNeedsDisplay;
// The location of the last reconized long press in the |webView|.
CGPoint _locationForLastTouch;
// Details for currently in progress element fetches. The objects are
// instances of HTMLElementFetchRequest and are keyed by a unique requestID
// string.
NSMutableDictionary* _pendingElementFetchRequests;
}
@synthesize delegate = _delegate;
@synthesize injectionEvaluator = _injectionEvaluator;
@synthesize webView = _webView;
- (instancetype)initWithWebView:(WKWebView*)webView
browserState:(web::BrowserState*)browserState
injectionEvaluator:(id<CRWJSInjectionEvaluator>)injectionEvaluator
delegate:(id<CRWContextMenuDelegate>)delegate {
DCHECK(webView);
self = [super init];
if (self) {
_webView = webView;
_delegate = delegate;
_injectionEvaluator = injectionEvaluator;
_pendingElementFetchRequests = [[NSMutableDictionary alloc] init];
// The system context menu triggers after 0.55 second. Add a gesture
// recognizer with a shorter delay to be able to cancel the system menu if
// needed.
_contextMenuRecognizer = [[UILongPressGestureRecognizer alloc]
initWithTarget:self
action:@selector(longPressDetectedByGestureRecognizer:)];
[_contextMenuRecognizer setMinimumPressDuration:kLongPressDurationSeconds];
[_contextMenuRecognizer setAllowableMovement:kLongPressMoveDeltaPixels];
[_contextMenuRecognizer setDelegate:self];
[_webView addGestureRecognizer:_contextMenuRecognizer];
if (base::ios::IsRunningOnIOS11OrLater()) {
// WKWebView's default context menu gesture recognizer interferes with
// the detection of a long press by |_contextMenuRecognizer|. WKWebView's
// context menu gesture recognizer should fail if |_contextMenuRecognizer|
// detects a long press.
NSString* fragment = @"action=_longPressRecognized:";
UIGestureRecognizer* systemContextMenuRecognizer =
[self gestureRecognizerWithDescriptionFragment:fragment];
if (systemContextMenuRecognizer) {
[systemContextMenuRecognizer
requireGestureRecognizerToFail:_contextMenuRecognizer];
// requireGestureRecognizerToFail: doesn't retain the recognizer, so it
// is possible for |iRecognizer| to outlive |recognizer| and end up with
// a dangling pointer. Add a retaining associative reference to ensure
// that the lifetimes work out.
// Note that normally using the value as the key wouldn't make any
// sense, but here it's fine since nothing needs to look up the value.
void* associated_object_key = (__bridge void*)_contextMenuRecognizer;
objc_setAssociatedObject(systemContextMenuRecognizer.view,
associated_object_key, _contextMenuRecognizer,
OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
}
if (base::FeatureList::IsEnabled(
web::features::kContextMenuElementPostMessage)) {
// Listen for fetched element response.
web::WKWebViewConfigurationProvider& configurationProvider =
web::WKWebViewConfigurationProvider::FromBrowserState(browserState);
CRWWKScriptMessageRouter* messageRouter =
configurationProvider.GetScriptMessageRouter();
__weak CRWContextMenuController* weakSelf = self;
[messageRouter setScriptMessageHandler:^(WKScriptMessage* message) {
[weakSelf didReceiveScriptMessage:message];
}
name:kFindElementResultHandlerName
webView:webView];
}
}
return self;
}
- (void)allowSystemUIForCurrentGesture {
// Reset the state of the recognizer so that it doesn't recognize the on-going
// touch.
_contextMenuRecognizer.enabled = NO;
_contextMenuRecognizer.enabled = YES;
}
- (UIGestureRecognizer*)gestureRecognizerWithDescriptionFragment:
(NSString*)fragment {
for (UIView* view in [[_webView scrollView] subviews]) {
for (UIGestureRecognizer* recognizer in [view gestureRecognizers]) {
if ([recognizer.description rangeOfString:fragment].length) {
return recognizer;
}
}
}
return nil;
}
- (UIScrollView*)webScrollView {
return [_webView scrollView];
}
- (CGPoint)scrollPosition {
return self.webScrollView.contentOffset;
}
- (void)executeJavaScript:(NSString*)script
completionHandler:(web::JavaScriptResultBlock)completionHandler {
if (self.injectionEvaluator) {
[self.injectionEvaluator executeJavaScript:script
completionHandler:completionHandler];
} else {
[self.webView evaluateJavaScript:script
completionHandler:completionHandler];
}
}
- (void)longPressDetectedByGestureRecognizer:
(UIGestureRecognizer*)gestureRecognizer {
switch (gestureRecognizer.state) {
case UIGestureRecognizerStateBegan:
[self longPressGestureRecognizerBegan];
break;
case UIGestureRecognizerStateEnded:
[self cancelContextMenuDisplay];
break;
case UIGestureRecognizerStateChanged:
[self longPressGestureRecognizerChanged];
break;
default:
break;
}
}
- (void)longPressGestureRecognizerBegan {
if ([_DOMElementForLastTouch count]) {
// User long pressed on a link or an image. Cancelling all touches will
// intentionally suppress system context menu UI.
[self cancelAllTouches];
} else {
// There is no link or image under user's gesture. Do not cancel all touches
// to allow system text seletion UI.
}
if ([_delegate respondsToSelector:@selector(webView:handleContextMenu:)]) {
_locationForLastTouch = [_contextMenuRecognizer locationInView:_webView];
if ([_DOMElementForLastTouch count]) {
[self showContextMenu];
} else {
// Shows the context menu once the DOM element information is set.
_contextMenuNeedsDisplay = YES;
}
}
}
- (void)longPressGestureRecognizerChanged {
if (!_contextMenuNeedsDisplay ||
CGPointEqualToPoint(_locationForLastTouch, CGPointZero)) {
return;
}
// If the user moved more than kLongPressMoveDeltaPixels along either asis
// after the gesture was already recognized, the context menu should not be
// shown. The distance variation needs to be manually cecked if
// |_contextMenuNeedsDisplay| has already been set to True.
CGPoint currentTouchLocation =
[_contextMenuRecognizer locationInView:_webView];
float deltaX = std::abs(_locationForLastTouch.x - currentTouchLocation.x);
float deltaY = std::abs(_locationForLastTouch.y - currentTouchLocation.y);
if (deltaX > kLongPressMoveDeltaPixels ||
deltaY > kLongPressMoveDeltaPixels) {
[self cancelContextMenuDisplay];
}
}
- (void)showContextMenu {
web::ContextMenuParams params =
web::ContextMenuParamsFromElementDictionary(_DOMElementForLastTouch);
params.view = _webView;
params.location = _locationForLastTouch;
[_delegate webView:_webView handleContextMenu:params];
}
- (void)cancelAllTouches {
// Disable web view scrolling.
CancelTouches(self.webView.scrollView.panGestureRecognizer);
// All user gestures are handled by a subview of web view scroll view
// (WKContentView).
for (UIView* subview in self.webScrollView.subviews) {
for (UIGestureRecognizer* recognizer in subview.gestureRecognizers) {
CancelTouches(recognizer);
}
}
// Just disabling/enabling the gesture recognizers is not enough to suppress
// the click handlers on the JS side. This JS performs the function of
// suppressing these handlers on the JS side.
NSString* suppressNextClick = @"__gCrWeb.suppressNextClick()";
[self executeJavaScript:suppressNextClick completionHandler:nil];
}
- (void)setDOMElementForLastTouch:(NSDictionary*)element {
_DOMElementForLastTouch = [element copy];
if (_contextMenuNeedsDisplay) {
[self showContextMenu];
}
}
- (void)didReceiveScriptMessage:(WKScriptMessage*)message {
NSDictionary* response = message.body;
NSString* requestID = response[web::kContextMenuElementRequestID];
HTMLElementFetchRequest* fetchRequest =
_pendingElementFetchRequests[requestID];
// Do not process the message if a fetch request with a matching |requestID|
// was not found. This ensures that the response matches a request made by
// this CRWContextMenuController instance.
if (fetchRequest) {
[_pendingElementFetchRequests removeObjectForKey:requestID];
[self logElementFetchDurationWithStartTime:fetchRequest.creationTime];
[fetchRequest runHandlerWithResponse:response];
}
}
- (void)logElementFetchDurationWithStartTime:
(base::TimeTicks)elementFetchStartTime {
UMA_HISTOGRAM_TIMES("ContextMenu.DOMElementFetchDuration",
base::TimeTicks::Now() - elementFetchStartTime);
}
- (void)cancelContextMenuDisplay {
_contextMenuNeedsDisplay = NO;
[_pendingElementFetchRequests removeAllObjects];
}
#pragma mark -
#pragma mark UIGestureRecognizerDelegate
- (BOOL)gestureRecognizer:(UIGestureRecognizer*)gestureRecognizer
shouldRecognizeSimultaneouslyWithGestureRecognizer:
(UIGestureRecognizer*)otherGestureRecognizer {
// Allows the custom UILongPressGestureRecognizer to fire simultaneously with
// other recognizers.
return YES;
}
- (BOOL)gestureRecognizer:(UIGestureRecognizer*)gestureRecognizer
shouldReceiveTouch:(UITouch*)touch {
// Expect only _contextMenuRecognizer.
DCHECK([gestureRecognizer isEqual:_contextMenuRecognizer]);
// This is custom long press gesture recognizer. By the time the gesture is
// recognized the web controller needs to know if there is a link under the
// touch. If there a link, the web controller will reject system's context
// menu and show another one. If for some reason context menu info is not
// fetched - system context menu will be shown.
[self setDOMElementForLastTouch:nil];
[self cancelContextMenuDisplay];
__weak CRWContextMenuController* weakSelf = self;
[self fetchDOMElementAtPoint:[touch locationInView:_webView]
completionHandler:^(NSDictionary* element) {
[weakSelf setDOMElementForLastTouch:element];
}];
return YES;
}
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer*)gestureRecognizer {
// Expect only _contextMenuRecognizer.
DCHECK([gestureRecognizer isEqual:_contextMenuRecognizer]);
// Context menu should not be triggered while scrolling, as some users tend to
// stop scrolling by resting the finger on the screen instead of touching the
// screen. For more info, please refer to crbug.com/642375.
if ([self webScrollView].dragging) {
return NO;
}
return YES;
}
#pragma mark -
#pragma mark Web Page Features
- (void)fetchDOMElementAtPoint:(CGPoint)point
completionHandler:(void (^)(NSDictionary*))handler {
DCHECK(handler);
CGPoint scrollOffset = self.scrollPosition;
CGSize webViewContentSize = self.webScrollView.contentSize;
CGFloat webViewContentWidth = webViewContentSize.width;
CGFloat webViewContentHeight = webViewContentSize.height;
NSString* formatString;
web::JavaScriptResultBlock completionHandler = nil;
if (base::FeatureList::IsEnabled(
web::features::kContextMenuElementPostMessage)) {
NSString* requestID =
base::SysUTF8ToNSString(base::UnguessableToken::Create().ToString());
HTMLElementFetchRequest* fetchRequest =
[[HTMLElementFetchRequest alloc] initWithFoundElementHandler:handler];
_pendingElementFetchRequests[requestID] = fetchRequest;
formatString =
[NSString stringWithFormat:
@"__gCrWeb.findElementAtPoint('%@', %%g, %%g, %%g, %%g);",
requestID];
} else {
formatString = @"__gCrWeb.getElementFromPoint(%g, %g, %g, %g);";
base::TimeTicks getElementStartTime = base::TimeTicks::Now();
__weak CRWContextMenuController* weakSelf = self;
completionHandler = ^(id element, NSError* error) {
[weakSelf logElementFetchDurationWithStartTime:getElementStartTime];
if (error.code == WKErrorWebContentProcessTerminated ||
error.code == WKErrorWebViewInvalidated) {
// Renderer was terminated or view deallocated.
handler(nil);
} else {
handler(base::mac::ObjCCastStrict<NSDictionary>(element));
}
};
}
NSString* getElementScript =
[NSString stringWithFormat:formatString, point.x + scrollOffset.x,
point.y + scrollOffset.y, webViewContentWidth,
webViewContentHeight];
[self executeJavaScript:getElementScript completionHandler:completionHandler];
}
@end