blob: 12cbb0232e021ef9519c718b0159b473f76f9aea [file] [log] [blame]
// Copyright 2019 The Chromium Authors. All rights reserved.
// 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_web_view_navigation_observer.h"
#include "base/logging.h"
#include "base/metrics/histogram_functions.h"
#include "base/strings/sys_string_conversions.h"
#import "ios/net/http_response_headers_util.h"
#include "ios/web/common/features.h"
#include "ios/web/common/url_util.h"
#import "ios/web/navigation/crw_error_page_helper.h"
#import "ios/web/navigation/crw_navigation_item_holder.h"
#import "ios/web/navigation/crw_pending_navigation_info.h"
#import "ios/web/navigation/crw_web_view_navigation_observer_delegate.h"
#import "ios/web/navigation/crw_wk_navigation_handler.h"
#import "ios/web/navigation/crw_wk_navigation_states.h"
#import "ios/web/navigation/navigation_context_impl.h"
#import "ios/web/navigation/wk_navigation_util.h"
#import "ios/web/public/web_client.h"
#import "ios/web/web_state/web_state_impl.h"
#import "ios/web/web_view/wk_web_view_util.h"
#import "net/base/mac/url_conversions.h"
#include "url/gurl.h"
#if !defined(__has_feature) || !__has_feature(objc_arc)
#error "This file requires ARC support."
#endif
using web::NavigationManagerImpl;
using web::wk_navigation_util::IsRestoreSessionUrl;
// TODO(crbug.com/1038303): This legacy won't be needed anymore.
using web::wk_navigation_util::IsPlaceholderUrl;
@interface CRWWebViewNavigationObserver ()
// Dictionary where keys are the names of WKWebView properties and values are
// selector names which should be called when a corresponding property has
// changed. e.g. @{ @"URL" : @"webViewURLDidChange" } means that
// -[self webViewURLDidChange] must be called every time when WKWebView.URL is
// changed.
@property(weak, nonatomic, readonly) NSDictionary* WKWebViewObservers;
@property(nonatomic, assign, readonly) web::WebStateImpl* webStateImpl;
// The WKNavigationDelegate handler class.
@property(nonatomic, readonly, strong)
CRWWKNavigationHandler* navigationHandler;
// The actual URL of the document object (i.e., the last committed URL).
@property(nonatomic, readonly) const GURL& documentURL;
// The NavigationManagerImpl associated with the web state.
@property(nonatomic, readonly) NavigationManagerImpl* navigationManagerImpl;
@end
@implementation CRWWebViewNavigationObserver
#pragma mark - Property
- (void)setWebView:(WKWebView*)webView {
for (NSString* keyPath in self.WKWebViewObservers) {
[_webView removeObserver:self forKeyPath:keyPath];
}
_webView = webView;
for (NSString* keyPath in self.WKWebViewObservers) {
[_webView addObserver:self forKeyPath:keyPath options:0 context:nullptr];
}
}
- (NSDictionary*)WKWebViewObservers {
return @{
@"estimatedProgress" : @"webViewEstimatedProgressDidChange",
@"loading" : @"webViewLoadingStateDidChange",
@"canGoForward" : @"webViewBackForwardStateDidChange",
@"canGoBack" : @"webViewBackForwardStateDidChange",
@"URL" : @"webViewURLDidChange",
};
}
- (NavigationManagerImpl*)navigationManagerImpl {
return self.webStateImpl ? &(self.webStateImpl->GetNavigationManagerImpl())
: nil;
}
- (web::WebStateImpl*)webStateImpl {
return [self.delegate webStateImplForWebViewHandler:self];
}
- (CRWWKNavigationHandler*)navigationHandler {
return [self.delegate navigationHandlerForNavigationObserver:self];
}
- (const GURL&)documentURL {
return [self.delegate documentURLForWebViewHandler:self];
}
#pragma mark - KVO Observation
- (void)observeValueForKeyPath:(NSString*)keyPath
ofObject:(id)object
change:(NSDictionary*)change
context:(void*)context {
DCHECK(!self.beingDestroyed);
NSString* dispatcherSelectorName = self.WKWebViewObservers[keyPath];
DCHECK(dispatcherSelectorName);
if (dispatcherSelectorName) {
// With ARC memory management, it is not known what a method called
// via a selector will return. If a method returns a retained value
// (e.g. NS_RETURNS_RETAINED) that returned object will leak as ARC is
// unable to property insert the correct release calls for it.
// All selectors used here return void and take no parameters so it's safe
// to call a function mapping to the method implementation manually.
SEL selector = NSSelectorFromString(dispatcherSelectorName);
IMP methodImplementation = [self methodForSelector:selector];
if (methodImplementation) {
void (*methodCallFunction)(id, SEL) =
reinterpret_cast<void (*)(id, SEL)>(methodImplementation);
methodCallFunction(self, selector);
}
}
}
// Called when WKWebView estimatedProgress has been changed.
- (void)webViewEstimatedProgressDidChange {
self.webStateImpl->SendChangeLoadProgress(self.webView.estimatedProgress);
}
// Called when WKWebView loading state has been changed.
- (void)webViewLoadingStateDidChange {
self.webStateImpl->SetIsLoading(self.webView.loading);
if (self.webView.loading)
return;
GURL webViewURL = net::GURLWithNSURL(self.webView.URL);
if (![self.navigationHandler isCurrentNavigationBackForward])
return;
web::NavigationContextImpl* existingContext = [self.navigationHandler
contextForPendingMainFrameNavigationWithURL:webViewURL];
// When traversing history restored from a previous session, WKWebView does
// not fire 'pageshow', 'onload', 'popstate' or any of the
// WKNavigationDelegate callbacks for back/forward navigation from an about:
// scheme placeholder URL to another entry or if either of the redirect fails
// to load (e.g. in airplane mode, <iOS13). Loading state KVO is the only
// observable event in this scenario, so force a reload to trigger redirect
// from restore_session.html to the restored URL.
bool previousURLHasAboutScheme =
self.documentURL.SchemeIs(url::kAboutScheme) ||
(!base::FeatureList::IsEnabled(web::features::kUseJSForErrorPage) &&
IsPlaceholderUrl(self.documentURL)) ||
web::GetWebClient()->IsAppSpecificURL(self.documentURL);
bool needs_back_forward_navigation_reload =
existingContext &&
(existingContext->GetPageTransition() & ui::PAGE_TRANSITION_FORWARD_BACK);
// The back-forward workaround isn't need on iOS13.
if (@available(iOS 13, *)) {
needs_back_forward_navigation_reload = false;
}
if (IsRestoreSessionUrl(webViewURL)) {
if (previousURLHasAboutScheme || needs_back_forward_navigation_reload) {
[self.webView reload];
self.navigationHandler.navigationState =
web::WKNavigationState::REQUESTED;
return;
}
}
// For failed navigations, WKWebView will sometimes revert to the previous URL
// before committing the current navigation or resetting the web view's
// |isLoading| property to NO. If this is the first navigation for the web
// view, this will result in an empty URL.
BOOL navigationWasCommitted = self.navigationHandler.navigationState !=
web::WKNavigationState::REQUESTED;
if (!navigationWasCommitted &&
(webViewURL.is_empty() || webViewURL == self.documentURL)) {
return;
}
if (!navigationWasCommitted &&
!self.navigationHandler.pendingNavigationInfo.cancelled) {
// A fast back-forward navigation does not call |didCommitNavigation:|, so
// signal page change explicitly.
DCHECK_EQ(self.documentURL.GetOrigin(), webViewURL.GetOrigin());
BOOL isSameDocumentNavigation =
[self isKVOChangePotentialSameDocumentNavigationToURL:webViewURL];
[self.delegate navigationObserver:self
didChangeDocumentURL:webViewURL
forContext:existingContext];
if (!existingContext) {
// This URL was not seen before, so register new load request.
[self.delegate navigationObserver:self
didLoadNewURL:webViewURL
forSameDocumentNavigation:isSameDocumentNavigation];
} else {
// Same document navigation does not contain response headers.
if (isSameDocumentNavigation) {
existingContext->SetResponseHeaders(nullptr);
}
existingContext->SetIsSameDocument(isSameDocumentNavigation);
existingContext->SetHasCommitted(!isSameDocumentNavigation);
self.webStateImpl->OnNavigationStarted(existingContext);
[self.delegate navigationObserver:self
didChangePageWithContext:existingContext];
self.webStateImpl->OnNavigationFinished(existingContext);
}
}
[self.delegate webViewHandlerUpdateSSLStatusForCurrentNavigationItem:self];
[self.delegate webViewHandler:self didFinishNavigation:existingContext];
}
// Called when WKWebView canGoForward/canGoBack state has been changed.
- (void)webViewBackForwardStateDidChange {
// Don't trigger for LegacyNavigationManager because its back/foward state
// doesn't always match that of WKWebView.
self.webStateImpl->OnBackForwardStateChanged();
}
// Called when WKWebView URL has been changed.
- (void)webViewURLDidChange {
// TODO(crbug.com/966412): Determine if there are any cases where this still
// happens, and if so whether anything should be done when it does.
if (self.webView.URL.absoluteString.length == 0) {
DVLOG(1) << "Received nil/empty URL callback";
base::UmaHistogramBoolean("IOS.Web.URLDidChangeToEmptyURL", true);
return;
}
GURL URL(net::GURLWithNSURL(self.webView.URL));
// URL changes happen at four points:
// 1) When a load starts; at this point, the load is provisional, and
// it should be ignored until it's committed, since the document/window
// objects haven't changed yet.
// 2) When a non-document-changing URL change happens (hash change,
// history.pushState, etc.). This URL change happens instantly, so should
// be reported.
// 3) When a navigation error occurs after provisional navigation starts,
// the URL reverts to the previous URL without triggering a new navigation.
// 4) When the user is reloading an error page.
//
// If |isLoading| is NO, then it must be case 2, 3, or 4. If the last
// committed URL (_documentURL) matches the current URL, assume that it is
// case 3. If the URL does not match, assume it is a non-document-changing
// URL change, and handle accordingly.
//
// If |isLoading| is YES, then it could either be case 1, or it could be case
// 2 on a page that hasn't finished loading yet. If it's possible that it
// could be a same-page navigation (in which case there may not be any other
// callback about the URL having changed), then check the actual page URL via
// JavaScript. If the origin of the new URL matches the last committed URL,
// then check window.location.href, and if it matches, trust it. The origin
// check ensures that if a site somehow corrupts window.location.href it can't
// do a redirect to a slow-loading target page while it is still loading to
// spoof the origin. On a document-changing URL change, the
// window.location.href will match the previous URL at this stage, not the web
// view's current URL.
if (!self.webView.loading) {
if ([CRWErrorPageHelper isErrorPageFileURL:URL] &&
self.documentURL ==
[CRWErrorPageHelper failedNavigationURLFromErrorPageFileURL:URL]) {
// Case 4: reloading an error page.
return;
}
if (self.documentURL == URL) {
return;
}
// At this point, self.webView, self.webView.backForwardList.currentItem and
// its associated NavigationItem should all have the same URL, except in two
// edge cases:
// 1. location.replace that only changes hash: WebKit updates
// self.webView.URL
// and currentItem.URL, and NavigationItem URL must be synced.
// 2. location.replace to about: URL: a WebKit bug causes only
// self.webView.URL,
// but not currentItem.URL to be updated. NavigationItem URL should be
// synced to self.webView.URL.
// This needs to be done before |URLDidChangeWithoutDocumentChange| so any
// WebStateObserver callbacks will see the updated URL.
// TODO(crbug.com/809287) use currentItem.URL instead of self.webView.URL to
// update NavigationItem URL.
const GURL webViewURL = net::GURLWithNSURL(self.webView.URL);
web::NavigationItem* currentItem = nullptr;
if (self.webView.backForwardList.currentItem) {
currentItem = [[CRWNavigationItemHolder
holderForBackForwardListItem:self.webView.backForwardList.currentItem]
navigationItem];
} else {
// WKBackForwardList.currentItem may be nil in a corner case when
// location.replace is called with about:blank#hash in an empty window
// open tab. See crbug.com/866142.
DCHECK(self.webStateImpl->HasOpener());
DCHECK(!self.navigationManagerImpl->GetPendingItem());
currentItem = self.navigationManagerImpl->GetLastCommittedItem();
}
if (currentItem && webViewURL != currentItem->GetURL()) {
BOOL isRestoredURL = NO;
if (base::FeatureList::IsEnabled(web::features::kUseJSForErrorPage) &&
web::wk_navigation_util::IsRestoreSessionUrl(webViewURL)) {
GURL restoredURL;
web::wk_navigation_util::ExtractTargetURL(webViewURL, &restoredURL);
isRestoredURL = restoredURL == currentItem->GetURL();
}
if (!isRestoredURL)
currentItem->SetURL(webViewURL);
}
[self.delegate navigationObserver:self
URLDidChangeWithoutDocumentChange:URL];
} else if ([self isKVOChangePotentialSameDocumentNavigationToURL:URL]) {
WKNavigation* navigation =
[self.navigationHandler.navigationStates lastAddedNavigation];
[self.webView
evaluateJavaScript:@"window.location.href"
completionHandler:^(id result, NSError* error) {
// If the web view has gone away, or the location
// couldn't be retrieved, abort.
if (!self.webView || ![result isKindOfClass:[NSString class]]) {
return;
}
GURL JSURL(base::SysNSStringToUTF8(result));
// Check that window.location matches the new URL. If
// it does not, this is a document-changing URL change as
// the window location would not have changed to the new
// URL when the script was called.
BOOL windowLocationMatchesNewURL = JSURL == URL;
// Re-check origin in case navigaton has occurred since
// start of JavaScript evaluation.
BOOL newURLOriginMatchesDocumentURLOrigin =
self.documentURL.GetOrigin() == URL.GetOrigin();
// Check that the web view URL still matches the new URL.
// TODO(crbug.com/563568): webViewURLMatchesNewURL check
// may drop same document URL changes if pending URL
// change occurs immediately after. Revisit heuristics to
// prevent this.
BOOL webViewURLMatchesNewURL =
net::GURLWithNSURL(self.webView.URL) == URL;
// Check that the new URL is different from the current
// document URL. If not, URL change should not be reported.
BOOL URLDidChangeFromDocumentURL = URL != self.documentURL;
// Check if a new different document navigation started before the JS
// completion block fires. Check WKNavigationState to make sure this
// navigation has started in WKWebView. If so, don't run the block to
// avoid clobbering global states. See crbug.com/788452.
// TODO(crbug.com/788465): simplify hisgtory state handling to avoid
// this hack.
WKNavigation* last_added_navigation =
[self.navigationHandler.navigationStates lastAddedNavigation];
BOOL differentDocumentNavigationStarted =
navigation != last_added_navigation &&
[self.navigationHandler.navigationStates
stateForNavigation:last_added_navigation] >=
web::WKNavigationState::STARTED;
if (windowLocationMatchesNewURL &&
newURLOriginMatchesDocumentURLOrigin &&
webViewURLMatchesNewURL && URLDidChangeFromDocumentURL &&
!differentDocumentNavigationStarted) {
[self.delegate navigationObserver:self
URLDidChangeWithoutDocumentChange:URL];
}
}];
}
}
#pragma mark - Private
// Returns YES if a KVO change to |newURL| could be a 'navigation' within the
// document (hash change, pushState/replaceState, etc.). This should only be
// used in the context of a URL KVO callback firing, and only if |isLoading| is
// YES for the web view (since if it's not, no guesswork is needed).
- (BOOL)isKVOChangePotentialSameDocumentNavigationToURL:(const GURL&)newURL {
// If the origin changes, it can't be same-document.
if (self.documentURL.GetOrigin().is_empty() ||
self.documentURL.GetOrigin() != newURL.GetOrigin()) {
return NO;
}
if (self.navigationHandler.navigationState ==
web::WKNavigationState::REQUESTED) {
// Normally LOAD_REQUESTED indicates that this is a regular, pending
// navigation, but it can also happen during a fast-back navigation across
// a hash change, so that case is potentially a same-document navigation.
return web::GURLByRemovingRefFromGURL(newURL) ==
web::GURLByRemovingRefFromGURL(self.documentURL);
}
// If it passes all the checks above, it might be (but there's no guarantee
// that it is).
return YES;
}
@end