chromium/ios/web/navigation/crw_js_navigation_handler.mm

// Copyright 2019 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/web/navigation/crw_js_navigation_handler.h"

#import "base/json/string_escape.h"
#import "base/logging.h"
#import "base/strings/sys_string_conversions.h"
#import "ios/web/history_state_util.h"
#import "ios/web/navigation/navigation_context_impl.h"
#import "ios/web/navigation/navigation_item_impl.h"
#import "ios/web/navigation/navigation_manager_impl.h"
#import "ios/web/web_state/user_interaction_state.h"
#import "ios/web/web_state/web_state_impl.h"
#import "net/base/apple/url_conversions.h"

namespace {

// URLs that are fed into WKWebView as history push/replace get escaped,
// potentially changing their format. Code that attempts to determine whether a
// URL hasn't changed can be confused by those differences though, so method
// will round-trip a URL through the escaping process so that it can be adjusted
// pre-storing, to allow later comparisons to work as expected.
GURL URLEscapedForHistory(const GURL& url) {
  // TODO(crbug.com/41464782): This is a very large hammer; see if limited
  // unicode escaping would be sufficient.
  return net::GURLWithNSURL(net::NSURLWithGURL(url));
}

}  // namespace

@implementation CRWJSNavigationHandler

#pragma mark - Public

- (void)handleNavigationWillChangeState {
  self.changingHistoryState = YES;
}

- (void)handleNavigationDidPushStateMessage:(base::Value::Dict*)dict
                                   webState:(web::WebStateImpl*)webStateImpl
                             hasUserGesture:(BOOL)hasUserGesture
                       userInteractionState:
                           (web::UserInteractionState*)userInteractionState
                                 currentURL:(GURL)currentURL {
  if (!webStateImpl || webStateImpl->IsBeingDestroyed()) {
    // Ignore messages received after WebState is being destroyed.
    return;
  }

  DCHECK(self.changingHistoryState);
  self.changingHistoryState = NO;

  const web::NavigationManagerImpl& navigationManagerImpl =
      webStateImpl->GetNavigationManagerImpl();

  // If there is a pending entry, a new navigation has been registered but
  // hasn't begun loading.  Since the pushState message is coming from the
  // previous page, ignore it and allow the previously registered navigation to
  // continue.  This can ocur if a pushState is issued from an anchor tag
  // onClick event, as the click would have already been registered.
  if (navigationManagerImpl.GetPendingItem()) {
    return;
  }

  const std::string* pageURL = dict->FindString("pageUrl");
  const std::string* baseURL = dict->FindString("baseUrl");
  if (!pageURL || !baseURL) {
    DLOG(WARNING) << "JS message parameter not found: pageUrl or baseUrl";
    return;
  }
  GURL pushURL = web::history_state_util::GetHistoryStateChangeUrl(
      currentURL, GURL(*baseURL), *pageURL);
  // UIWebView seems to choke on unicode characters that haven't been
  // escaped; escape the URL now so the expected load URL is correct.
  pushURL = URLEscapedForHistory(pushURL);
  if (!pushURL.is_valid())
    return;

  web::NavigationItemImpl* navItem = navigationManagerImpl.GetCurrentItemImpl();
  // PushState happened before first navigation entry or called when the
  // navigation entry does not contain a valid URL.
  if (!navItem || !navItem->GetURL().is_valid())
    return;
  if (!web::history_state_util::IsHistoryStateChangeValid(navItem->GetURL(),
                                                          pushURL)) {
    // If the current session entry URL origin still doesn't match pushURL's
    // origin, ignore the pushState. This can happen if a new URL is loaded
    // just before the pushState.
    return;
  }
  const std::string* stateObjectJSON = dict->FindString("stateObject");
  if (!stateObjectJSON) {
    DLOG(WARNING) << "JS message parameter not found: stateObject";
    return;
  }
  NSString* stateObject = base::SysUTF8ToNSString(*stateObjectJSON);

  int currentIndex = navigationManagerImpl.GetIndexOfItem(navItem);
  if (currentIndex > 0) {
    web::NavigationItem* previousItem =
        navigationManagerImpl.GetItemAtIndex(currentIndex - 1);
    web::UserAgentType userAgent = previousItem->GetUserAgentType();
    if (userAgent != web::UserAgentType::NONE) {
      navItem->SetUserAgentType(userAgent);
    }
  }
  // If the user interacted with the page, categorize it as a link navigation.
  // If not, categorize it is a client redirect as it occurred without user
  // input and should not be added to the history stack.
  // TODO(crbug.com/41213462): Improve transition detection.
  ui::PageTransition transition =
      userInteractionState->UserInteractionRegisteredSincePageLoaded()
          ? ui::PAGE_TRANSITION_LINK
          : ui::PAGE_TRANSITION_CLIENT_REDIRECT;
  [self pushStateWithPageURL:pushURL
                 stateObject:stateObject
                  transition:transition
              hasUserGesture:hasUserGesture
        userInteractionState:userInteractionState
                    webState:webStateImpl];
}

- (void)handleNavigationDidReplaceStateMessage:(base::Value::Dict*)dict
                                      webState:(web::WebStateImpl*)webStateImpl
                                hasUserGesture:(BOOL)hasUserGesture
                          userInteractionState:
                              (web::UserInteractionState*)userInteractionState
                                    currentURL:(GURL)currentURL {
  if (!webStateImpl || webStateImpl->IsBeingDestroyed()) {
    // Ignore messages received after WebState is being destroyed.
    return;
  }

  DCHECK(self.changingHistoryState);
  self.changingHistoryState = NO;

  const std::string* pageURL = dict->FindString("pageUrl");
  const std::string* baseURL = dict->FindString("baseUrl");
  if (!pageURL || !baseURL) {
    DLOG(WARNING) << "JS message parameter not found: pageUrl or baseUrl";
    return;
  }
  GURL replaceURL = web::history_state_util::GetHistoryStateChangeUrl(
      currentURL, GURL(*baseURL), *pageURL);
  // UIWebView seems to choke on unicode characters that haven't been
  // escaped; escape the URL now so the expected load URL is correct.
  replaceURL = URLEscapedForHistory(replaceURL);
  if (!replaceURL.is_valid())
    return;

  const web::NavigationManagerImpl& navigationManagerImpl =
      webStateImpl->GetNavigationManagerImpl();
  web::NavigationItemImpl* navItem = navigationManagerImpl.GetCurrentItemImpl();
  // ReplaceState happened before first navigation entry or called right
  // after window.open when the url is empty/not valid.
  if (!navItem || (navigationManagerImpl.GetItemCount() <= 1 &&
                   navItem->GetURL().is_empty()))
    return;
  if (!web::history_state_util::IsHistoryStateChangeValid(navItem->GetURL(),
                                                          replaceURL)) {
    // If the current session entry URL origin still doesn't match
    // replaceURL's origin, ignore the replaceState. This can happen if a
    // new URL is loaded just before the replaceState.
    return;
  }
  const std::string* stateObjectJSON = dict->FindString("stateObject");
  if (!stateObjectJSON) {
    DLOG(WARNING) << "JS message parameter not found: stateObject";
    return;
  }
  NSString* stateObject = base::SysUTF8ToNSString(*stateObjectJSON);
  [self replaceStateWithPageURL:replaceURL
                    stateObject:stateObject
                 hasUserGesture:hasUserGesture
                       webState:webStateImpl];
}

#pragma mark - Private

// Adds a new NavigationItem with the given URL and state object to the
// history stack. A state object is a serialized generic JavaScript object
// that contains details of the UI's state for a given NavigationItem/URL.
// TODO(crbug.com/40624624): Move the pushState/replaceState logic into
// NavigationManager.
- (void)pushStateWithPageURL:(const GURL&)pageURL
                 stateObject:(NSString*)stateObject
                  transition:(ui::PageTransition)transition
              hasUserGesture:(BOOL)hasUserGesture
        userInteractionState:(web::UserInteractionState*)userInteractionState
                    webState:(web::WebStateImpl*)webStateImpl {
  std::unique_ptr<web::NavigationContextImpl> context =
      web::NavigationContextImpl::CreateNavigationContext(
          webStateImpl, pageURL, hasUserGesture, transition,
          /*is_renderer_initiated=*/true);
  context->SetIsSameDocument(true);
  webStateImpl->OnNavigationStarted(context.get());
  context->SetHasCommitted(true);
  webStateImpl->OnNavigationFinished(context.get());
  userInteractionState->SetUserInteractionRegisteredSincePageLoaded(false);
}

// Assigns the given URL and state object to the current NavigationItem.
- (void)replaceStateWithPageURL:(const GURL&)pageURL
                    stateObject:(NSString*)stateObject
                 hasUserGesture:(BOOL)hasUserGesture
                       webState:(web::WebStateImpl*)webStateImpl {
  std::unique_ptr<web::NavigationContextImpl> context =
      web::NavigationContextImpl::CreateNavigationContext(
          webStateImpl, pageURL, hasUserGesture,
          ui::PageTransition::PAGE_TRANSITION_CLIENT_REDIRECT,
          /*is_renderer_initiated=*/true);
  context->SetIsSameDocument(true);
  webStateImpl->OnNavigationStarted(context.get());
  web::NavigationManagerImpl& navigationManagerImpl =
      webStateImpl->GetNavigationManagerImpl();
  navigationManagerImpl.UpdateCurrentItemForReplaceState(pageURL, stateObject);
  context->SetHasCommitted(true);
  webStateImpl->OnNavigationFinished(context.get());
}

@end