blob: 3bbd4e568ba99245622a96f0801d2b6cd203025f [file] [log] [blame]
// Copyright 2020 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/ui/views/hats/hats_next_web_dialog.h"
#include "base/base64url.h"
#include "base/feature_list.h"
#include "base/json/json_writer.h"
#include "base/memory/raw_ptr.h"
#include "base/metrics/histogram_functions.h"
#include "base/notimplemented.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/string_util.h"
#include "base/strings/to_string.h"
#include "base/time/time.h"
#include "base/values.h"
#include "chrome/browser/browser_process.h"
#include "chrome/browser/devtools/devtools_window.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/profiles/profile_destroyer.h"
#include "chrome/browser/ui/browser_dialogs.h"
#include "chrome/browser/ui/browser_element_identifiers.h"
#include "chrome/browser/ui/browser_window/public/browser_window_interface.h"
#include "chrome/browser/ui/hats/hats_service.h"
#include "chrome/browser/ui/hats/hats_service_desktop.h"
#include "chrome/browser/ui/hats/hats_service_factory.h"
#include "chrome/browser/ui/hats/survey_config.h"
#include "chrome/browser/ui/ui_features.h"
#include "chrome/browser/ui/views/frame/app_menu_button.h"
#include "chrome/browser/ui/views/frame/browser_view.h"
#include "chrome/browser/ui/views/frame/toolbar_button_provider.h"
#include "chrome/browser/ui/views/frame/top_container_view.h"
#include "chrome/browser/ui/webui/chrome_web_contents_handler.h"
#include "chrome/common/chrome_features.h"
#include "chrome/common/chrome_isolated_world_ids.h"
#include "chrome/common/pref_names.h"
#include "chrome/common/webui_url_constants.h"
#include "components/constrained_window/constrained_window_views.h"
#include "components/version_info/version_info.h"
#include "content/public/browser/browser_thread.h"
#include "content/public/browser/navigation_handle.h"
#include "content/public/browser/render_frame_host.h"
#include "content/public/browser/web_ui.h"
#include "net/base/url_util.h"
#include "services/metrics/public/cpp/ukm_builders.h"
#include "services/metrics/public/cpp/ukm_recorder.h"
#include "third_party/blink/public/common/page/page_zoom.h"
#include "third_party/re2/src/re2/re2.h"
#include "ui/base/metadata/metadata_header_macros.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/base/mojom/dialog_button.mojom.h"
#include "ui/base/ui_base_types.h"
#include "ui/views/bubble/bubble_dialog_delegate_view.h"
#include "ui/views/bubble/bubble_frame_view.h"
#include "ui/views/controls/webview/web_dialog_view.h"
#include "ui/views/controls/webview/webview.h"
#include "ui/views/layout/fill_layout.h"
#include "ui/views/view_class_properties.h"
#include "ui/web_dialogs/web_dialog_delegate.h"
constexpr gfx::Size HatsNextWebDialog::kMinSize;
constexpr gfx::Size HatsNextWebDialog::kMaxSize;
constexpr char kSurveyQuestionAnsweredRegex[] = "answer-(\\d+)-((?:\\d+,?)+)";
constexpr char kSurveyQuestionAnsweredAnswerRegex[] = "(\\d+),?";
constexpr char kHatsSurveyCompletedHistogram[] =
"Feedback.HappinessTrackingSurvey.SurveyCompleted";
void LogUmaHistogramSparse(
const std::optional<std::string>& hats_histogram_name,
int enumeration) {
if (hats_histogram_name.has_value()) {
base::UmaHistogramSparse(hats_histogram_name.value(), enumeration);
}
}
void LogUmaHistogramSparse(
const std::optional<std::string>& hats_histogram_name,
HatsNextWebDialog::SurveyHistogramEnumeration enumeration) {
return LogUmaHistogramSparse(hats_histogram_name,
static_cast<int>(enumeration));
}
// WebView which contains the WebContents displaying the HaTS Next survey.
class HatsNextWebDialog::HatsWebView : public views::WebView {
METADATA_HEADER(HatsWebView, views::WebView)
public:
HatsWebView(content::BrowserContext* browser_context,
BrowserWindowInterface* browser,
HatsNextWebDialog* dialog)
: views::WebView(browser_context), dialog_(dialog), browser_(browser) {}
~HatsWebView() override = default;
// content::WebContentsDelegate:
bool HandleContextMenu(content::RenderFrameHost& render_frame_host,
const content::ContextMenuParams& params) override {
// Ignores context menu.
return true;
}
bool IsWebContentsCreationOverridden(
content::RenderFrameHost* opener,
content::SiteInstance* source_site_instance,
content::mojom::WindowContainerType window_container_type,
const GURL& opener_url,
const std::string& frame_name,
const GURL& target_url) override {
return true;
}
content::WebContents* CreateCustomWebContents(
content::RenderFrameHost* opener,
content::SiteInstance* source_site_instance,
bool is_new_browsing_instance,
const GURL& opener_url,
const std::string& frame_name,
const GURL& target_url,
const content::StoragePartitionConfig& partition_config,
content::SessionStorageNamespace* session_storage_namespace) override {
// The HaTS Next WebDialog runs with a non-primary OTR profile. This profile
// cannot open new browser windows, so they are instead opened in the
// regular browser that initiated the HaTS survey.
content::OpenURLParams params =
content::OpenURLParams(target_url, content::Referrer(),
WindowOpenDisposition::NEW_FOREGROUND_TAB,
ui::PAGE_TRANSITION_LINK, false);
// For the case where we are showing a survey in an undocked DevTools
// window, we open the URL in the browser of the inspected page.
if (browser_->GetType() == BrowserWindowInterface::Type::TYPE_DEVTOOLS) {
DevToolsWindow* devtools_window =
DevToolsWindow::AsDevToolsWindow(browser_);
DCHECK(devtools_window);
devtools_window->OpenURLFromInspectedTab(params);
} else {
browser_->OpenURL(params, /*navigation_handle_callback=*/{});
}
return nullptr;
}
// TODO(crbug.com/40285934): Remove this whole function after HaTSWebUI is
// launched.
// content::WebContentsObserver:
void DidStartNavigation(
content::NavigationHandle* navigation_handle) override {
if (navigation_handle->IsSameDocument() &&
navigation_handle->IsRendererInitiated()) {
dialog_->OnSurveyStateUpdateReceived(
navigation_handle->GetURL().GetRef());
}
}
private:
raw_ptr<HatsNextWebDialog> dialog_;
raw_ptr<BrowserWindowInterface> browser_;
};
BEGIN_METADATA(HatsNextWebDialog, HatsWebView)
END_METADATA
HatsNextWebDialog::HatsNextWebDialog(
BrowserWindowInterface* browser,
const std::string& trigger_id,
const std::optional<std::string>& hats_histogram_name,
const std::optional<uint64_t> hats_survey_ukm_id,
base::OnceClosure success_callback,
base::OnceClosure failure_callback,
const SurveyBitsData& product_specific_bits_data,
const SurveyStringData& product_specific_string_data)
: HatsNextWebDialog(
browser,
trigger_id,
hats_histogram_name,
hats_survey_ukm_id,
GURL(features::kHappinessTrackingSurveysHostedUrl.Get()),
base::Seconds(10),
std::move(success_callback),
std::move(failure_callback),
product_specific_bits_data,
product_specific_string_data) {}
gfx::Size HatsNextWebDialog::CalculatePreferredSize(
const views::SizeBounds& available_size) const {
gfx::Size preferred_size =
views::View::CalculatePreferredSize(available_size);
preferred_size.SetToMax(kMinSize);
preferred_size.SetToMin(kMaxSize);
return preferred_size;
}
void HatsNextWebDialog::OnProfileWillBeDestroyed(Profile* profile) {
DCHECK_EQ(profile, otr_profile_);
otr_profile_ = nullptr;
}
std::optional<std::string> HatsNextWebDialog::GetHistogramName() {
return hats_histogram_name_;
}
void HatsNextWebDialog::OnSurveyLoaded() {
// If this has already been called, do nothing. This makes the code robust,
// should it be called multiple times.
if (received_survey_loaded_) {
return;
}
loading_timer_.Stop();
// Record that the survey was shown, and display the widget.
auto* service =
HatsServiceFactory::GetForProfile(browser_->GetProfile(), false);
DCHECK(service);
service->RecordSurveyAsShown(trigger_id_);
received_survey_loaded_ = true;
ShowWidget();
LogUmaHistogramSparse(hats_histogram_name_,
SurveyHistogramEnumeration::kSurveyLoadedEnumeration);
if (hats_survey_ukm_id_.has_value()) {
ukm_hats_builder_.SetSurveyLoaded(true);
}
std::move(success_callback_).Run();
}
void HatsNextWebDialog::OnSurveyCompleted() {
base::UmaHistogramBoolean(kHatsSurveyCompletedHistogram, true);
LogUmaHistogramSparse(
hats_histogram_name_,
SurveyHistogramEnumeration::kSurveyCompletedEnumeration);
if (hats_survey_ukm_id_.has_value()) {
ukm_hats_builder_.SetSurveyCompleted(true);
}
}
void HatsNextWebDialog::OnSurveyClosed() {
loading_timer_.Stop();
if (!received_survey_loaded_) {
// Receiving a close state prior to a loaded state indicates that contact
// was made with the HaTS Next service, but the HaTS service declined the
// survey request. This is likely because of a survey misconfiguration,
// such as a survey still being in test mode, or an invalid survey ID.
base::UmaHistogramEnumeration(
kHatsShouldShowSurveyReasonHistogram,
HatsServiceDesktop::ShouldShowSurveyReasons::kNoRejectedByHatsService);
std::move(failure_callback_).Run();
}
if (hats_survey_ukm_id_.has_value()) {
ukm_hats_builder_.Record(ukm::UkmRecorder::Get());
}
CloseWidget();
}
void HatsNextWebDialog::OnSurveyQuestionAnswered(const std::string& state) {
if (!hats_histogram_name_.has_value() && !hats_survey_ukm_id_.has_value()) {
return;
}
int question;
std::vector<int> question_answers;
if (!ParseSurveyQuestionAnswer(state, &question, &question_answers)) {
LogUmaHistogramSparse(
hats_histogram_name_,
SurveyHistogramEnumeration::kSurveyQuestionAnswerParseError);
return;
}
if (hats_survey_ukm_id_.has_value()) {
uint64_t ukm_value = EncodeUkmQuestionAnswers(question_answers);
switch (question) {
case 1:
ukm_hats_builder_.SetSurveyAnswerToQuestion1(ukm_value);
break;
case 2:
ukm_hats_builder_.SetSurveyAnswerToQuestion2(ukm_value);
break;
case 3:
ukm_hats_builder_.SetSurveyAnswerToQuestion3(ukm_value);
break;
case 4:
ukm_hats_builder_.SetSurveyAnswerToQuestion4(ukm_value);
break;
case 5:
ukm_hats_builder_.SetSurveyAnswerToQuestion5(ukm_value);
break;
case 6:
ukm_hats_builder_.SetSurveyAnswerToQuestion6(ukm_value);
break;
case 7:
ukm_hats_builder_.SetSurveyAnswerToQuestion7(ukm_value);
break;
case 8:
ukm_hats_builder_.SetSurveyAnswerToQuestion8(ukm_value);
break;
case 9:
ukm_hats_builder_.SetSurveyAnswerToQuestion9(ukm_value);
break;
}
}
if (hats_histogram_name_.has_value()) {
for (int answer : question_answers) {
LogUmaHistogramSparse(hats_histogram_name_,
GetHistogramBucket(question, answer));
}
}
}
// static
bool HatsNextWebDialog::ParseSurveyQuestionAnswer(const std::string& input,
int* question,
std::vector<int>* answers) {
std::string question_num_string;
re2::StringPiece all_answers_string;
if (!RE2::FullMatch(input, kSurveyQuestionAnsweredRegex, &question_num_string,
&all_answers_string)) {
return false;
}
if (!base::StringToInt(question_num_string, question) || *question <= 0 ||
*question > 10) {
return false;
}
std::string answer_string;
while (RE2::FindAndConsume(&all_answers_string,
kSurveyQuestionAnsweredAnswerRegex,
&answer_string)) {
int answer;
if (!base::StringToInt(answer_string, &answer) || answer <= 0 ||
answer > 100) {
return false;
}
answers->push_back(answer);
}
return true;
}
// static
uint64_t HatsNextWebDialog::EncodeUkmQuestionAnswers(
const std::vector<int>& question_answers) {
uint64_t ukm_value = 0;
for (int answer : question_answers) {
if (answer > 0) {
ukm_value |= 1 << (answer - 1);
}
}
return ukm_value;
}
HatsNextWebDialog::HatsNextWebDialog(
BrowserWindowInterface* browser,
const std::string& trigger_id,
const std::optional<std::string>& hats_histogram_name,
const std::optional<uint64_t> hats_survey_ukm_id,
const GURL& hats_survey_url,
const base::TimeDelta& timeout,
base::OnceClosure success_callback,
base::OnceClosure failure_callback,
const SurveyBitsData& product_specific_bits_data,
const SurveyStringData& product_specific_string_data)
: BubbleDialogDelegateView(
browser->GetType() == BrowserWindowInterface::Type::TYPE_DEVTOOLS
? static_cast<views::View*>(
BrowserView::GetBrowserViewForBrowser(browser)
->top_container())
: BrowserView::GetBrowserViewForBrowser(browser)
->toolbar_button_provider()
->GetAppMenuButton(),
views::BubbleBorder::TOP_RIGHT,
views::BubbleBorder::DIALOG_SHADOW,
/*autosize=*/true),
otr_profile_(browser->GetProfile()->GetOffTheRecordProfile(
Profile::OTRProfileID::CreateUnique("HaTSNext:WebDialog"),
/*create_if_needed=*/true)),
browser_(browser),
browser_close_subscription_(browser->RegisterBrowserDidClose(
base::BindRepeating(&HatsNextWebDialog::BrowserDidClose,
base::Unretained(this)))),
trigger_id_(trigger_id),
hats_histogram_name_(
hats::SurveyConfig::ValidateHatsHistogramName(hats_histogram_name)),
hats_survey_ukm_id_(
hats::SurveyConfig::ValidateHatsSurveyUkmId(hats_survey_ukm_id)),
hats_survey_url_(hats_survey_url),
timeout_(timeout),
success_callback_(std::move(success_callback)),
failure_callback_(std::move(failure_callback)),
product_specific_bits_data_(product_specific_bits_data),
product_specific_string_data_(product_specific_string_data),
ukm_hats_builder_(browser->GetTabStripModel()
->GetActiveWebContents()
->GetPrimaryMainFrame()
->GetPageUkmSourceId()) {
DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
otr_profile_->AddObserver(this);
set_close_on_deactivate(false);
// Override the default zoom level for ths HaTS dialog. Its size should align
// with native UI elements, rather than web content.
content::HostZoomMap::GetDefaultForBrowserContext(otr_profile_)
->SetZoomLevelForHost(hats_survey_url_.GetHost(),
blink::ZoomFactorToZoomLevel(1.0f));
SetButtons(static_cast<int>(ui::mojom::DialogButton::kNone));
SetLayoutManager(std::make_unique<views::FillLayout>());
web_view_ =
AddChildView(std::make_unique<HatsWebView>(otr_profile_, browser, this));
web_view_->LoadInitialURL(GetParameterizedHatsURL());
web_view_->EnableSizingFromWebContents(kMinSize, kMaxSize);
SetProperty(views::kElementIdentifierKey, kHatsNextWebDialogId);
set_margins(gfx::Insets());
widget_ = views::BubbleDialogDelegateView::CreateBubble(this);
if (hats_survey_ukm_id_.has_value()) {
ukm_hats_builder_.SetSurveyId(hats_survey_ukm_id_.value());
ukm_hats_builder_.SetSurveyLoaded(false);
ukm_hats_builder_.SetSurveyCompleted(false);
}
loading_timer_.Start(FROM_HERE, timeout_,
base::BindOnce(&HatsNextWebDialog::LoadTimedOut,
weak_factory_.GetWeakPtr()));
}
HatsNextWebDialog::~HatsNextWebDialog() {
#if IS_ANDROID
NOTIMPLEMENTED(); // This class is for desktop only. Enforce assumption.
#endif
DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
if (otr_profile_) {
otr_profile_->RemoveObserver(this);
ProfileDestroyer::DestroyOTRProfileWhenAppropriate(otr_profile_);
}
HatsServiceDesktop* service = static_cast<HatsServiceDesktop*>(
HatsServiceFactory::GetForProfile(browser_->GetProfile(), false));
DCHECK(service);
service->HatsNextDialogClosed();
// Explicitly clear the delegate to ensure it is not invalid between now and
// when the web contents is destroyed in the base class.
web_view_->web_contents()->SetDelegate(nullptr);
}
// TODO(crbug.com/40285934): Remove this whole function after HaTSWebUI is
// launched.
GURL HatsNextWebDialog::GetParameterizedHatsURL() const {
GURL param_url =
net::AppendQueryParameter(hats_survey_url_, "trigger_id", trigger_id_);
// Append any Product Specific Data to the query. This will be interpreted
// by the wrapper website and provided to the HaTS backend service.
base::Value::Dict dict;
for (const auto& field_value : product_specific_bits_data_) {
dict.Set(field_value.first, base::ToString(field_value.second));
}
for (const auto& field_value : product_specific_string_data_) {
dict.Set(field_value.first, field_value.second);
}
std::string product_specific_data_json = base::WriteJson(dict).value_or("");
param_url = net::AppendQueryParameter(param_url, "product_specific_data",
product_specific_data_json);
// The HaTS backend service accepts a list of preferred languages, although
// only the application locale is provided here to ensure that the survey
// matches the native UI language.
base::Value::List language_list;
language_list.Append(g_browser_process->GetApplicationLocale());
std::string language_list_json = base::WriteJson(language_list).value_or("");
param_url =
net::AppendQueryParameter(param_url, "languages", language_list_json);
if (base::FeatureList::IsEnabled(
features::kHappinessTrackingSurveysForDesktopDemo)) {
param_url = net::AppendQueryParameter(param_url, "enable_testing", "true");
}
return param_url;
}
void HatsNextWebDialog::LoadTimedOut() {
load_timed_out_ = true;
base::UmaHistogramEnumeration(
kHatsShouldShowSurveyReasonHistogram,
HatsServiceDesktop::ShouldShowSurveyReasons::kNoSurveyUnreachable);
CloseWidget();
std::move(failure_callback_).Run();
}
// TODO(crbug.com/40285934): Remove this whole function after HaTSWebUI is
// launched.
void HatsNextWebDialog::OnSurveyStateUpdateReceived(std::string state) {
if (load_timed_out_) {
// Ignore state update, since we already consider the survey load to be
// timed out, and treated it accordingly.
return;
}
loading_timer_.Stop();
if (state == "loaded") {
OnSurveyLoaded();
} else if (state == "close") {
OnSurveyClosed();
} else if (state == "completed") {
OnSurveyCompleted();
} else if (base::StartsWith(state, "answer-")) {
OnSurveyQuestionAnswered(state);
} else {
LOG(ERROR) << "Unknown state provided in URL fragment by HaTS survey:"
<< state;
CloseWidget();
LogUmaHistogramSparse(hats_histogram_name_,
SurveyHistogramEnumeration::kSurveyUnknownState);
std::move(failure_callback_).Run();
}
}
void HatsNextWebDialog::SetHatsSurveyURLforTesting(GURL url) {
hats_survey_url_ = url;
}
void HatsNextWebDialog::ShowWidget() {
widget_->Show();
}
void HatsNextWebDialog::CloseWidget() {
widget_->Close();
}
bool HatsNextWebDialog::IsWaitingForSurveyForTesting() {
return loading_timer_.IsRunning();
}
int HatsNextWebDialog::GetHistogramBucket(int question, int answer) {
// The enumeration is specified as `QQNN`, where `QQ` is the question
// number and `NN` is the answer index. Therefore, we can calculate this
// value via `QQ * 100 + NN`.
// Note: The `ParseSurveyQuestionAnswer` function guarantees that the answer
// will be in the range [1, 100].
// The results returned from this function should be consistent with the enum,
// HappinessTrackingSurvey, which is defined in the file
// tools/metrics/histograms/metadata/others/enums.xml.
return question * 100 + answer;
}
void HatsNextWebDialog::BrowserDidClose(BrowserWindowInterface* browser) {
widget_->CloseNow();
}
BEGIN_METADATA(HatsNextWebDialog)
ADD_READONLY_PROPERTY_METADATA(GURL, ParameterizedHatsURL)
END_METADATA