chromium/ios/chrome/browser/ui/lens/lens_coordinator.mm

// Copyright 2022 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/ui/lens/lens_coordinator.h"

#import "base/strings/sys_string_conversions.h"
#import "components/feature_engagement/public/event_constants.h"
#import "components/feature_engagement/public/feature_constants.h"
#import "components/feature_engagement/public/tracker.h"
#import "components/lens/lens_metrics.h"
#import "components/prefs/pref_service.h"
#import "components/search_engines/template_url.h"
#import "components/search_engines/template_url_service.h"
#import "ios/chrome/browser/feature_engagement/model/tracker_factory.h"
#import "ios/chrome/browser/intents/intents_donation_helper.h"
#import "ios/chrome/browser/search_engines/model/template_url_service_factory.h"
#import "ios/chrome/browser/shared/model/application_context/application_context.h"
#import "ios/chrome/browser/shared/model/browser/browser.h"
#import "ios/chrome/browser/shared/model/prefs/pref_names.h"
#import "ios/chrome/browser/shared/model/profile/profile_ios.h"
#import "ios/chrome/browser/shared/model/url/url_util.h"
#import "ios/chrome/browser/shared/model/web_state_list/web_state_list.h"
#import "ios/chrome/browser/shared/model/web_state_list/web_state_list_observer_bridge.h"
#import "ios/chrome/browser/shared/public/commands/command_dispatcher.h"
#import "ios/chrome/browser/shared/public/commands/lens_commands.h"
#import "ios/chrome/browser/shared/public/commands/omnibox_commands.h"
#import "ios/chrome/browser/shared/public/commands/open_lens_input_selection_command.h"
#import "ios/chrome/browser/shared/public/commands/search_image_with_lens_command.h"
#import "ios/chrome/browser/shared/public/commands/toolbar_commands.h"
#import "ios/chrome/browser/shared/public/features/features.h"
#import "ios/chrome/browser/shared/ui/symbols/symbols.h"
#import "ios/chrome/browser/signin/model/authentication_service.h"
#import "ios/chrome/browser/signin/model/authentication_service_factory.h"
#import "ios/chrome/browser/ui/lens/features.h"
#import "ios/chrome/browser/ui/lens/lens_availability.h"
#import "ios/chrome/browser/ui/lens/lens_entrypoint.h"
#import "ios/chrome/browser/ui/lens/lens_modal_animator.h"
#import "ios/chrome/browser/url_loading/model/url_loading_browser_agent.h"
#import "ios/chrome/browser/url_loading/model/url_loading_params.h"
#import "ios/chrome/browser/web/model/web_navigation_util.h"
#import "ios/chrome/browser/web_state_list/model/web_state_dependency_installer_bridge.h"
#import "ios/chrome/common/app_group/app_group_constants.h"
#import "ios/chrome/grit/ios_strings.h"
#import "ios/public/provider/chrome/browser/lens/lens_api.h"
#import "ios/public/provider/chrome/browser/lens/lens_configuration.h"
#import "ios/web/public/navigation/navigation_manager.h"
#import "ios/web/public/web_state.h"
#import "ios/web/public/web_state_observer_bridge.h"
#import "net/base/apple/url_conversions.h"
#import "ui/base/device_form_factor.h"
#import "ui/base/l10n/l10n_util_mac.h"

using lens::CameraOpenEntryPoint;

namespace {

// Lens results web page loading progress threshold to transition from LVF to
// results page.
static const double kLensWebPageTransitionLoadingProgressThreshold = 0.5;

}  // namespace

@interface LensCoordinator () <ChromeLensControllerDelegate,
                               LensCommands,
                               CRWWebStateObserver,
                               WebStateListObserving>

// A controller that can provide an entrypoint into Lens features.
@property(nonatomic, strong) id<ChromeLensController> lensController;

// The Lens viewController.
@property(nonatomic, strong) UIViewController* viewController;

// The animator for dismissing the Lens view.
@property(nonatomic, strong) LensModalAnimator* transitionAnimator;

// Whether or not a Lens Web page load was triggered from the Lens UI.
@property(nonatomic, assign) BOOL lensWebPageLoadTriggeredFromInputSelection;

// The WebState that is loading a Lens results page, if any.
@property(nonatomic, assign) web::WebState* loadingWebState;

// TemplateURL used to get the search engine.
@property(nonatomic, assign) TemplateURLService* templateURLService;

// Feature Engagement Tracker used to handle promo events.
@property(nonatomic, assign) feature_engagement::Tracker* tracker;

@end

@implementation LensCoordinator {
  // Used to observe the active WebState.
  std::unique_ptr<web::WebStateObserverBridge> _webStateObserverBridge;
  std::unique_ptr<base::ScopedObservation<web::WebState, web::WebStateObserver>>
      _webStateObservation;

  // Used to observe the WebStateList.
  std::unique_ptr<WebStateListObserverBridge> _webStateListObserverBridge;
  std::unique_ptr<base::ScopedObservation<WebStateList, WebStateListObserver>>
      _webStateListObservation;
}
@synthesize baseViewController = _baseViewController;

// The timeout before the Lens UI is closed, if the Lens Web page
// fails to load.
const base::TimeDelta kCloseLensViewTimeout = base::Seconds(10);

#pragma mark - ChromeCoordinator

- (instancetype)initWithBrowser:(Browser*)browser {
  DCHECK(browser);
  self = [super initWithBaseViewController:nil browser:browser];
  if (self) {
    _webStateObserverBridge =
        std::make_unique<web::WebStateObserverBridge>(self);
    _webStateListObserverBridge =
        std::make_unique<WebStateListObserverBridge>(self);
  }
  return self;
}

- (void)start {
  [super start];

  Browser* browser = self.browser;
  DCHECK(browser);

  [browser->GetCommandDispatcher()
      startDispatchingToTarget:self
                   forProtocol:@protocol(LensCommands)];

  _webStateListObservation = std::make_unique<
      base::ScopedObservation<WebStateList, WebStateListObserver>>(
      _webStateListObserverBridge.get());

  _webStateObservation = std::make_unique<
      base::ScopedObservation<web::WebState, web::WebStateObserver>>(
      _webStateObserverBridge.get());

  ChromeBrowserState* browserState = browser->GetBrowserState();
  DCHECK(browserState);

  self.templateURLService =
      ios::TemplateURLServiceFactory::GetForBrowserState(browserState);
  self.tracker =
      feature_engagement::TrackerFactory::GetForBrowserState(browserState);
  self.loadingWebState = nil;
  self.lensWebPageLoadTriggeredFromInputSelection = NO;
  self.transitionAnimator = [[LensModalAnimator alloc] init];
  _webStateListObservation->Observe(browser->GetWebStateList());
  [self updateLensAvailabilityForWidgets];
  [self updateQRCodeOrLensAppShortcutItem];
}

- (void)stop {
  Browser* browser = self.browser;
  DCHECK(browser);

  [self dismissViewController];
  self.loadingWebState = nullptr;
  self.transitionAnimator = nil;
  self.lensWebPageLoadTriggeredFromInputSelection = NO;
  self.templateURLService = nil;
  self.tracker = nil;

  _webStateListObservation.reset();
  _webStateObservation.reset();

  [browser->GetCommandDispatcher() stopDispatchingToTarget:self];

  [super stop];
}

#pragma mark - Commands

- (void)searchImageWithLens:(SearchImageWithLensCommand*)command {
  const bool isIncognito = self.browser->GetBrowserState()->IsOffTheRecord();
  __weak LensCoordinator* weakSelf = self;

  LensQuery* lensQuery = [LensQuery alloc];
  lensQuery.image = command.image;
  lensQuery.isIncognito = isIncognito;
  lensQuery.entrypoint = command.entryPoint;
  lensQuery.webviewSize = [self webContentFrame].size;
  ios::provider::GenerateLensLoadParamsAsync(
      lensQuery,
      base::BindOnce(^(const web::NavigationManager::WebLoadParams params) {
        [weakSelf openWebLoadParams:params];
      }));
}

- (void)openLensInputSelection:(OpenLensInputSelectionCommand*)command {
  // Cancel any omnibox editing.
  Browser* browser = self.browser;
  CommandDispatcher* dispatcher = browser->GetCommandDispatcher();
  id<OmniboxCommands> omniboxCommandsHandler =
      HandlerForProtocol(dispatcher, OmniboxCommands);
  [omniboxCommandsHandler cancelOmniboxEdit];

  // Early return if Lens is not available.
  if (!ios::provider::IsLensSupported()) {
    return;
  }

  [IntentDonationHelper donateIntent:IntentType::kStartLens];

  // Create a Lens configuration for this request.
  const LensEntrypoint entrypoint = command.entryPoint;
  ChromeBrowserState* browserState = browser->GetBrowserState();
  const bool isIncognito = browserState->IsOffTheRecord();
  LensConfiguration* configuration = [[LensConfiguration alloc] init];
  configuration.isIncognito = isIncognito;
  configuration.singleSignOnService =
      GetApplicationContext()->GetSingleSignOnService();
  configuration.entrypoint = entrypoint;

  // Mark IPHs as completed.
  if (entrypoint == LensEntrypoint::Keyboard) {
    feature_engagement::Tracker* featureTracker = self.tracker;
    DCHECK(featureTracker);
    featureTracker->NotifyEvent(
        feature_engagement::events::kLensButtonKeyboardUsed);
    featureTracker->Dismissed(feature_engagement::kIPHiOSLensKeyboardFeature);
  } else if (entrypoint == LensEntrypoint::NewTabPage) {
    browserState->GetPrefs()->SetInteger(
        prefs::kNTPLensEntryPointNewBadgeShownCount, INT_MAX);
  }

  if (!isIncognito) {
    AuthenticationService* authenticationService =
        AuthenticationServiceFactory::GetForBrowserState(browserState);
    id<SystemIdentity> identity = authenticationService->GetPrimaryIdentity(
        ::signin::ConsentLevel::kSignin);
    configuration.identity = identity;
  }

  // Set the controller.
  id<ChromeLensController> lensController =
      ios::provider::NewChromeLensController(configuration);
  DCHECK(lensController);

  self.lensController = lensController;
  lensController.delegate = self;

  // Create an input selection UIViewController and present it modally.
  CGRect contentArea = [UIScreen mainScreen].bounds;

  id<LensPresentationDelegate> delegate = self.delegate;
  if (delegate) {
    contentArea = [delegate webContentAreaForLensCoordinator:self];
  }

  UIViewController* viewController =
      [lensController inputSelectionViewController];

  // TODO(crbug.com/40235185): the returned UIViewController
  // must not be nil, remove this check once the internal
  // implementation of the method is complete.
  if (!viewController) {
    return;
  }

  self.viewController = viewController;

  // Set the transitioning delegate of the view controller to customize
  // modal dismiss animations.
  const LensModalAnimator* transitionAnimator = self.transitionAnimator;
  DCHECK(transitionAnimator);
  transitionAnimator.presentationStyle = command.presentationStyle;
  transitionAnimator.presentationCompletion = command.presentationCompletion;
  [viewController setTransitioningDelegate:transitionAnimator];

  [viewController
      setModalPresentationStyle:UIModalPresentationOverCurrentContext];

  [self.baseViewController presentViewController:viewController
                                        animated:YES
                                      completion:nil];

  switch (entrypoint) {
    case LensEntrypoint::HomeScreenWidget:
      RecordCameraOpen(CameraOpenEntryPoint::WIDGET);
      break;
    case LensEntrypoint::NewTabPage:
      RecordCameraOpen(CameraOpenEntryPoint::NEW_TAB_PAGE);
      break;
    case LensEntrypoint::Keyboard:
      RecordCameraOpen(CameraOpenEntryPoint::KEYBOARD);
      break;
    case LensEntrypoint::Spotlight:
      RecordCameraOpen(CameraOpenEntryPoint::SPOTLIGHT);
      break;
    default:
      // Do not record the camera open histogram for other entry points.
      break;
  }
}

#pragma mark - ChromeLensControllerDelegate

- (void)lensControllerDidTapDismissButton {
  self.lensWebPageLoadTriggeredFromInputSelection = NO;
  web::WebState* loadingWebState = self.loadingWebState;
  // If there is a webstate loading Lens results underneath the Lens UI,
  // close it so we return the user to the initial state.
  if (loadingWebState) {
    const int index =
        self.browser->GetWebStateList()->GetIndexOfWebState(loadingWebState);
    self.loadingWebState = nil;
    if (index != WebStateList::kInvalidIndex) {
      self.browser->GetWebStateList()->CloseWebStateAt(
          index, WebStateList::CLOSE_USER_ACTION);
    }
  }

  [self dismissViewController];
}

- (void)lensControllerDidGenerateLoadParams:
    (const web::NavigationManager::WebLoadParams&)params {
  const __weak UIViewController* lensViewController = self.viewController;
  if (!lensViewController) {
    // If the coordinator view controller is nil, simply open the params and
    // return early.
    [self openWebLoadParams:params];
    return;
  }

  // Prepare the coordinator for dismissing the presented view controller.
  // Since we are opening Lens Web, mark the page load as triggered.
  self.lensWebPageLoadTriggeredFromInputSelection = YES;
  [self openWebLoadParams:params];

  // This function will be called when the user selects an image in Lens.
  // we should continue to display the Lens UI until the search results
  // for that image have loaded, or a timeout occurs.
  // Fallback to close the preview if the page never loads beneath.
  __weak LensCoordinator* weakSelf = self;
  base::SequencedTaskRunner::GetCurrentDefault()->PostDelayedTask(
      FROM_HERE, base::BindOnce(^{
        // Only dismiss the Lens view if the displayed view controller is the
        // same as the one that was displayed when the load params were
        // initially generated.
        if (weakSelf.viewController == lensViewController) {
          weakSelf.lensWebPageLoadTriggeredFromInputSelection = NO;
          [weakSelf dismissViewController];
        }
      }),
      kCloseLensViewTimeout);
}

- (void)lensControllerDidSelectURL:(NSURL*)url {
  // This method is called when the user selects a URL within the Lens UI
  // and should be treated as a link press.
  web::NavigationManager::WebLoadParams params =
      web_navigation_util::CreateWebLoadParams(
          net::GURLWithNSURL(url), ui::PAGE_TRANSITION_LINK, nullptr);
  [self openWebLoadParams:params];
  [self dismissViewController];
}

- (CGRect)webContentFrame {
  id<LensPresentationDelegate> delegate = self.delegate;
  if (delegate) {
    return [delegate webContentAreaForLensCoordinator:self];
  }

  return [UIScreen mainScreen].bounds;
}

#pragma mark - WebStateListObserving methods

- (void)didChangeWebStateList:(WebStateList*)webStateList
                       change:(const WebStateListChange&)change
                       status:(const WebStateListStatus&)status {
  if (status.active_web_state_change() &&
      self.lensWebPageLoadTriggeredFromInputSelection) {
    self.loadingWebState = status.new_active_web_state;
  }
}

#pragma mark - CRWWebStateObserver methods

- (void)webState:(web::WebState*)webState
    didChangeLoadingProgress:(double)progress {
  if (progress >= kLensWebPageTransitionLoadingProgressThreshold) {
    [self transitionToLensWebPageWithWebState:webState];
  }
}

- (void)webState:(web::WebState*)webState didLoadPageWithSuccess:(BOOL)success {
  [self transitionToLensWebPageWithWebState:webState];
}

// Triggers the dismissal of the Lens UI (LVF) and display of the Lens web page
// load.
- (void)transitionToLensWebPageWithWebState:(web::WebState*)webState {
  DCHECK_EQ(webState, self.loadingWebState);

  // Check if the Lens UI has not already been dismissed, loaded page is a Lens
  // Web page and we are expecting a Lens Web page load, dismiss the Lens UI.
  if (self.viewController && self.lensWebPageLoadTriggeredFromInputSelection &&
      ios::provider::IsLensWebResultsURL(webState->GetLastCommittedURL())) {
    self.lensWebPageLoadTriggeredFromInputSelection = NO;
    self.loadingWebState = nil;
    [self dismissViewController];

    // As this was a successful Lens Web results page load, trigger the toolbar
    // slide-in animation.
    CommandDispatcher* dispatcher = self.browser->GetCommandDispatcher();
    id<ToolbarCommands> toolbarCommandsHandler =
        HandlerForProtocol(dispatcher, ToolbarCommands);
    [toolbarCommandsHandler triggerToolbarSlideInAnimation];
  }
}

- (void)webStateDidStartLoading:(web::WebState*)webState {
  const id<ChromeLensController> lensController = self.lensController;
  if (self.lensWebPageLoadTriggeredFromInputSelection && lensController) {
    [lensController triggerSecondaryTransitionAnimation];
  }
}

- (void)webStateDestroyed:(web::WebState*)webState {
  DCHECK_EQ(webState, self.loadingWebState);
  self.loadingWebState = nil;
}

#pragma mark - Private

- (void)openWebLoadParams:(const web::NavigationManager::WebLoadParams&)params {
  if (!self.browser)
    return;
  web::WebState* webState =
      self.browser->GetWebStateList()->GetActiveWebState();
  UrlLoadParams loadParams;

  // Open in the current tab if the current tab is a NTP.
  if (webState && IsUrlNtp(webState->GetLastCommittedURL())) {
    loadParams = UrlLoadParams::InCurrentTab(params);
    self.loadingWebState = webState;
  } else {
    loadParams = UrlLoadParams::InNewTab(params);
    loadParams.append_to = OpenPosition::kCurrentTab;
    loadParams.SetInBackground(NO);
  }
  loadParams.in_incognito = self.browser->GetBrowserState()->IsOffTheRecord();
  UrlLoadingBrowserAgent::FromBrowser(self.browser)->Load(loadParams);
}

- (void)dismissViewController {
  if (self.baseViewController.presentedViewController == self.viewController) {
    [self.baseViewController dismissViewControllerAnimated:YES completion:nil];
  }

  self.viewController = nil;
}

- (void)setLoadingWebState:(web::WebState*)webState {
  DCHECK(_webStateObservation);
  _webStateObservation->Reset();
  _loadingWebState = webState;
  if (_loadingWebState) {
    _webStateObservation->Observe(_loadingWebState);
  }
}

- (BOOL)isGoogleDefaultSearchEngine {
  DCHECK(self.templateURLService);
  const TemplateURL* defaultURL =
      self.templateURLService->GetDefaultSearchProvider();
  BOOL isGoogleDefaultSearchProvider =
      defaultURL &&
      defaultURL->GetEngineType(self.templateURLService->search_terms_data()) ==
          SEARCH_ENGINE_GOOGLE;
  return isGoogleDefaultSearchProvider;
}

// Sets the visibility of the Lens replacement for the QR code scanner in the
// home screen widget.
- (void)updateLensAvailabilityForWidgets {
  NSUserDefaults* sharedDefaults = app_group::GetGroupUserDefaults();
  NSString* enableLensInWidgetKey =
      base::SysUTF8ToNSString(app_group::kChromeAppGroupEnableLensInWidget);

  // Determine the availability of the Lens entrypoint in the home screen
  // widget. We don't use LensAvailability here because the seach engine status
  // is determined elsewhere in the Extension Search Engine Data Updater.
  const bool enableLensInWidget =
      ios::provider::IsLensSupported() &&
      GetApplicationContext()->GetLocalState()->GetBoolean(
          prefs::kLensCameraAssistedSearchPolicyAllowed) &&
      !base::FeatureList::IsEnabled(kDisableLensCamera) &&
      ui::GetDeviceFormFactor() != ui::DEVICE_FORM_FACTOR_TABLET;
  [sharedDefaults setBool:enableLensInWidget forKey:enableLensInWidgetKey];

  // If the Lens entrypoint is shown, determine whether to show the color or
  // monochrome icons.
  NSString* enableColorLensAndVoiceIconsInHomeScreenWidgetKey =
      base::SysUTF8ToNSString(
          app_group::kChromeAppGroupEnableColorLensAndVoiceIconsInWidget);
  const bool enableColorLensAndVoiceIconsInHomeScreenWidget =
      base::FeatureList::IsEnabled(
          kEnableColorLensAndVoiceIconsInHomeScreenWidget);
  [sharedDefaults setBool:enableColorLensAndVoiceIconsInHomeScreenWidget
                   forKey:enableColorLensAndVoiceIconsInHomeScreenWidgetKey];
}

// Sets the app shortcut item for either the QR code scanner or Lens.
- (void)updateQRCodeOrLensAppShortcutItem {
  const bool useLens =
      lens_availability::CheckAndLogAvailabilityForLensEntryPoint(
          LensEntrypoint::AppIconLongPress, [self isGoogleDefaultSearchEngine]);

  NSString* shortcutType;
  NSString* shortcutTitle;
  UIApplicationShortcutIcon* shortcutIcon;
  if (useLens) {
    shortcutType = @"OpenLensFromAppIconLongPress";
    shortcutTitle = l10n_util::GetNSStringWithFixup(
        IDS_IOS_APPLICATION_SHORTCUT_LENS_TITLE);
    shortcutIcon =
        [UIApplicationShortcutIcon iconWithTemplateImageName:kCameraLensSymbol];
  } else {
    shortcutType = @"OpenQRScanner";
    shortcutTitle = l10n_util::GetNSStringWithFixup(
        IDS_IOS_APPLICATION_SHORTCUT_QR_SCANNER_TITLE);
    shortcutIcon =
        [UIApplicationShortcutIcon iconWithSystemImageName:@"qrcode"];
  }
  UIApplicationShortcutItem* item =
      [[UIApplicationShortcutItem alloc] initWithType:shortcutType
                                       localizedTitle:shortcutTitle
                                    localizedSubtitle:nil
                                                 icon:shortcutIcon
                                             userInfo:nil];
  [[UIApplication sharedApplication] setShortcutItems:@[ item ]];
}

@end