chromium/ios/chrome/browser/find_in_page/model/java_script_find_in_page_controller.mm

// Copyright 2012 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/find_in_page/model/java_script_find_in_page_controller.h"

#import <UIKit/UIKit.h>

#import <cmath>
#import <memory>

#import "base/apple/foundation_util.h"
#import "base/check_op.h"
#import "base/memory/raw_ptr.h"
#import "base/notreached.h"
#import "components/ukm/ios/ukm_url_recorder.h"
#import "ios/chrome/browser/find_in_page/model/constants.h"
#import "ios/chrome/browser/find_in_page/model/find_in_page_model.h"
#import "ios/chrome/browser/find_in_page/model/find_in_page_response_delegate.h"
#import "ios/web/public/find_in_page/find_in_page_manager_delegate_bridge.h"
#import "ios/web/public/find_in_page/java_script_find_in_page_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 "ios/web/public/web_state.h"
#import "services/metrics/public/cpp/ukm_builders.h"

namespace {
// Keeps find in page search term to be shared between different tabs. Never
// reset, not stored on disk.
static NSString* gSearchTerm;

// Accessibility announcement delay, so VoiceOver does not cancel the context
// string announcement when a new match has been selected.
// TODO(crbug.com/40249260): This is a temporary workaround. The context string
// announcement might still fail. A retry mechanism needs to be implemented.
const int64_t kContextStringAnnouncementDelayInNanoseconds = 0.1 * NSEC_PER_SEC;
}  // namespace

@interface JavaScriptFindInPageController () <CRWFindInPageManagerDelegate>

// The web view's scroll view.
- (CRWWebViewScrollViewProxy*)webViewScrollView;
// Find in Page text field listeners.
- (void)findBarTextFieldWillBecomeFirstResponder:(NSNotification*)note;
- (void)findBarTextFieldDidResignFirstResponder:(NSNotification*)note;
// Keyboard listeners.
- (void)keyboardDidShow:(NSNotification*)note;
- (void)keyboardWillHide:(NSNotification*)note;
// Records UKM metric for Find in Page search matches.
- (void)logFindInPageSearchUKM;
// Prevent scrolling past the end of the page.
- (CGPoint)limitOverscroll:(CRWWebViewScrollViewProxy*)scrollViewProxy
                   atPoint:(CGPoint)point;
@end

@implementation JavaScriptFindInPageController {
  // Object that manages searches and match traversals.
  raw_ptr<web::JavaScriptFindInPageManager> _findInPageManager;

  // Access to the web view from the web state.
  id<CRWWebViewProxy> _webViewProxy;

  // True when a find is in progress. Used to avoid running JavaScript during
  // disable when there is nothing to clear.
  BOOL _findStringStarted;

  // The WebState this instance is observing. Will be null after
  // -webStateDestroyed: has been called.
  raw_ptr<web::WebState> _webState;

  // Bridge to observe FindInPageManager from Objective-C.
  std::unique_ptr<web::FindInPageManagerDelegateBridge>
      _findInPageDelegateBridge;
}

@synthesize findInPageModel = _findInPageModel;

+ (void)setSearchTerm:(NSString*)string {
  gSearchTerm = [string copy];
}

+ (NSString*)searchTerm {
  return gSearchTerm;
}

- (id)initWithWebState:(web::WebState*)webState {
  self = [super init];
  if (self) {
    DCHECK(webState);
    DCHECK(webState->IsRealized());

    _webState = webState;
    _findInPageModel = [[FindInPageModel alloc] init];
    _findInPageDelegateBridge =
        std::make_unique<web::FindInPageManagerDelegateBridge>(self);
    _findInPageManager =
        web::JavaScriptFindInPageManager::FromWebState(_webState);
    _findInPageManager->SetDelegate(_findInPageDelegateBridge.get());

    _webViewProxy = _webState->GetWebViewProxy();
    [[NSNotificationCenter defaultCenter]
        addObserver:self
           selector:@selector(findBarTextFieldWillBecomeFirstResponder:)
               name:kFindBarTextFieldWillBecomeFirstResponderNotification
             object:nil];
    [[NSNotificationCenter defaultCenter]
        addObserver:self
           selector:@selector(findBarTextFieldDidResignFirstResponder:)
               name:kFindBarTextFieldDidResignFirstResponderNotification
             object:nil];
  }
  return self;
}

- (void)dealloc {
  DCHECK(!_webState) << "-detachFromWebState must be called before -dealloc";
}

- (BOOL)canFindInPage {
  return _findInPageManager->CanSearchContent();
}

- (CRWWebViewScrollViewProxy*)webViewScrollView {
  return [_webViewProxy scrollViewProxy];
}

- (CGPoint)limitOverscroll:(CRWWebViewScrollViewProxy*)scrollViewProxy
                   atPoint:(CGPoint)point {
  CGFloat contentHeight = scrollViewProxy.contentSize.height;
  CGFloat frameHeight = scrollViewProxy.frame.size.height;
  CGFloat maxScroll = std::max<CGFloat>(0, contentHeight - frameHeight);
  if (point.y > maxScroll) {
    point.y = maxScroll;
  }
  return point;
}

- (void)logFindInPageSearchUKM {
  ukm::SourceId sourceID = ukm::GetSourceIdForWebStateDocument(_webState);
  if (sourceID != ukm::kInvalidSourceId) {
    ukm::builders::IOS_FindInPageSearchMatches(sourceID)
        .SetHasMatches(_findInPageModel.matches > 0)
        .Record(ukm::UkmRecorder::Get());
  }
}

- (void)findStringInPage:(NSString*)query {
  // Keep track of whether a find is in progress so to avoid running
  // JavaScript during disable if unnecessary.
  _findStringStarted = YES;
  // Save the query in the model before searching. TODO:(crbug.com/963908):
  // Remove as part of refactoring.
  [self.findInPageModel updateQuery:query matches:0];
  _findInPageManager->Find(query, web::FindInPageOptions::FindInPageSearch);
}

- (void)findNextStringInPage {
  _findInPageManager->Find(nil, web::FindInPageOptions::FindInPageNext);
}

// Highlight the previous search match, update model and scroll to match.
- (void)findPreviousStringInPage {
  _findInPageManager->Find(nil, web::FindInPageOptions::FindInPagePrevious);
}

// Remove highlights from the page and disable the model.
- (void)disableFindInPage {
  if (![self canFindInPage]) {
    return;
  }

  // Only run FindInPageManager::StopFinding() if there is a string in progress
  // to avoid WKWebView crash on deallocation due to outstanding completion
  // handler.
  if (_findStringStarted) {
    _findInPageManager->StopFinding();
    _findStringStarted = NO;
  }
}

- (void)saveSearchTerm {
  [[self class] setSearchTerm:[[self findInPageModel] text]];
}

- (void)restoreSearchTerm {
  // Pasteboards always return nil in background:
  if ([[UIApplication sharedApplication] applicationState] !=
      UIApplicationStateActive) {
    return;
  }

  NSString* term = [[self class] searchTerm];
  [[self findInPageModel] updateQuery:(term ? term : @"") matches:0];
}

#pragma mark - CRWFindInPageManagerDelegate

- (void)findInPageManager:(web::AbstractFindInPageManager*)manager
    didHighlightMatchesOfQuery:(NSString*)query
                withMatchCount:(NSInteger)matchCount
                   forWebState:(web::WebState*)webState {
  if (matchCount == 0 && !query) {
    // StopFinding responds with `matchCount` as 0 and `query` as nil.
    [self.responseDelegate findDidStop];
    [self logFindInPageSearchUKM];
    return;
  }
  [self.findInPageModel updateQuery:query matches:matchCount];
  [self.responseDelegate findDidFinishWithUpdatedModel:self.findInPageModel];
}

- (void)findInPageManager:(web::AbstractFindInPageManager*)manager
    didSelectMatchAtIndex:(NSInteger)index
        withContextString:(NSString*)contextString
              forWebState:(web::WebState*)webState {
  if (contextString) {
    // TODO(crbug.com/40249260): When tapping the Previous or Next button in the
    // Find Bar, VoiceOver will trigger the announcement of the title of the
    // button, usually a fraction of a second after this method is called. As a
    // result, the announcement triggered by the
    // `UIAccessibilityAnnouncementNotification` posted here will be interrupted
    // by the announcement of the button. Setting a delay on posting the context
    // string announcement notification yields the opposite result i.e. the
    // expected result: VoiceOver will not read "Previous" or "Next", but read
    // the new context string instead. This is a temporary workaround. The
    // context string announcement might still fail. Some kind of retry
    // mechanism needs to be implemented.
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
                                 kContextStringAnnouncementDelayInNanoseconds),
                   dispatch_get_main_queue(), ^{
                     UIAccessibilityPostNotification(
                         UIAccessibilityAnnouncementNotification,
                         contextString);
                   });
  }
  // Increment index so that match number show in FindBar ranges from 1...N as
  // opposed to 0...N-1.
  index++;
  [self.findInPageModel updateIndex:index atPoint:CGPointZero];
  [self.responseDelegate findDidFinishWithUpdatedModel:self.findInPageModel];
}

- (void)userDismissedFindNavigatorForManager:
    (web::AbstractFindInPageManager*)manager {
  // There should not be any Find navigator in JavaScript Find in Page.
  NOTREACHED_IN_MIGRATION();
}

#pragma mark - Notification listeners

- (void)findBarTextFieldWillBecomeFirstResponder:(NSNotification*)note {
  // Listen to the keyboard appearance notifications.
  NSNotificationCenter* defaultCenter = [NSNotificationCenter defaultCenter];
  [defaultCenter addObserver:self
                    selector:@selector(keyboardDidShow:)
                        name:UIKeyboardDidShowNotification
                      object:nil];
  [defaultCenter addObserver:self
                    selector:@selector(keyboardWillHide:)
                        name:UIKeyboardWillHideNotification
                      object:nil];
}

- (void)findBarTextFieldDidResignFirstResponder:(NSNotification*)note {
  // Resign from the keyboard appearance notifications on the next turn of the
  // runloop.
  dispatch_async(dispatch_get_main_queue(), ^{
    NSNotificationCenter* defaultCenter = [NSNotificationCenter defaultCenter];
    [defaultCenter removeObserver:self
                             name:UIKeyboardDidShowNotification
                           object:nil];
    [defaultCenter removeObserver:self
                             name:UIKeyboardWillHideNotification
                           object:nil];
  });
}

- (void)keyboardDidShow:(NSNotification*)note {
  NSDictionary* info = [note userInfo];
  CGSize kbSize =
      [[info objectForKey:UIKeyboardFrameEndUserInfoKey] CGRectValue].size;
  CGFloat kbHeight = kbSize.height;
  UIEdgeInsets insets = UIEdgeInsetsZero;
  insets.bottom = kbHeight;
  [_webViewProxy registerInsets:insets forCaller:self];
}

- (void)keyboardWillHide:(NSNotification*)note {
  [_webViewProxy unregisterInsetsForCaller:self];
}

- (void)detachFromWebState {
  _findInPageManager->SetDelegate(nullptr);
  _findInPageDelegateBridge.reset();
  _findInPageManager = nullptr;
  _webState = nullptr;
}

@end