chromium/ios/chrome/app/spotlight/open_tabs_spotlight_manager.mm

// Copyright 2023 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/app/spotlight/open_tabs_spotlight_manager.h"

#import <CoreSpotlight/CoreSpotlight.h>
#import <memory>
#import <queue>

#import "base/apple/foundation_util.h"
#import "base/memory/weak_ptr.h"
#import "base/metrics/histogram_macros.h"
#import "base/strings/sys_string_conversions.h"
#import "base/timer/elapsed_timer.h"
#import "ios/chrome/app/spotlight/searchable_item_factory.h"
#import "ios/chrome/app/spotlight/spotlight_interface.h"
#import "ios/chrome/app/spotlight/spotlight_logger.h"
#import "ios/chrome/browser/favicon/model/ios_chrome_large_icon_service_factory.h"
#import "ios/chrome/browser/shared/model/browser/browser.h"
#import "ios/chrome/browser/shared/model/browser/browser_list.h"
#import "ios/chrome/browser/shared/model/browser/browser_list_factory.h"
#import "ios/chrome/browser/shared/model/browser/browser_list_observer_bridge.h"
#import "ios/chrome/browser/shared/model/web_state_list/all_web_state_observation_forwarder.h"
#import "ios/chrome/browser/shared/model/web_state_list/web_state_list_observer_bridge.h"
#import "ios/web/public/web_state_observer_bridge.h"

using web::WebState;

namespace {

// At initial indexing, the # of web states to index per batch before releasing
// the main queue.
const int kBatchSize = 100;

}  // namespace

@interface OpenTabsSpotlightManager () <BrowserListObserver,
                                        WebStateListObserving,
                                        CRWWebStateObserver>

/// Tracks if a clear and reindex operation is pending e.g. while the app is
/// backgrounded.
@property(nonatomic, assign) BOOL needsClearAndReindex;
/// Tracks if a clear and reindex operation is pending e.g. while the app is
/// backgrounded.
@property(nonatomic, assign) BOOL needsFullIndex;
/// Prevents reentry into clearAndReindexIfNeeded method.
@property(nonatomic, assign) BOOL deletionInProgress;

@end

/// @discussion Keeps a list of currently opened non-incognito tabs in the
/// spotlight index. For this, this class observes all navigations in all opened
/// tabs (by observing all WebStateLists in all non-incognito browsers).
/// Whenever a URL might have changed for a given tab, the `_lastCommittedURLs`
/// is updated, and the counts in `_knownURLCounts` are changed. When a known
/// URL count reaches 0, the spotlight entry is removed, and vice versa.
/// Non-HTTP(S) and invalid URLs are ignored for the purpose of this class.
@implementation OpenTabsSpotlightManager {
  // Bridges browser list events
  std::unique_ptr<BrowserListObserverBridge> _browserListObserverBridge;

  std::unique_ptr<web::WebStateObserverBridge> _webStateObserverBridge;

  // Tracks the last committed URL for each tab.
  std::map<web::WebStateID, GURL> _lastCommittedURLs;
  // Tracks the number of open tabs with this URL.
  std::map<GURL, NSUInteger> _knownURLCounts;
  // Bridge that observes all web state lists in all non-incognito browsers.
  // Used to keep track of closing tabs.
  std::unique_ptr<WebStateListObserverBridge> _webStateListObserverBridge;

  // At full reindex of a WebStateList, this queue is used for WebStates that
  // require indexing.
  std::queue<base::WeakPtr<WebState>> _indexingQueue;

  // Timer that's counts how long it takes to index all tabs.
  std::unique_ptr<base::ElapsedTimer> _initialIndexTimer;

  // Tracks the number of times reindexing had to restart because of the model
  int _reindexInterruptionCount;
}

#pragma mark - public

+ (OpenTabsSpotlightManager*)openTabsSpotlightManagerWithBrowserState:
    (ChromeBrowserState*)browserState {
  favicon::LargeIconService* largeIconService =
      IOSChromeLargeIconServiceFactory::GetForBrowserState(browserState);
  SearchableItemFactory* searchableItemFactory = [[SearchableItemFactory alloc]
      initWithLargeIconService:largeIconService
                        domain:spotlight::DOMAIN_OPEN_TABS
         useTitleInIdentifiers:NO];

  return [[OpenTabsSpotlightManager alloc]
      initWithLargeIconService:largeIconService
                   browserList:BrowserListFactory::GetForBrowserState(
                                   browserState)
            spotlightInterface:[SpotlightInterface defaultInterface]
         searchableItemFactory:searchableItemFactory];
}

- (instancetype)
    initWithLargeIconService:(favicon::LargeIconService*)largeIconService
                 browserList:(BrowserList*)browserList
          spotlightInterface:(SpotlightInterface*)spotlightInterface
       searchableItemFactory:(SearchableItemFactory*)searchableItemFactory {
  self = [super initWithSpotlightInterface:spotlightInterface
                     searchableItemFactory:searchableItemFactory];

  if (self) {
    _browserList = browserList;
    _indexingQueue = std::queue<base::WeakPtr<WebState>>();
    _browserListObserverBridge =
        std::make_unique<BrowserListObserverBridge>(self);
    _webStateObserverBridge =
        std::make_unique<web::WebStateObserverBridge>(self);
    _webStateListObserverBridge =
        std::make_unique<WebStateListObserverBridge>(self);
    _browserList->AddObserver(_browserListObserverBridge.get());
    [self startObservingAllWebStates];
  }

  return self;
}

- (void)clearAndReindexOpenTabs {
  if (!_browserList) {
    [SpotlightLogger logSpotlightError:[OpenTabsSpotlightManager
                                           browserListNotAvailableError]];
    return;
  }

  // If there is an ongoing indexing, recreate the queue to clear it.
  _indexingQueue = std::queue<base::WeakPtr<WebState>>();

  [self stopObservingAllWebStates];

  self.needsClearAndReindex = YES;
  [self clearAndReindexIfNeeded];
}
- (void)clearAndReindexIfNeeded {
  // If already waiting for Spotlight DB to clear all, don't do anything.
  if (self.deletionInProgress) {
    return;
  }

  if (!self.needsClearAndReindex || self.isAppInBackground) {
    return;
  }

  self.needsFullIndex = NO;
  self.deletionInProgress = YES;
  __weak OpenTabsSpotlightManager* weakSelf = self;
  [self.spotlightInterface
      deleteSearchableItemsWithDomainIdentifiers:@[
        StringFromSpotlightDomain(spotlight::DOMAIN_OPEN_TABS)
      ]
                               completionHandler:^(NSError*) {
                                 weakSelf.deletionInProgress = NO;
                                 if (weakSelf.isShuttingDown) {
                                   return;
                                 }
                                 weakSelf.needsClearAndReindex = NO;
                                 [weakSelf indexAllOpenTabs];
                               }];
}

- (void)shutdown {
  [super shutdown];
  [self shutdownAllObservation];
}

- (void)appWillEnterForeground {
  [super appWillEnterForeground];

  if (self.needsClearAndReindex || self.needsFullIndex) {
    [self clearAndReindexOpenTabs];
    return;
  }

  if (!_indexingQueue.empty()) {
    __weak OpenTabsSpotlightManager* weakSelf = self;
    dispatch_async(dispatch_get_main_queue(), ^{
      [weakSelf indexNextBatchFromQueue];
    });
  }
}

#pragma mark - BrowserListObserver

- (void)browserList:(const BrowserList*)browserList
       browserAdded:(Browser*)browser {
  if (browser->type() == Browser::Type::kIncognito) {
    return;
  }
  // If the initial indexing is still in progress, cancel it and restart.
  if (!_indexingQueue.empty()) {
    [self logReindexInterruption];
    [self clearAndReindexOpenTabs];
    return;
  }

  WebStateList* webStateList = browser->GetWebStateList();
  webStateList->AddObserver(_webStateListObserverBridge.get());

  if (self.isAppInBackground) {
    // Normally, no model updates should happen in background.
    // In case they do, process them on foreground.
    self.needsClearAndReindex = YES;
    return;
  }

  [self addAllURLsFromWebStateList:webStateList];

}

- (void)browserList:(const BrowserList*)browserList
     browserRemoved:(Browser*)browser {
  if (browser->type() == Browser::Type::kIncognito) {
    return;
  }
  WebStateList* webStateList = browser->GetWebStateList();
  webStateList->RemoveObserver(_webStateListObserverBridge.get());

  if (self.isAppInBackground) {
    // Normally, no model updates should happen in background.
    // In case they do, process them on foreground.
    self.needsClearAndReindex = YES;
    return;
  }

  [self removeAllURLsFromWebStateList:webStateList];
}

- (void)browserListWillShutdown:(const BrowserList*)browserList {
  [self shutdownAllObservation];
}

#pragma mark - WebStateListObserving

- (void)didChangeWebStateList:(WebStateList*)webStateList
                       change:(const WebStateListChange&)change
                       status:(const WebStateListStatus&)status {
  // If the initial indexing is still in progress, cancel it and restart.
  if (!_indexingQueue.empty() && !self.isAppInBackground) {
    [self logReindexInterruption];
    [self clearAndReindexOpenTabs];
    return;
  }

  if (change.type() == WebStateListChange::Type::kInsert) {
    const WebStateListChangeInsert& insertChange =
        change.As<WebStateListChangeInsert>();
    insertChange.inserted_web_state()->AddObserver(
        _webStateObserverBridge.get());
  } else if (change.type() == WebStateListChange::Type::kDetach) {
    const WebStateListChangeDetach& detachChange =
        change.As<WebStateListChangeDetach>();
    raw_ptr<web::WebState> webState = detachChange.detached_web_state();
    webState->RemoveObserver(_webStateObserverBridge.get());

    if (self.isAppInBackground) {
      // Normally, no model updates should happen in background.
      // In case they do, process them on foreground.
      self.needsClearAndReindex = YES;
      return;
    }

    [self removeLatestCommittedURLForWebState:webState];
  }
}

- (void)webStateListDestroyed:(WebStateList*)webStateList {
  webStateList->RemoveObserver(_webStateListObserverBridge.get());

  if (self.isAppInBackground) {
    // Normally, no model updates should happen in background.
    // In case they do, process them on foreground.
    self.needsClearAndReindex = YES;
    return;
  }

  [self removeAllURLsFromWebStateList:webStateList];
}

#pragma mark - CRWWebStateObserver

// Invoked by WebStateObserverBridge::DidStartNavigation.
- (void)webState:(web::WebState*)webState
    didStartNavigation:(web::NavigationContext*)navigationContext {
  if (self.isAppInBackground) {
    // Normally, no model updates should happen in background.
    // In case they do, process them on foreground.
    self.needsClearAndReindex = YES;
    return;
  }

  [self removeLatestCommittedURLForWebState:webState];
}

// Invoked by WebStateObserverBridge::DidFinishNavigation.
- (void)webState:(web::WebState*)webState
    didFinishNavigation:(web::NavigationContext*)navigationContext {
  if (self.isAppInBackground) {
    // Normally, no model updates should happen in background.
    // In case they do, process them on foreground.
    self.needsClearAndReindex = YES;
    return;
  }

  [self updateLatestCommittedURLForWebState:webState];
}

- (void)webState:(web::WebState*)webState
    didRedirectNavigation:(web::NavigationContext*)navigationContext {
  if (self.isAppInBackground) {
    // Normally, no model updates should happen in background.
    // In case they do, process them on foreground.
    self.needsClearAndReindex = YES;
    return;
  }

  [self updateLatestCommittedURLForWebState:webState];
}

- (void)webStateDestroyed:(web::WebState*)webState {
  webState->RemoveObserver(_webStateObserverBridge.get());
}

#pragma mark - private

/// Removes whatever the previously remembered URL was for a given webstate.
- (void)removeLatestCommittedURLForWebState:(web::WebState*)webState {
  if (self.isShuttingDown) {
    return;
  }
  [self indexURL:nullptr
              title:nil
      forWebStateID:webState->GetUniqueIdentifier()];
}

/// Updates the remembered URL of the webstate.
- (void)updateLatestCommittedURLForWebState:(web::WebState*)webState {
  if (self.isShuttingDown) {
    return;
  }
  GURL URL = webState->GetLastCommittedURL();
  if (![OpenTabsSpotlightManager shouldIndexURL:URL]) {
    return;
  }

  NSString* title = base::SysUTF16ToNSString(webState->GetTitle());
  [self indexURL:&URL
              title:title
      forWebStateID:webState->GetUniqueIdentifier()];
}

/// Iterates through all webstates in `webStateList` add adds them to the index.
- (void)addAllURLsFromWebStateList:(WebStateList*)webStateList {
  if (self.isShuttingDown) {
    return;
  }

  BOOL wasQueueEmpty = _indexingQueue.empty();

  for (int i = 0; i < webStateList->count(); i++) {
    web::WebState* webState = webStateList->GetWebStateAt(i);
    _indexingQueue.push(webState->GetWeakPtr());
  }

  // When the queue wasn't empty, the indexing is already in progress.
  if (!wasQueueEmpty) {
    return;
  }

  __weak OpenTabsSpotlightManager* weakSelf = self;
  dispatch_async(dispatch_get_main_queue(), ^{
    [weakSelf indexNextBatchFromQueue];
  });
}

- (void)indexNextBatchFromQueue {
  if (self.isShuttingDown) {
    if (!_indexingQueue.empty()) {
      [self logReindexInterruption];
    }
    return;
  }

  if (self.isAppInBackground) {
    return;  // Indexing will resume on foreground.
  }

  for (int i = 0; i < kBatchSize; i++) {
    if (_indexingQueue.empty()) {
      if (_initialIndexTimer) {
        UMA_HISTOGRAM_TIMES("IOS.Spotlight.OpenTabsIndexingDuration",
                            _initialIndexTimer->Elapsed());
        _initialIndexTimer.reset();
      }

      return;
    }

    base::WeakPtr<WebState> webState = _indexingQueue.front();
    _indexingQueue.pop();
    if (webState) {
      [self updateLatestCommittedURLForWebState:webState.get()];
    }
  }

  __weak OpenTabsSpotlightManager* weakSelf = self;
  dispatch_async(dispatch_get_main_queue(), ^{
    [weakSelf indexNextBatchFromQueue];
  });
}

/// Iterates through all webstates in `webStateList` add removes them from the
/// index.
- (void)removeAllURLsFromWebStateList:(WebStateList*)webStateList {
  if (self.isShuttingDown) {
    return;
  }
  for (int i = 0; i < webStateList->count(); i++) {
    web::WebState* webState = webStateList->GetWebStateAt(i);
    [self removeLatestCommittedURLForWebState:webState];
  }
}

/// Iterate over all non-incognito web states and adds them to the index
/// immediately.
- (void)indexAllOpenTabs {
  if (self.isShuttingDown) {
    return;
  }

  if (!_indexingQueue.empty()) {
    // Indexing is already happening, nothing to do.
    return;
  }

  self.needsFullIndex = YES;
  [self indexAllOpenTabsIfNeeded];
}

- (void)indexAllOpenTabsIfNeeded {
  if (self.isAppInBackground || !self.needsFullIndex) {
    return;
  }

  _initialIndexTimer = std::make_unique<base::ElapsedTimer>();

  // Start observing only the web state lists. Individual webstates will be
  // observed as they are batch-indexed.
  [self startObservingAllWebStateLists];

  for (Browser* browser : self.browserList->BrowsersOfType(
           BrowserList::BrowserType::kRegularAndInactive)) {
    WebStateList* webStateList = browser->GetWebStateList();
    [self addAllURLsFromWebStateList:webStateList];
  }

  self.needsFullIndex = NO;

  UMA_HISTOGRAM_COUNTS_1000("IOS.Spotlight.OpenTabsInitialIndexSize",
                            _knownURLCounts.size());
}

/// NSError to throw when the browser list isn't available.
+ (NSError*)browserListNotAvailableError {
  return [NSError
      errorWithDomain:@"chrome"
                 code:0
             userInfo:@{
               NSLocalizedDescriptionKey :
                   @"BrowserList isn't initialized or already shut down"
             }];
}

/// Only index valid HTTP(S) URLs.
+ (BOOL)shouldIndexURL:(GURL)URL {
  return URL.is_valid() && URL.SchemeIsHTTPOrHTTPS();
}

/// Pass nullptr for `URL` to remove the previously indexed URL without
/// replacing it. In this case, `title` is ignored so `nil` is accepted.
- (void)indexURL:(GURL*)URL
            title:(NSString*)title
    forWebStateID:(web::WebStateID)webStateID {
  if (self.isShuttingDown || self.isAppInBackground) {
    return;
  }
  if (_lastCommittedURLs.contains(webStateID)) {
    GURL lastKnownURL = _lastCommittedURLs[webStateID];
    DCHECK(_knownURLCounts[lastKnownURL] > 0);
    _knownURLCounts[lastKnownURL]--;
    if (_knownURLCounts[lastKnownURL] == 0) {
      // The URL doesn't correspond to any open tab anymore, remove it from the
      // index.
      [self.spotlightInterface deleteSearchableItemsWithIdentifiers:@[
        [self.searchableItemFactory spotlightIDForURL:lastKnownURL]
      ]
                                                  completionHandler:nil];
    }
  }

  if (URL) {
    _lastCommittedURLs[webStateID] = *URL;
    _knownURLCounts[*URL]++;
    if (_knownURLCounts[*URL] == 1) {
      // The URL is newly added, update Spotlight index.
      __weak OpenTabsSpotlightManager* weakSelf = self;
      [self.searchableItemFactory
          generateSearchableItem:*URL
                           title:title
              additionalKeywords:@[]
               completionHandler:^(CSSearchableItem* item) {
                 [weakSelf.spotlightInterface indexSearchableItems:@[ item ]];
               }];
    }
  } else {
    _lastCommittedURLs.erase(webStateID);
  }
}

#pragma mark - observation helpers

/// Stops observing all objects and resets bridges and the browser list.
- (void)shutdownAllObservation {
  if (!_browserList) {
    return;
  }

  [self stopObservingAllWebStates];

  // Stop observing brower list.
  _browserList->RemoveObserver(_browserListObserverBridge.get());
  _browserListObserverBridge.reset();

  // Finally, reset the browser list to make repeated calls safe.
  _browserList = nil;
}

- (void)logReindexInterruption {
  _reindexInterruptionCount++;
  UMA_HISTOGRAM_COUNTS_1000("IOS.Spotlight.OpenTabsReindexRestarted",
                            _reindexInterruptionCount);
}

- (void)stopObservingAllWebStates {
  if (!_browserList) {
    return;
  }

  for (Browser* browser : _browserList->BrowsersOfType(
           BrowserList::BrowserType::kRegularAndInactive)) {
    WebStateList* webStateList = browser->GetWebStateList();
    if (!webStateList) {
      continue;
    }
    for (int i = 0; i < webStateList->count(); i++) {
      WebState* webState = webStateList->GetWebStateAt(i);
      webState->RemoveObserver(_webStateObserverBridge.get());
    }
    webStateList->RemoveObserver(_webStateListObserverBridge.get());
  }

  _webStateObserverBridge = std::make_unique<web::WebStateObserverBridge>(self);
  _webStateListObserverBridge =
      std::make_unique<WebStateListObserverBridge>(self);
}

- (void)startObservingAllWebStateLists {
  if (!_browserList) {
    return;
  }

  [self stopObservingAllWebStates];

  for (Browser* browser : _browserList->BrowsersOfType(
           BrowserList::BrowserType::kRegularAndInactive)) {
    WebStateList* webStateList = browser->GetWebStateList();
    webStateList->AddObserver(_webStateListObserverBridge.get());
  }
}

- (void)startObservingAllWebStates {
  if (!_browserList) {
    return;
  }

  [self startObservingAllWebStateLists];

  for (Browser* browser : _browserList->BrowsersOfType(
           BrowserList::BrowserType::kRegularAndInactive)) {
    WebStateList* webStateList = browser->GetWebStateList();
    for (int i = 0; i < webStateList->count(); i++) {
      web::WebState* webState = webStateList->GetWebStateAt(i);
      webState->AddObserver(_webStateObserverBridge.get());
    }
  }
}

@end