blob: 650022c310fadcb722f9b0f5539d73e907dd8655 [file] [log] [blame]
// 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 "components/user_education/common/feature_promo_lifecycle.h"
#include "base/containers/contains.h"
#include "base/metrics/histogram_functions.h"
#include "base/metrics/histogram_macros.h"
#include "base/metrics/user_metrics.h"
#include "base/notreached.h"
#include "base/time/time.h"
#include "components/user_education/common/feature_promo_data.h"
#include "components/user_education/common/feature_promo_storage_service.h"
#include "components/user_education/common/help_bubble.h"
#include "components/user_education/common/user_education_features.h"
namespace user_education {
namespace {
class ScopedPromoData {
public:
ScopedPromoData(FeaturePromoStorageService* storage_service,
const base::Feature* iph_feature)
: storage_service_(storage_service), iph_feature_(iph_feature) {
promo_data_ = storage_service_->ReadPromoData(*iph_feature)
.value_or(FeaturePromoData());
}
~ScopedPromoData() {
storage_service_->SavePromoData(*iph_feature_, promo_data_);
}
FeaturePromoData* operator->() { return &promo_data_; }
private:
FeaturePromoData promo_data_;
const raw_ptr<FeaturePromoStorageService> storage_service_;
const raw_ptr<const base::Feature> iph_feature_;
};
} // namespace
FeaturePromoLifecycle::FeaturePromoLifecycle(
FeaturePromoStorageService* storage_service,
const base::StringPiece& app_id,
const base::Feature* iph_feature,
PromoType promo_type,
PromoSubtype promo_subtype)
: storage_service_(storage_service),
app_id_(app_id),
iph_feature_(iph_feature),
promo_type_(promo_type),
promo_subtype_(promo_subtype) {}
FeaturePromoLifecycle::~FeaturePromoLifecycle() {
MaybeEndPromo();
}
FeaturePromoResult FeaturePromoLifecycle::CanShow() const {
DCHECK(promo_subtype_ != PromoSubtype::kPerApp || !app_id_.empty());
const auto data = storage_service_->ReadPromoData(*iph_feature_);
if (!data.has_value()) {
return FeaturePromoResult::Success();
}
switch (promo_subtype_) {
case PromoSubtype::kNormal:
switch (promo_type_) {
case PromoType::kLegacy:
case PromoType::kToast:
return data->is_dismissed ? FeaturePromoResult::kPermanentlyDismissed
: FeaturePromoResult::Success();
case PromoType::kCustomAction:
case PromoType::kSnooze:
case PromoType::kTutorial:
return CanShowSnoozePromo(*data);
case PromoType::kUnspecified:
NOTREACHED();
return FeaturePromoResult::kPermanentlyDismissed;
}
break;
case PromoSubtype::kPerApp:
return base::Contains(data->shown_for_apps, app_id_)
? FeaturePromoResult::kPermanentlyDismissed
: FeaturePromoResult::Success();
case PromoSubtype::kLegalNotice:
case PromoSubtype::kActionableAlert:
return data->is_dismissed ? FeaturePromoResult::kPermanentlyDismissed
: FeaturePromoResult::Success();
}
}
bool FeaturePromoLifecycle::CanSnooze() const {
switch (promo_type_) {
case PromoType::kLegacy:
case PromoType::kToast:
return false;
case PromoType::kCustomAction:
case PromoType::kSnooze:
case PromoType::kTutorial:
// Only enforce snooze count in V2 to avoid breaking backwards behavior.
return !features::IsUserEducationV2() ||
storage_service_->GetSnoozeCount(*iph_feature_) <
features::GetMaxSnoozeCount();
case PromoType::kUnspecified:
NOTREACHED();
return false;
}
}
void FeaturePromoLifecycle::OnPromoShown(
std::unique_ptr<HelpBubble> help_bubble,
feature_engagement::Tracker* tracker) {
CHECK(!was_started());
state_ = State::kRunning;
tracker_ = tracker;
help_bubble_ = std::move(help_bubble);
if (is_demo()) {
return;
}
ScopedPromoData data(storage_service_, iph_feature_);
const auto now = GetCurrentTime();
if (data->show_count == 0) {
data->first_show_time = now;
}
++data->show_count;
data->last_show_time = now;
RecordShown();
}
void FeaturePromoLifecycle::OnPromoShownForDemo(
std::unique_ptr<HelpBubble> help_bubble) {
OnPromoShown(std::move(help_bubble), nullptr);
}
bool FeaturePromoLifecycle::OnPromoBubbleClosed() {
help_bubble_.reset();
if (state_ == State::kRunning) {
MaybeRecordClosedReason(FeaturePromoClosedReason::kAbortPromo);
CHECK(MaybeEndPromo());
return true;
}
return false;
}
void FeaturePromoLifecycle::OnPromoEnded(FeaturePromoClosedReason close_reason,
bool continue_promo) {
MaybeRecordClosedReason(close_reason);
if (continue_promo) {
CHECK(is_bubble_visible());
state_ = State::kContinued;
// When a snoozeable, normal promo that has a follow-up action (Tutorial,
// custom action), the result is not recorded until after the follow-up
// finishes, because e.g. an aborted tutorial counts as a snooze.
if (promo_subtype_ != PromoSubtype::kNormal ||
close_reason != FeaturePromoClosedReason::kAction) {
MaybeWriteClosedPromoData(close_reason);
}
help_bubble_->Close();
} else {
CHECK(MaybeEndPromo());
MaybeWriteClosedPromoData(close_reason);
}
}
void FeaturePromoLifecycle::OnContinuedPromoEnded(bool completed_successfully) {
MaybeWriteClosedPromoData(completed_successfully
? FeaturePromoClosedReason::kAction
: FeaturePromoClosedReason::kSnooze);
MaybeEndPromo();
}
bool FeaturePromoLifecycle::MaybeEndPromo() {
if (!is_promo_active()) {
return false;
}
state_ = State::kClosed;
if (!is_demo() && !tracker_dismissed_) {
tracker_dismissed_ = true;
tracker_->Dismissed(*iph_feature_);
}
return true;
}
FeaturePromoResult FeaturePromoLifecycle::CanShowSnoozePromo(
const FeaturePromoData& promo_data) const {
// This IPH has been dismissed by user permanently.
if (promo_data.is_dismissed) {
return FeaturePromoResult::kPermanentlyDismissed;
}
// This IPH is shown for the first time.
if (promo_data.show_count == 0) {
return FeaturePromoResult::Success();
}
const auto now = GetCurrentTime();
// Figure out when the promo can show next.
if (features::IsUserEducationV2()) {
// In V2, there is a separate cooldown if a promo is snoozed vs. shown but
// not snoozed, for example, if it was aborted for some other reason and not
// dismissed.
if (now < promo_data.last_snooze_time + features::GetSnoozeDuration()) {
return FeaturePromoResult::kSnoozed;
}
if (now < promo_data.last_show_time + features::GetAbortCooldown()) {
return FeaturePromoResult::kRecentlyAborted;
}
} else {
// In V1, it was always the default snooze duration from the previous
// show or snooze time (non-snoozed IPH were subject to "non-clicker policy"
// which still used the default snooze duration).
const auto last_show =
std::max(promo_data.last_show_time, promo_data.last_snooze_time);
if (now < last_show + features::GetSnoozeDuration()) {
return FeaturePromoResult::kSnoozed;
}
}
return FeaturePromoResult::Success();
}
base::Time FeaturePromoLifecycle::GetCurrentTime() const {
return storage_service_->GetCurrentTime();
}
void FeaturePromoLifecycle::MaybeWriteClosedPromoData(
FeaturePromoClosedReason close_reason) {
if (is_demo() || wrote_close_data_) {
return;
}
wrote_close_data_ = true;
switch (close_reason) {
case FeaturePromoClosedReason::kAction:
case FeaturePromoClosedReason::kCancel:
case FeaturePromoClosedReason::kDismiss:
case FeaturePromoClosedReason::kFeatureEngaged:
case FeaturePromoClosedReason::kTimeout: {
ScopedPromoData data(storage_service_, iph_feature_);
if (!app_id_.empty()) {
data->shown_for_apps.insert(app_id_);
}
data->is_dismissed = true;
data->last_dismissed_by = close_reason;
break;
}
case FeaturePromoClosedReason::kSnooze: {
ScopedPromoData data(storage_service_, iph_feature_);
++data->snooze_count;
data->last_snooze_time = GetCurrentTime();
break;
}
case FeaturePromoClosedReason::kAbortPromo:
case FeaturePromoClosedReason::kOverrideForDemo:
case FeaturePromoClosedReason::kOverrideForPrecedence:
case FeaturePromoClosedReason::kOverrideForTesting:
case FeaturePromoClosedReason::kOverrideForUIRegionConflict:
// No additional action required.
break;
}
}
void FeaturePromoLifecycle::RecordShown() {
// Record Promo shown
std::string action_name = "UserEducation.MessageShown";
base::RecordComputedAction(action_name);
// Record Promo feature ID
action_name.append(".");
action_name.append(iph_feature_->name);
base::RecordComputedAction(action_name);
// Record Promo type
UMA_HISTOGRAM_ENUMERATION("UserEducation.MessageShown.Type", promo_type_);
UMA_HISTOGRAM_ENUMERATION("UserEducation.MessageShown.Subtype",
promo_subtype_);
std::string type_action_name = "UserEducation.MessageShown.";
switch (promo_subtype_) {
case PromoSubtype::kNormal:
break;
case PromoSubtype::kPerApp:
// Ends with a period.
type_action_name.append("PerApp.");
break;
case PromoSubtype::kLegalNotice:
// Ends with a period.
type_action_name.append("LegalNotice.");
break;
case PromoSubtype::kActionableAlert:
// Ends with a period.
type_action_name.append("ActionableAlert.");
break;
}
switch (promo_type_) {
case PromoType::kLegacy:
type_action_name.append("Legacy");
break;
case PromoType::kToast:
type_action_name.append("Toast");
break;
case PromoType::kCustomAction:
type_action_name.append("CustomAction");
break;
case PromoType::kSnooze:
type_action_name.append("Snooze");
break;
case PromoType::kTutorial:
type_action_name.append("Tutorial");
break;
case PromoType::kUnspecified:
NOTREACHED();
}
base::RecordComputedAction(type_action_name);
}
void FeaturePromoLifecycle::MaybeRecordClosedReason(
FeaturePromoClosedReason close_reason) {
if (is_demo() || state_ != State::kRunning) {
return;
}
std::string action_name = "UserEducation.MessageAction.";
switch (close_reason) {
case FeaturePromoClosedReason::kDismiss:
action_name.append("Dismiss");
break;
case FeaturePromoClosedReason::kSnooze:
action_name.append("Snooze");
break;
case FeaturePromoClosedReason::kAction:
action_name.append("Action");
break;
case FeaturePromoClosedReason::kCancel:
action_name.append("Cancel");
break;
case FeaturePromoClosedReason::kTimeout:
action_name.append("Timeout");
break;
case FeaturePromoClosedReason::kAbortPromo:
action_name.append("Abort");
break;
case FeaturePromoClosedReason::kFeatureEngaged:
action_name.append("FeatureEngaged");
break;
case FeaturePromoClosedReason::kOverrideForUIRegionConflict:
action_name.append("OverrideForUIRegionConflict");
break;
case FeaturePromoClosedReason::kOverrideForPrecedence:
action_name.append("OverrideForPrecedence");
break;
case FeaturePromoClosedReason::kOverrideForDemo:
// Not used for metrics.
return;
case FeaturePromoClosedReason::kOverrideForTesting:
// Not used for metrics.
return;
}
action_name.append(".");
action_name.append(iph_feature()->name);
// Record the user action.
base::RecordComputedAction(action_name);
// Record the histogram.
std::string histogram_name =
std::string("UserEducation.MessageAction.").append(iph_feature()->name);
base::UmaHistogramEnumeration(
histogram_name, static_cast<FeaturePromoClosedReason>(close_reason));
}
} // namespace user_education