| // Copyright 2020 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/link_to_text/link_to_text_tab_helper.h" |
| |
| #import "base/bind.h" |
| #import "base/metrics/histogram_functions.h" |
| #import "base/optional.h" |
| #import "base/timer/elapsed_timer.h" |
| #import "base/values.h" |
| #import "components/shared_highlighting/core/common/disabled_sites.h" |
| #import "ios/chrome/browser/link_to_text/link_to_text_constants.h" |
| #import "ios/web/public/js_messaging/web_frame.h" |
| #import "ios/web/public/js_messaging/web_frames_manager.h" |
| #import "ios/web/public/ui/crw_web_view_proxy.h" |
| #import "ios/web/public/ui/crw_web_view_scroll_view_proxy.h" |
| #import "url/gurl.h" |
| |
| #if !defined(__has_feature) || !__has_feature(objc_arc) |
| #error "This file requires ARC support." |
| #endif |
| |
| // Interface encapsulating the properties needed to check the contents of the |
| // selection and whether or not it is editable. |
| @protocol EditableTextInput <UITextInput> |
| - (BOOL)isEditable; |
| @end |
| |
| namespace { |
| const char kGetLinkToTextJavaScript[] = "linkToText.getLinkToText"; |
| |
| // Pattern to identify non-whitespace/punctuation characters. Mirrors the regex |
| // used in the JS lib to identify non-boundary characters. |
| NSString* const kNotBoundaryCharPattern = @"[^\\p{P}\\s]"; |
| |
| // Limit the search for a non-boundary char to the first 200 characters, |
| // to ensure this regex does not have performance impact. |
| const int kBoundaryCharSearchLimit = 200; |
| |
| // Corresponds to LinkToTextShouldOfferResult in enums.xml; used to log |
| // fine-grained behavior of ShouldOffer. |
| enum class ShouldOfferResult { |
| kSuccess = 0, |
| kBlockListed = 2, |
| kSelectionEmpty = 6, |
| kUserEditing = 7, |
| kTextInputNotFound = 8, |
| kPartialSuccess = 9, |
| |
| // Deprecated. Do not reuse, change, or remove these values. |
| kRejectedInJavaScript = 1, |
| kUnableToInvokeJavaScript = 3, |
| kWebLayerTaskTimeout = 4, |
| kDispatchedTimeout = 5, |
| |
| kMaxValue = kPartialSuccess |
| }; |
| |
| void LogShouldOfferResult(ShouldOfferResult result) { |
| base::UmaHistogramEnumeration("IOS.LinkToText.ShouldOfferResult", result); |
| } |
| |
| // Traverse subviews to find the one responsible for the text selection |
| // behavior (UITextInput). |
| UIView<EditableTextInput>* FindInput(UIView* root) { |
| if ([root conformsToProtocol:@protocol(UITextInput)] && |
| [root respondsToSelector:@selector(isEditable)]) { |
| return (UIView<EditableTextInput>*)root; |
| } |
| for (UIView* view in [root subviews]) { |
| auto* maybe_input = FindInput(view); |
| if (maybe_input) { |
| return maybe_input; |
| } |
| } |
| return nil; |
| } |
| |
| } // namespace |
| |
| LinkToTextTabHelper::LinkToTextTabHelper(web::WebState* web_state) |
| : web_state_(web_state), weak_ptr_factory_(this) { |
| web_state_->AddObserver(this); |
| } |
| |
| LinkToTextTabHelper::~LinkToTextTabHelper() {} |
| |
| // static |
| void LinkToTextTabHelper::CreateForWebState(web::WebState* web_state) { |
| DCHECK(web_state); |
| if (!FromWebState(web_state)) { |
| web_state->SetUserData( |
| UserDataKey(), base::WrapUnique(new LinkToTextTabHelper(web_state))); |
| } |
| } |
| |
| bool LinkToTextTabHelper::ShouldOffer() { |
| if (!shared_highlighting::ShouldOfferLinkToText( |
| web_state_->GetLastCommittedURL())) { |
| LogShouldOfferResult(ShouldOfferResult::kBlockListed); |
| return false; |
| } |
| |
| web::WebFrame* main_frame = |
| web_state_->GetWebFramesManager()->GetMainWebFrame(); |
| if (!web_state_->ContentIsHTML() || !main_frame || |
| !main_frame->CanCallJavaScriptFunction()) { |
| LogShouldOfferResult(ShouldOfferResult::kUnableToInvokeJavaScript); |
| return false; |
| } |
| |
| UIView<EditableTextInput>* textInputView = FindInput(web_state_->GetView()); |
| |
| if (!textInputView) { |
| LogShouldOfferResult(ShouldOfferResult::kTextInputNotFound); |
| NOTREACHED(); |
| return false; |
| } |
| |
| if ([textInputView isEditable]) { |
| LogShouldOfferResult(ShouldOfferResult::kUserEditing); |
| return false; |
| } |
| |
| NSString* selection = |
| [textInputView textInRange:[textInputView selectedTextRange]]; |
| |
| if (!selection) { |
| // A bug on older versions can cause selection to be nil. In this case, we |
| // offer the feature even though it might just be whitespace. |
| LogShouldOfferResult(ShouldOfferResult::kPartialSuccess); |
| return true; |
| } |
| |
| if (IsOnlyBoundaryChars(selection)) { |
| LogShouldOfferResult(ShouldOfferResult::kSelectionEmpty); |
| return false; |
| } |
| |
| LogShouldOfferResult(ShouldOfferResult::kSuccess); |
| return true; |
| } |
| |
| void LinkToTextTabHelper::GetLinkToText(LinkToTextCallback callback) { |
| link_generation_timer_ = std::make_unique<base::ElapsedTimer>(); |
| |
| base::WeakPtr<LinkToTextTabHelper> weak_ptr = weak_ptr_factory_.GetWeakPtr(); |
| web_state_->GetWebFramesManager()->GetMainWebFrame()->CallJavaScriptFunction( |
| kGetLinkToTextJavaScript, {}, |
| base::BindOnce(^(const base::Value* response) { |
| if (weak_ptr) { |
| weak_ptr->OnJavaScriptResponseReceived(callback, response); |
| } |
| }), |
| base::TimeDelta::FromMilliseconds( |
| link_to_text::kLinkGenerationTimeoutInMs)); |
| } |
| |
| void LinkToTextTabHelper::OnJavaScriptResponseReceived( |
| LinkToTextCallback callback, |
| const base::Value* response) { |
| if (callback) { |
| base::TimeDelta latency; |
| if (link_generation_timer_) { |
| // Compute latency. |
| latency = link_generation_timer_->Elapsed(); |
| |
| // Reset variable. |
| link_generation_timer_.reset(); |
| } |
| |
| callback([LinkToTextResponse linkToTextResponseWithValue:response |
| webState:web_state_ |
| latency:latency]); |
| } |
| } |
| |
| bool LinkToTextTabHelper::IsOnlyBoundaryChars(NSString* str) { |
| if (!not_boundary_char_regex_) { |
| NSError* error = nil; |
| not_boundary_char_regex_ = [NSRegularExpression |
| regularExpressionWithPattern:kNotBoundaryCharPattern |
| options:0 |
| error:&error]; |
| if (error) { |
| // We should never get an error from compiling the regex, since it's a |
| // literal. |
| NOTREACHED(); |
| return true; |
| } |
| } |
| int max_len = MIN(kBoundaryCharSearchLimit, [str length]); |
| auto range = [not_boundary_char_regex_ |
| rangeOfFirstMatchInString:str |
| options:0 |
| range:NSMakeRange(0, max_len)]; |
| return range.location == NSNotFound; |
| } |
| |
| void LinkToTextTabHelper::WebStateDestroyed(web::WebState* web_state) { |
| DCHECK_EQ(web_state_, web_state); |
| |
| web_state_->RemoveObserver(this); |
| web_state_ = nil; |
| |
| // The call to RemoveUserData cause the destruction of the current instance, |
| // so nothing should be done after that point (this is like "delete this;"). |
| web_state->RemoveUserData(UserDataKey()); |
| } |
| |
| WEB_STATE_USER_DATA_KEY_IMPL(LinkToTextTabHelper) |