| // 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. |
| |
| #include "chrome/browser/3pcd_heuristics/opener_heuristic_tab_helper.h" |
| |
| #include <utility> |
| |
| #include "base/memory/weak_ptr.h" |
| #include "base/rand_util.h" |
| #include "base/time/clock.h" |
| #include "base/time/default_clock.h" |
| #include "base/time/time.h" |
| #include "chrome/browser/3pcd_heuristics/opener_heuristic_metrics.h" |
| #include "chrome/browser/3pcd_heuristics/opener_heuristic_utils.h" |
| #include "chrome/browser/dips/dips_bounce_detector.h" |
| #include "chrome/browser/dips/dips_service.h" |
| #include "chrome/browser/dips/dips_utils.h" |
| #include "content/public/browser/navigation_handle.h" |
| #include "content/public/browser/render_frame_host.h" |
| #include "net/cookies/site_for_cookies.h" |
| #include "services/metrics/public/cpp/ukm_builders.h" |
| #include "services/metrics/public/cpp/ukm_recorder.h" |
| |
| using content::NavigationHandle; |
| using content::RenderFrameHost; |
| using content::WebContents; |
| |
| namespace { |
| |
| // We don't need to protect this with a lock since it's only set while |
| // single-threaded in tests. |
| base::Clock* g_clock = nullptr; |
| |
| base::Clock* GetClock() { |
| return g_clock ? g_clock : base::DefaultClock::GetInstance(); |
| } |
| |
| } // namespace |
| |
| OpenerHeuristicTabHelper::OpenerHeuristicTabHelper(WebContents* web_contents) |
| : content::WebContentsObserver(web_contents), |
| content::WebContentsUserData<OpenerHeuristicTabHelper>(*web_contents) {} |
| |
| OpenerHeuristicTabHelper::~OpenerHeuristicTabHelper() = default; |
| |
| /* static */ |
| base::Clock* OpenerHeuristicTabHelper::SetClockForTesting(base::Clock* clock) { |
| return std::exchange(g_clock, clock); |
| } |
| |
| void OpenerHeuristicTabHelper::InitPopup( |
| const GURL& popup_url, |
| base::WeakPtr<OpenerHeuristicTabHelper> opener) { |
| popup_observer_ = |
| std::make_unique<PopupObserver>(web_contents(), popup_url, opener); |
| |
| DIPSService* dips = DIPSService::Get(web_contents()->GetBrowserContext()); |
| if (!dips) { |
| // If DIPS is disabled, we can't look up past interaction. |
| // TODO(rtarpine): consider falling back to SiteEngagementService. |
| return; |
| } |
| |
| dips->storage() |
| ->AsyncCall(&DIPSStorage::Read) |
| .WithArgs(popup_url) |
| .Then(base::BindOnce(&OpenerHeuristicTabHelper::GotPopupDipsState, |
| weak_factory_.GetWeakPtr())); |
| } |
| |
| void OpenerHeuristicTabHelper::GotPopupDipsState(const DIPSState& state) { |
| if (!state.user_interaction_times().has_value()) { |
| // No previous interaction. |
| return; |
| } |
| |
| popup_observer_->SetPastInteractionTime( |
| state.user_interaction_times().value().second); |
| } |
| |
| bool OpenerHeuristicTabHelper::HasSameSiteIframe(const GURL& popup_url) { |
| const auto popup_site = net::SiteForCookies::FromUrl(popup_url); |
| bool found = false; |
| |
| web_contents()->GetPrimaryMainFrame()->ForEachRenderFrameHostWithAction( |
| [&](RenderFrameHost* frame) { |
| if (frame->IsInPrimaryMainFrame()) { |
| // Continue to look at children of the main frame. |
| return RenderFrameHost::FrameIterationAction::kContinue; |
| } |
| |
| if (popup_site.IsFirstPartyWithSchemefulMode( |
| frame->GetLastCommittedURL(), /*compute_schemefully=*/false)) { |
| // We found a same-site iframe -- break out of the ForEach loop. |
| found = true; |
| return RenderFrameHost::FrameIterationAction::kStop; |
| } |
| |
| // Not same-site, so skip children and go to the next sibling iframe. |
| return RenderFrameHost::FrameIterationAction::kSkipChildren; |
| }); |
| |
| return found; |
| } |
| |
| void OpenerHeuristicTabHelper::PrimaryPageChanged(content::Page& page) { |
| page_id_++; |
| } |
| |
| void OpenerHeuristicTabHelper::DidOpenRequestedURL( |
| WebContents* new_contents, |
| RenderFrameHost* source_render_frame_host, |
| const GURL& url, |
| const content::Referrer& referrer, |
| WindowOpenDisposition disposition, |
| ui::PageTransition transition, |
| bool started_from_context_menu, |
| bool renderer_initiated) { |
| if (!source_render_frame_host->IsInPrimaryMainFrame()) { |
| return; |
| } |
| |
| if (source_render_frame_host != web_contents()->GetPrimaryMainFrame()) { |
| // Not sure exactly when this happens, but it seems to involve devtools. |
| // Cf. crbug.com/1448789 |
| return; |
| } |
| |
| if (disposition != WindowOpenDisposition::NEW_POPUP) { |
| // Ignore if not a popup. |
| return; |
| } |
| |
| if (!new_contents->HasOpener()) { |
| // Ignore if popup doesn't have opener access. |
| return; |
| } |
| |
| // Create an OpenerHeuristicTabHelper for the popup. |
| // |
| // Note: TabHelpers::AttachTabHelpers() creates OpenerHeuristicTabHelper, but |
| // on Android that can happen after DidOpenRequestedURL() is called (on other |
| // platforms it seems to happen first). So create it now if it doesn't already |
| // exist. |
| OpenerHeuristicTabHelper::CreateForWebContents(new_contents); |
| OpenerHeuristicTabHelper::FromWebContents(new_contents) |
| ->InitPopup(url, weak_factory_.GetWeakPtr()); |
| } |
| |
| WEB_CONTENTS_USER_DATA_KEY_IMPL(OpenerHeuristicTabHelper); |
| |
| OpenerHeuristicTabHelper::PopupObserver::PopupObserver( |
| WebContents* web_contents, |
| const GURL& initial_url, |
| base::WeakPtr<OpenerHeuristicTabHelper> opener) |
| : content::WebContentsObserver(web_contents), |
| popup_id_(static_cast<int32_t>(base::RandUint64())), |
| initial_url_(initial_url), |
| opener_(opener), |
| opener_page_id_(opener->page_id()), |
| opener_source_id_( |
| opener->web_contents()->GetPrimaryMainFrame()->GetPageUkmSourceId()) { |
| } |
| |
| OpenerHeuristicTabHelper::PopupObserver::~PopupObserver() = default; |
| |
| void OpenerHeuristicTabHelper::PopupObserver::SetPastInteractionTime( |
| base::Time time) { |
| CHECK(!time_since_interaction_.has_value()) |
| << "SetPastInteractionTime() called more than once"; |
| // Technically we should use the time when the pop-up first opened. But since |
| // we only report this metric at hourly granularity, it shouldn't matter. |
| time_since_interaction_ = GetClock()->Now() - time; |
| |
| // TODO(rtarpine): consider ignoring interactions that are too old. (This |
| // shouldn't happen since DIPS already discards old timestamps.) |
| |
| EmitPastInteractionIfReady(); |
| } |
| |
| void OpenerHeuristicTabHelper::PopupObserver::EmitPastInteractionIfReady() { |
| if (!time_since_interaction_.has_value() || !initial_source_id_.has_value()) { |
| // Not enough information to emit event yet. |
| return; |
| } |
| |
| auto has_iframe = GetOpenerHasSameSiteIframe(initial_url_); |
| ukm::builders::OpenerHeuristic_PopupPastInteraction( |
| initial_source_id_.value()) |
| .SetHoursSinceLastInteraction( |
| BucketizeHoursSinceLastInteraction(time_since_interaction_.value())) |
| .SetOpenerHasSameSiteIframe(static_cast<int64_t>(has_iframe)) |
| .SetPopupId(popup_id_) |
| .Record(ukm::UkmRecorder::Get()); |
| |
| EmitTopLevel(has_iframe); |
| } |
| |
| void OpenerHeuristicTabHelper::PopupObserver::DidFinishNavigation( |
| NavigationHandle* navigation_handle) { |
| if (!navigation_handle->IsInPrimaryMainFrame() || |
| !navigation_handle->HasCommitted() || |
| navigation_handle->IsSameDocument()) { |
| return; |
| } |
| |
| url_index_ += navigation_handle->GetRedirectChain().size(); |
| |
| if (initial_source_id_.has_value()) { |
| // Only get the source id and time for the first commit. Ignore the rest. |
| return; |
| } |
| |
| commit_time_ = GetClock()->Now(); |
| |
| if (navigation_handle->GetRedirectChain().size() > 1) { |
| // Get a source id for the URL the popup was originally opened with, |
| // even though the user was redirected elsewhere. |
| initial_source_id_ = GetInitialRedirectSourceId(navigation_handle); |
| } else { |
| // No redirect happened, get the source id for the committed page. |
| initial_source_id_ = navigation_handle->GetNextPageUkmSourceId(); |
| } |
| |
| EmitPastInteractionIfReady(); |
| } |
| |
| void OpenerHeuristicTabHelper::PopupObserver::FrameReceivedUserActivation( |
| RenderFrameHost* render_frame_host) { |
| if (!render_frame_host->IsInPrimaryMainFrame()) { |
| return; |
| } |
| |
| if (interaction_reported_) { |
| // Only report the first interaction. |
| return; |
| } |
| |
| if (!commit_time_.has_value()) { |
| // Not sure if this can happen. What happens if the user clicks before the |
| // popup loads its initial URL? |
| return; |
| } |
| |
| auto time_since_committed = GetClock()->Now() - *commit_time_; |
| auto has_iframe = |
| GetOpenerHasSameSiteIframe(render_frame_host->GetLastCommittedURL()); |
| ukm::builders::OpenerHeuristic_PopupInteraction( |
| render_frame_host->GetPageUkmSourceId()) |
| .SetSecondsSinceCommitted( |
| BucketizeSecondsSinceCommitted(time_since_committed)) |
| .SetUrlIndex(url_index_) |
| .SetOpenerHasSameSiteIframe(static_cast<int64_t>(has_iframe)) |
| .SetPopupId(popup_id_) |
| .Record(ukm::UkmRecorder::Get()); |
| |
| interaction_reported_ = true; |
| |
| EmitTopLevel(has_iframe); |
| } |
| |
| void OpenerHeuristicTabHelper::PopupObserver::EmitTopLevel( |
| OptionalBool has_iframe) { |
| if (toplevel_reported_) { |
| return; |
| } |
| |
| ukm::builders::OpenerHeuristic_TopLevel(opener_source_id_) |
| .SetHasSameSiteIframe(static_cast<int64_t>(has_iframe)) |
| .SetPopupProvider(static_cast<int64_t>(GetPopupProvider(initial_url_))) |
| .SetPopupId(popup_id_) |
| .Record(ukm::UkmRecorder::Get()); |
| |
| toplevel_reported_ = true; |
| } |
| |
| OptionalBool |
| OpenerHeuristicTabHelper::PopupObserver::GetOpenerHasSameSiteIframe( |
| const GURL& popup_url) { |
| if (opener_ && opener_->page_id() == opener_page_id_) { |
| return ToOptionalBool(opener_->HasSameSiteIframe(popup_url)); |
| } |
| |
| return OptionalBool::kUnknown; |
| } |