blob: 8a0c75a3a51a279ecafb4d662896bc0b2c40d168 [file] [log] [blame]
// Copyright 2015 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.
#include "chrome/browser/banners/app_banner_manager.h"
#include <algorithm>
#include <utility>
#include "base/bind.h"
#include "base/command_line.h"
#include "base/feature_list.h"
#include "base/metrics/histogram_macros.h"
#include "base/time/time.h"
#include "build/build_config.h"
#include "chrome/browser/banners/app_banner_manager_desktop.h"
#include "chrome/browser/banners/app_banner_metrics.h"
#include "chrome/browser/banners/app_banner_settings_helper.h"
#include "chrome/browser/browser_process.h"
#include "chrome/browser/engagement/site_engagement_service.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/common/chrome_features.h"
#include "chrome/common/chrome_switches.h"
#include "components/rappor/public/rappor_utils.h"
#include "components/rappor/rappor_service_impl.h"
#include "content/public/browser/navigation_handle.h"
#include "content/public/browser/render_frame_host.h"
#include "content/public/browser/web_contents.h"
#include "mojo/public/cpp/bindings/interface_request.h"
#include "services/service_manager/public/cpp/interface_provider.h"
#include "third_party/WebKit/public/platform/modules/installation/installation.mojom.h"
#include "third_party/skia/include/core/SkBitmap.h"
#if defined(OS_ANDROID)
#include "chrome/browser/banners/app_banner_manager_android.h"
#else
#include "chrome/browser/banners/app_banner_manager_desktop.h"
#endif
namespace {
int gTimeDeltaInDaysForTesting = 0;
InstallableParams ParamsToGetManifest() {
InstallableParams params;
params.check_eligibility = true;
return params;
}
} // anonymous namespace
namespace banners {
// static
base::Time AppBannerManager::GetCurrentTime() {
return base::Time::Now() +
base::TimeDelta::FromDays(gTimeDeltaInDaysForTesting);
}
// static
void AppBannerManager::SetTimeDeltaForTesting(int days) {
gTimeDeltaInDaysForTesting = days;
}
// static
void AppBannerManager::SetTotalEngagementToTrigger(double engagement) {
AppBannerSettingsHelper::SetTotalEngagementToTrigger(engagement);
}
class AppBannerManager::StatusReporter {
public:
virtual ~StatusReporter() {}
// Reports |code| (via a mechanism which depends on the implementation).
virtual void ReportStatus(InstallableStatusCode code) = 0;
};
} // namespace banners
namespace {
// Logs installable status codes to the console.
class ConsoleStatusReporter : public banners::AppBannerManager::StatusReporter {
public:
// Constructs a ConsoleStatusReporter which logs to the devtools console
// attached to |web_contents|.
explicit ConsoleStatusReporter(content::WebContents* web_contents)
: web_contents_(web_contents) {}
// Logs an error message corresponding to |code| to the devtools console.
void ReportStatus(InstallableStatusCode code) override {
LogErrorToConsole(web_contents_, code);
}
private:
content::WebContents* web_contents_;
};
// Tracks installable status codes via an UMA histogram.
class TrackingStatusReporter
: public banners::AppBannerManager::StatusReporter {
public:
TrackingStatusReporter() : done_(false) {}
~TrackingStatusReporter() override {}
// Records code via an UMA histogram.
void ReportStatus(InstallableStatusCode code) override {
// We only increment the histogram once per page load (and only if the
// banner pipeline is triggered).
if (!done_ && code != NO_ERROR_DETECTED)
banners::TrackInstallableStatusCode(code);
done_ = true;
}
private:
bool done_;
};
class NullStatusReporter : public banners::AppBannerManager::StatusReporter {
public:
void ReportStatus(InstallableStatusCode code) override {
// In general, NullStatusReporter::ReportStatus should not be called.
// However, it may be called in cases where Stop is called without a
// preceding call to RequestAppBanner e.g. because the WebContents is being
// destroyed. In that case, code should always be NO_ERROR_DETECTED.
DCHECK(code == NO_ERROR_DETECTED);
}
};
} // anonymous namespace
namespace banners {
void AppBannerManager::RequestAppBanner(const GURL& validated_url,
bool is_debug_mode) {
// The only time we should start the pipeline while it is already running is
// if it's been triggered from devtools.
if (state_ != State::INACTIVE) {
DCHECK(is_debug_mode);
weak_factory_.InvalidateWeakPtrs();
ResetBindings();
}
UpdateState(State::ACTIVE);
triggered_by_devtools_ = is_debug_mode;
// We only need to use TrackingStatusReporter if we aren't in debug mode
// (this avoids skew from testing).
if (IsDebugMode())
status_reporter_ = std::make_unique<ConsoleStatusReporter>(web_contents());
else
status_reporter_ = std::make_unique<TrackingStatusReporter>();
if (validated_url_.is_empty())
validated_url_ = validated_url;
UpdateState(State::FETCHING_MANIFEST);
manager_->GetData(
ParamsToGetManifest(),
base::Bind(&AppBannerManager::OnDidGetManifest, GetWeakPtr()));
}
void AppBannerManager::OnInstall(bool is_native,
blink::WebDisplayMode display) {
if (!is_native)
TrackInstallDisplayMode(display);
blink::mojom::InstallationServicePtr installation_service;
web_contents()->GetMainFrame()->GetRemoteInterfaces()->GetInterface(
mojo::MakeRequest(&installation_service));
DCHECK(installation_service);
installation_service->OnInstall();
}
void AppBannerManager::SendBannerAccepted() {
if (event_.is_bound())
event_->BannerAccepted(GetBannerType());
}
void AppBannerManager::SendBannerDismissed() {
if (event_.is_bound())
event_->BannerDismissed();
if (IsExperimentalAppBannersEnabled())
SendBannerPromptRequest();
}
base::WeakPtr<AppBannerManager> AppBannerManager::GetWeakPtr() {
return weak_factory_.GetWeakPtr();
}
AppBannerManager::AppBannerManager(content::WebContents* web_contents)
: content::WebContentsObserver(web_contents),
SiteEngagementObserver(SiteEngagementService::Get(
Profile::FromBrowserContext(web_contents->GetBrowserContext()))),
state_(State::INACTIVE),
manager_(InstallableManager::FromWebContents(web_contents)),
binding_(this),
has_sufficient_engagement_(false),
load_finished_(false),
triggered_by_devtools_(false),
status_reporter_(std::make_unique<NullStatusReporter>()),
installable_(Installable::UNKNOWN),
weak_factory_(this) {
DCHECK(manager_);
AppBannerSettingsHelper::UpdateFromFieldTrial();
}
AppBannerManager::~AppBannerManager() { }
std::string AppBannerManager::GetAppIdentifier() {
DCHECK(!manifest_.IsEmpty());
return manifest_.start_url.spec();
}
std::string AppBannerManager::GetBannerType() {
return "web";
}
bool AppBannerManager::HasSufficientEngagement() const {
return has_sufficient_engagement_ || IsDebugMode();
}
bool AppBannerManager::IsDebugMode() const {
return triggered_by_devtools_ ||
base::CommandLine::ForCurrentProcess()->HasSwitch(
switches::kBypassAppBannerEngagementChecks);
}
bool AppBannerManager::IsWebAppConsideredInstalled(
content::WebContents* web_contents,
const GURL& validated_url,
const GURL& start_url,
const GURL& manifest_url) {
return false;
}
void AppBannerManager::OnDidGetManifest(const InstallableData& data) {
UpdateState(State::ACTIVE);
if (data.error_code != NO_ERROR_DETECTED) {
Stop(data.error_code);
return;
}
DCHECK(!data.manifest_url.is_empty());
DCHECK(!data.manifest->IsEmpty());
manifest_url_ = data.manifest_url;
manifest_ = *data.manifest;
PerformInstallableCheck();
}
InstallableParams AppBannerManager::ParamsToPerformInstallableCheck() {
InstallableParams params;
params.valid_primary_icon = true;
params.valid_manifest = true;
params.has_worker = true;
// Don't wait for the service worker if this was triggered from devtools.
params.wait_for_worker = !triggered_by_devtools_;
return params;
}
void AppBannerManager::PerformInstallableCheck() {
if (!CheckIfShouldShowBanner())
return;
// Fetch and verify the other required information.
UpdateState(State::PENDING_INSTALLABLE_CHECK);
manager_->GetData(ParamsToPerformInstallableCheck(),
base::Bind(&AppBannerManager::OnDidPerformInstallableCheck,
GetWeakPtr()));
}
// static
AppBannerManager::Installable AppBannerManager::GetInstallable(
content::WebContents* web_contents) {
AppBannerManager* manager = nullptr;
#if defined(OS_ANDROID)
manager = AppBannerManagerAndroid::FromWebContents(web_contents);
#else
manager = AppBannerManagerDesktop::FromWebContents(web_contents);
#endif
return manager ? manager->installable() : Installable::UNKNOWN;
}
AppBannerManager::Installable AppBannerManager::installable() const {
return installable_;
}
void AppBannerManager::OnDidPerformInstallableCheck(
const InstallableData& data) {
UpdateState(State::ACTIVE);
if (data.has_worker && data.valid_manifest)
TrackDisplayEvent(DISPLAY_EVENT_WEB_APP_BANNER_REQUESTED);
installable_ = data.error_code == NO_ERROR_DETECTED
? Installable::INSTALLABLE_YES
: Installable::INSTALLABLE_NO;
if (data.error_code != NO_ERROR_DETECTED) {
if (data.error_code == NO_MATCHING_SERVICE_WORKER)
TrackDisplayEvent(DISPLAY_EVENT_LACKS_SERVICE_WORKER);
Stop(data.error_code);
return;
}
DCHECK(data.has_worker && data.valid_manifest);
DCHECK(!data.primary_icon_url.is_empty());
DCHECK(data.primary_icon);
primary_icon_url_ = data.primary_icon_url;
primary_icon_ = *data.primary_icon;
// If we triggered the installability check on page load, then it's possible
// we don't have enough engagement yet. If that's the case, return here but
// don't call Terminate(). We wait for OnEngagementEvent to tell us that we
// should trigger.
if (!HasSufficientEngagement()) {
UpdateState(State::PENDING_ENGAGEMENT);
return;
}
SendBannerPromptRequest();
}
void AppBannerManager::RecordDidShowBanner(const std::string& event_name) {
content::WebContents* contents = web_contents();
DCHECK(contents);
AppBannerSettingsHelper::RecordBannerEvent(
contents, validated_url_, GetAppIdentifier(),
AppBannerSettingsHelper::APP_BANNER_EVENT_DID_SHOW,
GetCurrentTime());
rappor::SampleDomainAndRegistryFromGURL(g_browser_process->rappor_service(),
event_name,
contents->GetLastCommittedURL());
}
void AppBannerManager::ReportStatus(InstallableStatusCode code) {
DCHECK(status_reporter_);
status_reporter_->ReportStatus(code);
}
void AppBannerManager::ResetCurrentPageData() {
active_media_players_.clear();
manifest_ = content::Manifest();
manifest_url_ = GURL();
validated_url_ = GURL();
referrer_.erase();
installable_ = Installable::UNKNOWN;
}
void AppBannerManager::Terminate() {
if (state_ == State::PENDING_PROMPT)
TrackBeforeInstallEvent(
BEFORE_INSTALL_EVENT_PROMPT_NOT_CALLED_AFTER_PREVENT_DEFAULT);
if (state_ == State::PENDING_ENGAGEMENT && !has_sufficient_engagement_)
TrackDisplayEvent(DISPLAY_EVENT_NOT_VISITED_ENOUGH);
Stop(TerminationCode());
}
InstallableStatusCode AppBannerManager::TerminationCode() const {
switch (state_) {
case State::PENDING_PROMPT:
return RENDERER_CANCELLED;
case State::PENDING_ENGAGEMENT:
return has_sufficient_engagement_ ? NO_ERROR_DETECTED
: INSUFFICIENT_ENGAGEMENT;
case State::FETCHING_MANIFEST:
return WAITING_FOR_MANIFEST;
case State::FETCHING_NATIVE_DATA:
return WAITING_FOR_NATIVE_DATA;
case State::PENDING_INSTALLABLE_CHECK:
return WAITING_FOR_INSTALLABLE_CHECK;
case State::ACTIVE:
case State::SENDING_EVENT:
case State::SENDING_EVENT_GOT_EARLY_PROMPT:
case State::INACTIVE:
case State::COMPLETE:
break;
}
return NO_ERROR_DETECTED;
}
void AppBannerManager::Stop(InstallableStatusCode code) {
ReportStatus(code);
weak_factory_.InvalidateWeakPtrs();
ResetBindings();
UpdateState(State::COMPLETE);
status_reporter_ = std::make_unique<NullStatusReporter>(),
has_sufficient_engagement_ = false;
}
void AppBannerManager::SendBannerPromptRequest() {
RecordCouldShowBanner();
UpdateState(State::SENDING_EVENT);
TrackBeforeInstallEvent(BEFORE_INSTALL_EVENT_CREATED);
// Any existing binding is invalid when we send a new beforeinstallprompt.
ResetBindings();
blink::mojom::AppBannerControllerPtr controller;
web_contents()->GetMainFrame()->GetRemoteInterfaces()->GetInterface(
mojo::MakeRequest(&controller));
blink::mojom::AppBannerServicePtr banner_proxy;
binding_.Bind(mojo::MakeRequest(&banner_proxy));
// Get a raw controller pointer before we move out of the smart pointer to
// avoid crashing with MSVC's order of evaluation.
blink::mojom::AppBannerController* controller_ptr = controller.get();
controller_ptr->BannerPromptRequest(
std::move(banner_proxy), mojo::MakeRequest(&event_), {GetBannerType()},
base::BindOnce(&AppBannerManager::OnBannerPromptReply, GetWeakPtr(),
base::Passed(&controller)));
}
void AppBannerManager::UpdateState(State state) {
state_ = state;
}
void AppBannerManager::DidStartNavigation(content::NavigationHandle* handle) {
if (!handle->IsInMainFrame() || handle->IsSameDocument())
return;
if (state_ != State::COMPLETE && state_ != State::INACTIVE)
Terminate();
UpdateState(State::INACTIVE);
load_finished_ = false;
has_sufficient_engagement_ = false;
}
void AppBannerManager::DidFinishNavigation(content::NavigationHandle* handle) {
if (handle->IsInMainFrame() && handle->HasCommitted() &&
!handle->IsSameDocument()) {
ResetCurrentPageData();
}
}
void AppBannerManager::DidFinishLoad(
content::RenderFrameHost* render_frame_host,
const GURL& validated_url) {
// Don't start the banner flow unless the main frame has finished loading.
if (render_frame_host->GetParent())
return;
load_finished_ = true;
validated_url_ = validated_url;
// If we already have enough engagement, or require no engagement to trigger
// the banner, the rest of the banner pipeline should operate as if the
// engagement threshold has been met.
if (AppBannerSettingsHelper::HasSufficientEngagement(0) ||
AppBannerSettingsHelper::HasSufficientEngagement(
GetSiteEngagementService()->GetScore(validated_url))) {
has_sufficient_engagement_ = true;
}
// Start the pipeline immediately if we pass (or bypass) the engagement check,
// or if the feature to run the installability check on page load is enabled.
if (state_ == State::INACTIVE &&
(has_sufficient_engagement_ ||
base::FeatureList::IsEnabled(
features::kCheckInstallabilityForBannerOnLoad))) {
RequestAppBanner(validated_url, false /* is_debug_mode */);
}
}
void AppBannerManager::MediaStartedPlaying(const MediaPlayerInfo& media_info,
const MediaPlayerId& id) {
active_media_players_.push_back(id);
}
void AppBannerManager::MediaStoppedPlaying(
const MediaPlayerInfo& media_info,
const MediaPlayerId& id,
WebContentsObserver::MediaStoppedReason reason) {
active_media_players_.erase(std::remove(active_media_players_.begin(),
active_media_players_.end(), id),
active_media_players_.end());
}
void AppBannerManager::WebContentsDestroyed() {
Terminate();
}
void AppBannerManager::OnEngagementEvent(
content::WebContents* contents,
const GURL& url,
double score,
SiteEngagementService::EngagementType /*type*/) {
// Only trigger a banner using site engagement if:
// 1. engagement increased for the web contents which we are attached to; and
// 2. there are no currently active media players; and
// 3. we have accumulated sufficient engagement.
if (web_contents() == contents && active_media_players_.empty() &&
AppBannerSettingsHelper::HasSufficientEngagement(score)) {
has_sufficient_engagement_ = true;
if (state_ == State::PENDING_ENGAGEMENT) {
// We have already finished the installability eligibility checks. Proceed
// directly to sending the banner prompt request.
UpdateState(State::ACTIVE);
SendBannerPromptRequest();
} else if (load_finished_ && state_ == State::INACTIVE) {
// This performs some simple tests and starts async checks to test
// installability. It should be safe to start in response to user input.
// Don't call if we're already working on processing a banner request.
RequestAppBanner(url, false /* is_debug_mode */);
}
}
}
bool AppBannerManager::IsRunning() const {
switch (state_) {
case State::INACTIVE:
case State::PENDING_PROMPT:
case State::PENDING_ENGAGEMENT:
case State::COMPLETE:
return false;
case State::ACTIVE:
case State::FETCHING_MANIFEST:
case State::FETCHING_NATIVE_DATA:
case State::PENDING_INSTALLABLE_CHECK:
case State::SENDING_EVENT:
case State::SENDING_EVENT_GOT_EARLY_PROMPT:
return true;
}
return false;
}
// static
bool AppBannerManager::IsExperimentalAppBannersEnabled() {
return base::FeatureList::IsEnabled(features::kExperimentalAppBanners) ||
base::FeatureList::IsEnabled(features::kDesktopPWAWindowing);
}
void AppBannerManager::ResetBindings() {
binding_.Close();
event_.reset();
}
void AppBannerManager::RecordCouldShowBanner() {
content::WebContents* contents = web_contents();
DCHECK(contents);
AppBannerSettingsHelper::RecordBannerEvent(
contents, validated_url_, GetAppIdentifier(),
AppBannerSettingsHelper::APP_BANNER_EVENT_COULD_SHOW, GetCurrentTime());
}
InstallableStatusCode AppBannerManager::ShouldShowBannerCode() {
if (GetAppIdentifier().empty())
return PACKAGE_NAME_OR_START_URL_EMPTY;
content::WebContents* contents = web_contents();
if (IsWebAppConsideredInstalled(contents, validated_url_, manifest_.start_url,
manifest_url_)) {
return ALREADY_INSTALLED;
}
// Showing of experimental app banners is under developer control, and
// requires a user gesture. In contrast, showing of traditional app banners
// is automatic, so we throttle it if the user has recently ignored or
// blocked the banner.
if (!IsExperimentalAppBannersEnabled()) {
base::Time now = GetCurrentTime();
if (AppBannerSettingsHelper::WasBannerRecentlyBlocked(
contents, validated_url_, GetAppIdentifier(), now)) {
return PREVIOUSLY_BLOCKED;
}
if (AppBannerSettingsHelper::WasBannerRecentlyIgnored(
contents, validated_url_, GetAppIdentifier(), now)) {
return PREVIOUSLY_IGNORED;
}
}
return NO_ERROR_DETECTED;
}
bool AppBannerManager::CheckIfShouldShowBanner() {
if (IsDebugMode())
return true;
InstallableStatusCode code = ShouldShowBannerCode();
switch (code) {
case NO_ERROR_DETECTED:
return true;
case ALREADY_INSTALLED:
banners::TrackDisplayEvent(banners::DISPLAY_EVENT_INSTALLED_PREVIOUSLY);
break;
case PREVIOUSLY_BLOCKED:
banners::TrackDisplayEvent(banners::DISPLAY_EVENT_BLOCKED_PREVIOUSLY);
break;
case PREVIOUSLY_IGNORED:
banners::TrackDisplayEvent(banners::DISPLAY_EVENT_IGNORED_PREVIOUSLY);
break;
case PACKAGE_NAME_OR_START_URL_EMPTY:
break;
default:
NOTREACHED();
}
Stop(code);
return false;
}
void AppBannerManager::OnBannerPromptReply(
blink::mojom::AppBannerControllerPtr controller,
blink::mojom::AppBannerPromptReply reply,
const std::string& referrer) {
// The renderer might have requested the prompt to be canceled. They may
// request that it is redisplayed later, so don't Terminate() here. However,
// log that the cancelation was requested, so Terminate() can be called if a
// redisplay isn't asked for.
//
// If the redisplay request has not been received already, we stop here and
// wait for the prompt function to be called. If the redisplay request has
// already been received before cancel was sent (e.g. if redisplay was
// requested in the beforeinstallprompt event handler), we keep going and show
// the banner immediately.
referrer_ = referrer;
if (reply == blink::mojom::AppBannerPromptReply::CANCEL) {
TrackBeforeInstallEvent(BEFORE_INSTALL_EVENT_PREVENT_DEFAULT_CALLED);
if (IsDebugMode()) {
web_contents()->GetMainFrame()->AddMessageToConsole(
content::CONSOLE_MESSAGE_LEVEL_INFO,
"Banner not shown: beforeinstallpromptevent.preventDefault() called. "
"The page must call beforeinstallpromptevent.prompt() to show the "
"banner.");
}
}
bool need_prompt = IsExperimentalAppBannersEnabled() ||
reply == blink::mojom::AppBannerPromptReply::CANCEL;
if (need_prompt && state_ == State::SENDING_EVENT) {
UpdateState(State::PENDING_PROMPT);
return;
}
DCHECK(!need_prompt || State::SENDING_EVENT_GOT_EARLY_PROMPT == state_);
ShowBanner();
}
void AppBannerManager::ShowBanner() {
WebAppInstallSource install_source;
// If we are still in the SENDING_EVENT state, the prompt was never canceled
// by the page. Otherwise the page requested a delayed showing of the prompt.
if (state_ == State::SENDING_EVENT) {
// In the experimental flow, the banner is only shown if the site explicitly
// requests it to be shown.
DCHECK(!IsExperimentalAppBannersEnabled());
TrackBeforeInstallEvent(BEFORE_INSTALL_EVENT_NO_ACTION);
install_source = WebAppInstallSource::AUTOMATIC_PROMPT;
} else {
TrackBeforeInstallEvent(
BEFORE_INSTALL_EVENT_PROMPT_CALLED_AFTER_PREVENT_DEFAULT);
install_source = WebAppInstallSource::API;
}
// If this is the first time that we are showing the banner for this site,
// record how long it's been since the first visit.
if (AppBannerSettingsHelper::GetSingleBannerEvent(
web_contents(), validated_url_, GetAppIdentifier(),
AppBannerSettingsHelper::APP_BANNER_EVENT_DID_SHOW)
.is_null()) {
AppBannerSettingsHelper::RecordMinutesFromFirstVisitToShow(
web_contents(), validated_url_, GetAppIdentifier(), GetCurrentTime());
}
DCHECK(!manifest_url_.is_empty());
DCHECK(!manifest_.IsEmpty());
DCHECK(!primary_icon_url_.is_empty());
DCHECK(!primary_icon_.drawsNothing());
TrackBeforeInstallEvent(BEFORE_INSTALL_EVENT_COMPLETE);
ShowBannerUi(install_source);
UpdateState(State::COMPLETE);
}
void AppBannerManager::DisplayAppBanner(bool user_gesture) {
if (IsExperimentalAppBannersEnabled() && !user_gesture) {
Stop(NO_GESTURE);
return;
}
if (state_ == State::PENDING_PROMPT) {
ShowBanner();
} else if (state_ == State::SENDING_EVENT) {
// Log that the prompt request was made for when we get the prompt reply.
UpdateState(State::SENDING_EVENT_GOT_EARLY_PROMPT);
}
}
} // namespace banners