chromium/ios/chrome/browser/browser_view/ui_bundled/key_commands_provider.mm

// Copyright 2016 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/browser_view/ui_bundled/key_commands_provider.h"

#import <objc/runtime.h>

#import "base/memory/weak_ptr.h"
#import "base/metrics/user_metrics.h"
#import "base/metrics/user_metrics_action.h"
#import "components/bookmarks/browser/bookmark_model.h"
#import "components/prefs/pref_service.h"
#import "components/sessions/core/tab_restore_service_helper.h"
#import "components/strings/grit/components_strings.h"
#import "ios/chrome/browser/bookmarks/model/bookmark_model_factory.h"
#import "ios/chrome/browser/find_in_page/model/abstract_find_tab_helper.h"
#import "ios/chrome/browser/keyboard/ui_bundled/UIKeyCommand+Chrome.h"
#import "ios/chrome/browser/ntp/model/new_tab_page_util.h"
#import "ios/chrome/browser/policy/model/policy_util.h"
#import "ios/chrome/browser/reading_list/model/reading_list_browser_agent.h"
#import "ios/chrome/browser/sessions/model/ios_chrome_tab_restore_service_factory.h"
#import "ios/chrome/browser/shared/coordinator/layout_guide/layout_guide_util.h"
#import "ios/chrome/browser/shared/model/browser/browser.h"
#import "ios/chrome/browser/shared/model/profile/profile_ios.h"
#import "ios/chrome/browser/shared/model/url/chrome_url_constants.h"
#import "ios/chrome/browser/shared/model/web_state_list/web_state_list.h"
#import "ios/chrome/browser/shared/public/commands/application_commands.h"
#import "ios/chrome/browser/shared/public/commands/bookmarks_commands.h"
#import "ios/chrome/browser/shared/public/commands/browser_coordinator_commands.h"
#import "ios/chrome/browser/shared/public/commands/find_in_page_commands.h"
#import "ios/chrome/browser/shared/public/commands/omnibox_commands.h"
#import "ios/chrome/browser/shared/public/commands/open_new_tab_command.h"
#import "ios/chrome/browser/shared/public/commands/quick_delete_commands.h"
#import "ios/chrome/browser/shared/public/commands/reading_list_add_command.h"
#import "ios/chrome/browser/shared/public/commands/settings_commands.h"
#import "ios/chrome/browser/shared/ui/util/keyboard_observer_helper.h"
#import "ios/chrome/browser/shared/ui/util/layout_guide_names.h"
#import "ios/chrome/browser/shared/ui/util/rtl_geometry.h"
#import "ios/chrome/browser/shared/ui/util/uikit_ui_util.h"
#import "ios/chrome/browser/shared/ui/util/url_with_title.h"
#import "ios/chrome/browser/shared/ui/util/util_swift.h"
#import "ios/chrome/browser/tabs/model/tab_title_util.h"
#import "ios/chrome/browser/ui/settings/clear_browsing_data/features.h"
#import "ios/chrome/browser/url_loading/model/url_loading_util.h"
#import "ios/chrome/browser/web/model/web_navigation_browser_agent.h"
#import "ios/chrome/browser/window_activities/model/window_activity_helpers.h"
#import "ios/chrome/grit/ios_strings.h"
#import "ios/public/provider/chrome/browser/user_feedback/user_feedback_api.h"
#import "ios/public/provider/chrome/browser/user_feedback/user_feedback_sender.h"
#import "ios/web/public/navigation/referrer.h"
#import "ios/web/public/web_state.h"
#import "ui/base/l10n/l10n_util_mac.h"
#import "url/gurl.h"

using base::RecordAction;
using base::UserMetricsAction;

@interface KeyCommandsProvider () {
  // The current browser object.
  base::WeakPtr<Browser> _browser;
}

// The view controller delegating key command actions handling.
@property(nonatomic, weak) UIViewController* viewController;

// Configures the responder following the receiver in the responder chain.
@property(nonatomic, weak) UIResponder* followingNextResponder;

// The current navigation agent.
@property(nonatomic, assign, readonly)
    WebNavigationBrowserAgent* navigationAgent;

// Whether the Find in Pageā€¦ UI is currently available.
@property(nonatomic, readonly, getter=isFindInPageAvailable)
    BOOL findInPageAvailable;

// The number of tabs displayed.
@property(nonatomic, readonly) NSUInteger tabsCount;

// Whether text is currently being edited.
@property(nonatomic, readonly, getter=isEditingText) BOOL editingText;

@end

@implementation KeyCommandsProvider

#pragma mark - Public

- (instancetype)initWithBrowser:(Browser*)browser {
  DCHECK(browser);
  self = [super init];
  if (self) {
    _browser = browser->AsWeakPtr();
  }
  return self;
}

- (void)respondBetweenViewController:(UIViewController*)viewController
                        andResponder:(UIResponder*)nextResponder {
  _viewController = viewController;
  _followingNextResponder = nextResponder;
}

#pragma mark - UIResponder

- (UIResponder*)nextResponder {
  return _followingNextResponder;
}

- (NSArray<UIKeyCommand*>*)keyCommands {
  // On iOS 15+, key commands visible in the app's menu are created in
  // MenuBuilder. Return the key commands that are not already present in the
  // menu.
  return @[
    UIKeyCommand.cr_openNewRegularTab,
    UIKeyCommand.cr_showNextTab_2,
    UIKeyCommand.cr_showPreviousTab_2,
    UIKeyCommand.cr_showNextTab_3,
    UIKeyCommand.cr_showPreviousTab_3,
    UIKeyCommand.cr_back_2,
    UIKeyCommand.cr_forward_2,
    UIKeyCommand.cr_showDownloads_2,
    UIKeyCommand.cr_select2,
    UIKeyCommand.cr_select3,
    UIKeyCommand.cr_select4,
    UIKeyCommand.cr_select5,
    UIKeyCommand.cr_select6,
    UIKeyCommand.cr_select7,
    UIKeyCommand.cr_select8,
    UIKeyCommand.cr_reportAnIssue_2,
  ];
}

- (BOOL)canPerformAction:(SEL)action withSender:(id)sender {
  // If the browser disappeared, prevent any command handling.
  if (!_browser) {
    return NO;
  }

  // BVC prevents KeyCommandsProvider from providing key commands when it has
  // `presentedViewController` set. But there is an interval between presenting
  // a view controller and having `presentedViewController` set. In that window,
  // KeyCommandsProvider can register key commands while it shouldn't.
  // To prevent actions from executing, check again if there is a
  // `presentedViewController`.
  if (_viewController.presentedViewController) {
    return NO;
  }

  if (sel_isEqual(action, @selector(keyCommand_back))) {
    BOOL canPerformBack =
        self.tabsCount > 0 && self.navigationAgent->CanGoBack();
    // Since cmd+left is a valid system shortcuts when editing text, register it
    // only if text is not being edited.
    if ([sender isEqual:UIKeyCommand.cr_back_2]) {
      return canPerformBack && !self.editingText;
    }
    return canPerformBack;
  }

  if (sel_isEqual(action, @selector(keyCommand_forward))) {
    BOOL canPerformForward =
        self.tabsCount > 0 && self.navigationAgent->CanGoForward();
    // Since cmd+right is a valid system shortcuts when editing text, register
    // it only if text is not being edited.
    if ([sender isEqual:UIKeyCommand.cr_forward_2]) {
      return canPerformForward && !self.editingText;
    }
    return canPerformForward;
  }
  if (sel_isEqual(action, @selector(keyCommand_showHistory))) {
    return !_browser->GetBrowserState()->IsOffTheRecord() && self.tabsCount > 0;
  }
  if (sel_isEqual(action, @selector(keyCommand_openLocation)) ||
      sel_isEqual(action, @selector(keyCommand_closeTab)) ||
      sel_isEqual(action, @selector(keyCommand_showBookmarks)) ||
      sel_isEqual(action, @selector(keyCommand_reload)) ||
      sel_isEqual(action, @selector(keyCommand_voiceSearch)) ||
      sel_isEqual(action, @selector(keyCommand_stop)) ||
      sel_isEqual(action, @selector(keyCommand_showHelp)) ||
      sel_isEqual(action, @selector(keyCommand_showDownloads)) ||
      sel_isEqual(action, @selector(keyCommand_select1)) ||
      sel_isEqual(action, @selector(keyCommand_select2)) ||
      sel_isEqual(action, @selector(keyCommand_select3)) ||
      sel_isEqual(action, @selector(keyCommand_select4)) ||
      sel_isEqual(action, @selector(keyCommand_select5)) ||
      sel_isEqual(action, @selector(keyCommand_select6)) ||
      sel_isEqual(action, @selector(keyCommand_select7)) ||
      sel_isEqual(action, @selector(keyCommand_select8)) ||
      sel_isEqual(action, @selector(keyCommand_select9)) ||
      sel_isEqual(action, @selector(keyCommand_showNextTab)) ||
      sel_isEqual(action, @selector(keyCommand_showPreviousTab))) {
    return self.tabsCount > 0;
  }
  if (sel_isEqual(action, @selector(keyCommand_find))) {
    return self.findInPageAvailable;
  }
  if (sel_isEqual(action, @selector(keyCommand_findNext)) ||
      sel_isEqual(action, @selector(keyCommand_findPrevious))) {
    return [self isFindInPageActive];
  }
  if (sel_isEqual(action, @selector(keyCommand_addToBookmarks)) ||
      sel_isEqual(action, @selector(keyCommand_addToReadingList))) {
    return [self isHTTPOrHTTPSPage];
  }
  if (sel_isEqual(action, @selector(keyCommand_reopenLastClosedTab))) {
    sessions::TabRestoreService* const tabRestoreService =
        IOSChromeTabRestoreServiceFactory::GetForBrowserState(
            _browser->GetBrowserState());
    return tabRestoreService && !tabRestoreService->entries().empty();
  }
  if (sel_isEqual(action, @selector(keyCommand_reportAnIssue))) {
    return ios::provider::IsUserFeedbackSupported();
  }
  if (sel_isEqual(action, @selector(keyCommand_openNewRegularTab))) {
    // Don't open regular tab if incognito is forced by policy.
    return !IsIncognitoModeForced(_browser->GetBrowserState()->GetPrefs());
  }
  if (sel_isEqual(action, @selector(keyCommand_openNewIncognitoTab))) {
    // Don't open incognito tab if incognito is disabled by policy.
    return !IsIncognitoModeDisabled(_browser->GetBrowserState()->GetPrefs());
  }
  if (sel_isEqual(action, @selector(keyCommand_clearBrowsingData))) {
    // Clear Browsing Data shouldn't be available in incognito mode.
    return !_browser->GetBrowserState()->IsOffTheRecord();
  }

  return [super canPerformAction:action withSender:sender];
}

// Changes the title to display the most appropriate string in the shortcut
// menu.
- (void)validateCommand:(UICommand*)command {
  if (command.action == @selector(keyCommand_find)) {
    command.discoverabilityTitle =
        l10n_util::GetNSStringWithFixup(IDS_IOS_KEYBOARD_FIND_IN_PAGE);
  }
  if (command.action == @selector(keyCommand_select1)) {
    command.discoverabilityTitle =
        l10n_util::GetNSStringWithFixup(IDS_IOS_KEYBOARD_FIRST_TAB);
  }
  if (command.action == @selector(keyCommand_addToBookmarks)) {
    if ([self isBookmarkedPage]) {
      command.discoverabilityTitle =
          l10n_util::GetNSStringWithFixup(IDS_IOS_KEYBOARD_EDIT_BOOKMARK);
    }
  }
  return [super validateCommand:command];
}

#pragma mark - Key Command Actions

- (void)keyCommand_openNewTab {
  RecordAction(UserMetricsAction("MobileKeyCommandOpenNewTab"));
  if (_browser->GetBrowserState()->IsOffTheRecord()) {
    [self openNewIncognitoTab];
  } else {
    [self openNewRegularTab];
  }
}

- (void)keyCommand_openNewRegularTab {
  RecordAction(UserMetricsAction("MobileKeyCommandOpenNewRegularTab"));
  [self openNewRegularTab];
}

- (void)keyCommand_openNewIncognitoTab {
  RecordAction(UserMetricsAction("MobileKeyCommandOpenNewIncognitoTab"));
  [self openNewIncognitoTab];
}

- (void)keyCommand_openNewWindow {
  RecordAction(UserMetricsAction("MobileKeyCommandOpenNewWindow"));
  [_applicationHandler
      openNewWindowWithActivity:ActivityToLoadURL(
                                    WindowActivityKeyCommandOrigin,
                                    GURL(kChromeUINewTabURL))];
}

- (void)keyCommand_openNewIncognitoWindow {
  RecordAction(UserMetricsAction("MobileKeyCommandOpenNewIncognitoWindow"));
  [_applicationHandler
      openNewWindowWithActivity:ActivityToLoadURL(
                                    WindowActivityKeyCommandOrigin,
                                    GURL(kChromeUINewTabURL), web::Referrer(),
                                    /* in_incognito */ true)];
}

- (void)keyCommand_reopenLastClosedTab {
  RecordAction(UserMetricsAction("MobileKeyCommandReopenLastClosedTab"));
  ChromeBrowserState* browserState = _browser->GetBrowserState();
  sessions::TabRestoreService* const tabRestoreService =
      IOSChromeTabRestoreServiceFactory::GetForBrowserState(browserState);
  if (!tabRestoreService || tabRestoreService->entries().empty()) {
    return;
  }

  const std::unique_ptr<sessions::tab_restore::Entry>& entry =
      tabRestoreService->entries().front();
  // Only handle the TAB type.
  // TODO(crbug.com/40676931) : Support WINDOW restoration under multi-window.
  if (entry->type != sessions::tab_restore::Type::TAB) {
    return;
  }

  [_applicationHandler openURLInNewTab:[OpenNewTabCommand command]];
  RestoreTab(entry->id, WindowOpenDisposition::CURRENT_TAB, _browser.get());
}

- (void)keyCommand_find {
  RecordAction(UserMetricsAction("MobileKeyCommandFind"));
  [_findInPageHandler openFindInPage];
}

- (void)keyCommand_findNext {
  RecordAction(UserMetricsAction("MobileKeyCommandFindNext"));
  [_findInPageHandler findNextStringInPage];
}

- (void)keyCommand_findPrevious {
  RecordAction(UserMetricsAction("MobileKeyCommandFindPrevious"));
  [_findInPageHandler findPreviousStringInPage];
}

- (void)keyCommand_openLocation {
  RecordAction(UserMetricsAction("MobileKeyCommandOpenLocation"));
  [_omniboxHandler focusOmnibox];
}

- (void)keyCommand_closeTab {
  RecordAction(UserMetricsAction("MobileKeyCommandCloseTab"));
  [_browserCoordinatorHandler closeCurrentTab];
}

- (void)keyCommand_showNextTab {
  RecordAction(UserMetricsAction("MobileKeyCommandShowNextTab"));
  WebStateList* webStateList = _browser->GetWebStateList();
  int activeIndex = webStateList->active_index();
  if (activeIndex == WebStateList::kInvalidIndex) {
    return;
  }

  // If the active index isn't the last index, activate the next index.
  // (the last index is always `count() - 1`).
  // Otherwise activate the first index.
  if (activeIndex < (webStateList->count() - 1)) {
    webStateList->ActivateWebStateAt(activeIndex + 1);
  } else {
    webStateList->ActivateWebStateAt(0);
  }
}

- (void)keyCommand_showPreviousTab {
  RecordAction(UserMetricsAction("MobileKeyCommandShowPreviousTab"));
  WebStateList* webStateList = _browser->GetWebStateList();
  int activeIndex = webStateList->active_index();
  if (activeIndex == WebStateList::kInvalidIndex) {
    return;
  }

  // If the active index isn't the first index, activate the prior index.
  // Otherwise index the last index (`count() - 1`).
  if (activeIndex > 0) {
    webStateList->ActivateWebStateAt(activeIndex - 1);
  } else {
    webStateList->ActivateWebStateAt(webStateList->count() - 1);
  }
}

- (void)keyCommand_showBookmarks {
  RecordAction(UserMetricsAction("MobileKeyCommandShowBookmarks"));
  [_browserCoordinatorHandler showBookmarksManager];
}

- (void)keyCommand_addToBookmarks {
  RecordAction(UserMetricsAction("MobileKeyCommandAddToBookmarks"));
  web::WebState* currentWebState =
      _browser->GetWebStateList()->GetActiveWebState();
  if (!currentWebState) {
    return;
  }
  GURL URL = currentWebState->GetLastCommittedURL();
  if (!URL.is_valid()) {
    return;
  }

  NSString* title = tab_util::GetTabTitle(currentWebState);
  [_bookmarksHandler
      createOrEditBookmarkWithURL:[[URLWithTitle alloc] initWithURL:URL
                                                              title:title]];
}

- (void)keyCommand_reload {
  RecordAction(UserMetricsAction("MobileKeyCommandReload"));
  self.navigationAgent->Reload();
}

- (void)keyCommand_back {
  RecordAction(UserMetricsAction("MobileKeyCommandBack"));
  if (self.navigationAgent->CanGoBack()) {
    self.navigationAgent->GoBack();
  }
}

- (void)keyCommand_forward {
  RecordAction(UserMetricsAction("MobileKeyCommandForward"));
  if (self.navigationAgent->CanGoForward()) {
    self.navigationAgent->GoForward();
  }
}

- (void)keyCommand_showHistory {
  RecordAction(UserMetricsAction("MobileKeyCommandShowHistory"));
  [_applicationHandler showHistory];
}

- (void)keyCommand_voiceSearch {
  RecordAction(UserMetricsAction("MobileKeyCommandVoiceSearch"));
  [LayoutGuideCenterForBrowser(_browser.get())
      referenceView:nil
          underName:kVoiceSearchButtonGuide];
  [_applicationHandler startVoiceSearch];
}

- (void)keyCommand_showSettings {
  RecordAction(UserMetricsAction("MobileKeyCommandShowSettings"));
  [_applicationHandler showSettingsFromViewController:_viewController];
}

- (void)keyCommand_stop {
  RecordAction(UserMetricsAction("MobileKeyCommandStop"));
  self.navigationAgent->StopLoading();
}

- (void)keyCommand_showHelp {
  RecordAction(UserMetricsAction("MobileKeyCommandShowHelp"));
  [_browserCoordinatorHandler showHelpPage];
}

- (void)keyCommand_showDownloads {
  RecordAction(UserMetricsAction("MobileKeyCommandShowDownloads"));
  [_browserCoordinatorHandler showDownloadsFolder];
}

- (void)keyCommand_select1 {
  RecordAction(UserMetricsAction("MobileKeyCommandShowFirstTab"));
  [self showTabAtIndex:0];
}

- (void)keyCommand_select2 {
  RecordAction(UserMetricsAction("MobileKeyCommandShowTab2"));
  [self showTabAtIndex:1];
}

- (void)keyCommand_select3 {
  RecordAction(UserMetricsAction("MobileKeyCommandShowTab3"));
  [self showTabAtIndex:2];
}

- (void)keyCommand_select4 {
  RecordAction(UserMetricsAction("MobileKeyCommandShowTab4"));
  [self showTabAtIndex:3];
}

- (void)keyCommand_select5 {
  RecordAction(UserMetricsAction("MobileKeyCommandShowTab5"));
  [self showTabAtIndex:4];
}

- (void)keyCommand_select6 {
  RecordAction(UserMetricsAction("MobileKeyCommandShowTab6"));
  [self showTabAtIndex:5];
}

- (void)keyCommand_select7 {
  RecordAction(UserMetricsAction("MobileKeyCommandShowTab7"));
  [self showTabAtIndex:6];
}

- (void)keyCommand_select8 {
  RecordAction(UserMetricsAction("MobileKeyCommandShowTab8"));
  [self showTabAtIndex:7];
}

- (void)keyCommand_select9 {
  RecordAction(UserMetricsAction("MobileKeyCommandShowLastTab"));
  [self showTabAtIndex:self.tabsCount - 1];
}

- (void)keyCommand_reportAnIssue {
  RecordAction(UserMetricsAction("MobileKeyCommandReportAnIssue"));
  [_applicationHandler
      showReportAnIssueFromViewController:_viewController
                                   sender:UserFeedbackSender::KeyCommand];
}

- (void)keyCommand_addToReadingList {
  RecordAction(UserMetricsAction("MobileKeyCommandAddToReadingList"));
  web::WebState* currentWebState =
      _browser->GetWebStateList()->GetActiveWebState();
  if (!currentWebState) {
    return;
  }
  GURL URL = currentWebState->GetLastCommittedURL();
  if (!URL.SchemeIsHTTPOrHTTPS()) {
    return;
  }

  NSString* title = tab_util::GetTabTitle(currentWebState);
  ReadingListAddCommand* command =
      [[ReadingListAddCommand alloc] initWithURL:URL title:title];
  ReadingListBrowserAgent* readingListBrowserAgent =
      ReadingListBrowserAgent::FromBrowser(_browser.get());
  readingListBrowserAgent->AddURLsToReadingList(command.URLs);
}

- (void)keyCommand_showReadingList {
  RecordAction(UserMetricsAction("MobileKeyCommandShowReadingList"));
  [_browserCoordinatorHandler showReadingList];
}

- (void)keyCommand_goToTabGrid {
  RecordAction(UserMetricsAction("MobileKeyCommandGoToTabGrid"));
  [_applicationHandler prepareTabSwitcher];
  [_applicationHandler displayTabGridInMode:TabGridOpeningMode::kDefault];
}

- (void)keyCommand_clearBrowsingData {
  RecordAction(UserMetricsAction("MobileKeyCommandClearBrowsingData"));
  if (IsIosQuickDeleteEnabled()) {
    [_quickDeleteHandler showQuickDeleteAndCanPerformTabsClosureAnimation:YES];
  } else {
    [_settingsHandler showClearBrowsingDataSettings];
  }
}

#pragma mark - Private

- (WebNavigationBrowserAgent*)navigationAgent {
  return WebNavigationBrowserAgent::FromBrowser(_browser.get());
}

- (BOOL)isFindInPageAvailable {
  web::WebState* currentWebState =
      _browser->GetWebStateList()->GetActiveWebState();
  if (!currentWebState) {
    return NO;
  }

  auto* helper = GetConcreteFindTabHelperFromWebState(currentWebState);
  return (helper && helper->CurrentPageSupportsFindInPage());
}

- (BOOL)isFindInPageActive {
  web::WebState* currentWebState =
      _browser->GetWebStateList()->GetActiveWebState();
  if (!currentWebState) {
    return NO;
  }

  auto* helper = GetConcreteFindTabHelperFromWebState(currentWebState);
  return (helper && helper->IsFindUIActive());
}

- (NSUInteger)tabsCount {
  return _browser->GetWebStateList()->count();
}

- (BOOL)isEditingText {
  UIResponder* firstResponder = GetFirstResponder();
  return [firstResponder isKindOfClass:[UITextField class]] ||
         [firstResponder isKindOfClass:[UITextView class]] ||
         [[KeyboardObserverHelper sharedKeyboardObserver] isKeyboardVisible];
}

- (void)openNewRegularTab {
  OpenNewTabCommand* newTabCommand = [OpenNewTabCommand command];
  newTabCommand.shouldFocusOmnibox = YES;
  [_applicationHandler openURLInNewTab:newTabCommand];
}

- (void)openNewIncognitoTab {
  OpenNewTabCommand* newIncognitoTabCommand =
      [OpenNewTabCommand incognitoTabCommand];
  newIncognitoTabCommand.shouldFocusOmnibox = YES;
  [_applicationHandler openURLInNewTab:newIncognitoTabCommand];
}

- (void)showTabAtIndex:(NSUInteger)index {
  WebStateList* webStateList = _browser->GetWebStateList();
  if (webStateList->ContainsIndex(index)) {
    webStateList->ActivateWebStateAt(static_cast<int>(index));
  }
}

- (BOOL)isHTTPOrHTTPSPage {
  web::WebState* currentWebState =
      _browser->GetWebStateList()->GetActiveWebState();
  if (!currentWebState) {
    return NO;
  }

  const GURL& url = currentWebState->GetLastCommittedURL();
  return url.is_valid() && url.SchemeIsHTTPOrHTTPS();
}

- (BOOL)isBookmarkedPage {
  web::WebState* currentWebState =
      _browser->GetWebStateList()->GetActiveWebState();
  if (!currentWebState) {
    return NO;
  }

  const GURL& url = currentWebState->GetLastCommittedURL();
  bookmarks::BookmarkModel* bookmarkModel =
      ios::BookmarkModelFactory::GetForBrowserState(
          _browser->GetBrowserState());
  return bookmarkModel->IsBookmarked(url);
}

@end