// Copyright 2020 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/link_to_text/model/link_to_text_tab_helper.h"
#import "base/functional/bind.h"
#import "base/metrics/histogram_functions.h"
#import "base/values.h"
#import "components/shared_highlighting/core/common/disabled_sites.h"
#import "ios/chrome/browser/link_to_text/model/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"
// 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 {
// 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,
kUnableToInvokeJavaScript = 3,
kSelectionEmpty = 6,
kUserEditing = 7,
kTextInputNotFound = 8,
kPartialSuccess = 9,
// Deprecated. Do not reuse, change, or remove these values.
kRejectedInJavaScript = 1,
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() {}
bool LinkToTextTabHelper::ShouldOffer() {
if (!shared_highlighting::ShouldOfferLinkToText(
web_state_->GetLastCommittedURL())) {
LogShouldOfferResult(ShouldOfferResult::kBlockListed);
return false;
}
web::WebFrame* main_frame =
web_state_->GetPageWorldWebFramesManager()->GetMainWebFrame();
if (!web_state_->ContentIsHTML() || !main_frame) {
LogShouldOfferResult(ShouldOfferResult::kUnableToInvokeJavaScript);
return false;
}
UIView<EditableTextInput>* textInputView = FindInput(web_state_->GetView());
if (!textInputView) {
LogShouldOfferResult(ShouldOfferResult::kTextInputNotFound);
DUMP_WILL_BE_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(
base::OnceCallback<void(LinkToTextResponse*)> callback) {
GetJSFeature()->GetLinkToText(web_state_, std::move(callback));
}
void LinkToTextTabHelper::SetJSFeatureForTesting(
LinkToTextJavaScriptFeature* js_feature) {
js_feature_for_testing_ = js_feature;
}
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_IN_MIGRATION();
return true;
}
}
int max_len = MIN(kBoundaryCharSearchLimit, [str length]);
NSRange range = [not_boundary_char_regex_
rangeOfFirstMatchInString:str
options:0
range:NSMakeRange(0, max_len)];
return range.location == NSNotFound;
}
LinkToTextJavaScriptFeature* LinkToTextTabHelper::GetJSFeature() {
return js_feature_for_testing_ ? js_feature_for_testing_.get()
: LinkToTextJavaScriptFeature::GetInstance();
}
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)