// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#import "ios/chrome/browser/promos_manager/model/promos_manager_impl.h"
#import <Foundation/Foundation.h>
#import <algorithm>
#import <iterator>
#import <map>
#import <numeric>
#import <optional>
#import <set>
#import <vector>
#import "base/containers/contains.h"
#import "base/feature_list.h"
#import "base/json/values_util.h"
#import "base/metrics/histogram_functions.h"
#import "base/time/time.h"
#import "base/values.h"
#import "components/feature_engagement/public/tracker.h"
#import "components/prefs/pref_service.h"
#import "components/prefs/scoped_user_pref_update.h"
#import "ios/chrome/browser/promos_manager/model/constants.h"
#import "ios/chrome/browser/promos_manager/model/features.h"
#import "ios/chrome/browser/promos_manager/model/impression_limit.h"
#import "ios/chrome/browser/shared/model/prefs/pref_names.h"
using promos_manager::Promo;
namespace {
// Conditionally appends `promo` to the list pref `pref_path`. If `promo`
// already exists in the list pref `pref_path`, does nothing. If `promo` doesn't
// exist in the list pref `pref_path`, appends `promo` to the list.
void ConditionallyAppendPromoToPrefList(promos_manager::Promo promo,
const std::string& pref_path,
PrefService* local_state) {
DCHECK(local_state);
ScopedListPrefUpdate update(local_state, pref_path);
std::string promo_name = promos_manager::NameForPromo(promo);
// Erase `promo_name` if it already exists in `active_promos`; avoid polluting
// `active_promos` with duplicate `promo_name` entries.
update->EraseValue(base::Value(promo_name));
update->Append(promo_name);
}
} // namespace
#pragma mark - PromosManagerImpl
#pragma mark - Constructor/Destructor
PromosManagerImpl::PromosManagerImpl(PrefService* local_state,
base::Clock* clock,
feature_engagement::Tracker* tracker)
: local_state_(local_state), clock_(clock), tracker_(tracker) {
DCHECK(local_state_);
DCHECK(clock_);
}
PromosManagerImpl::~PromosManagerImpl() = default;
#pragma mark - Public
void PromosManagerImpl::Init() {
DCHECK(local_state_);
active_promos_ =
ActivePromos(local_state_->GetList(prefs::kIosPromosManagerActivePromos));
single_display_active_promos_ = ActivePromos(
local_state_->GetList(prefs::kIosPromosManagerSingleDisplayActivePromos));
InitializePendingPromos();
}
void PromosManagerImpl::DeregisterAfterDisplay(promos_manager::Promo promo) {
// Auto-deregister single display promos.
// Edge case: Possible to remove two instances of promo in
// `single_display_active_promos_` and `single_display_pending_promos_` that
// match the same type.
if (base::Contains(single_display_active_promos_, promo) ||
base::Contains(single_display_pending_promos_, promo)) {
DeregisterPromo(promo);
}
}
void PromosManagerImpl::RegisterPromoForContinuousDisplay(
promos_manager::Promo promo) {
ConditionallyAppendPromoToPrefList(
promo, prefs::kIosPromosManagerActivePromos, local_state_);
active_promos_ =
ActivePromos(local_state_->GetList(prefs::kIosPromosManagerActivePromos));
}
void PromosManagerImpl::RegisterPromoForSingleDisplay(
promos_manager::Promo promo) {
ConditionallyAppendPromoToPrefList(
promo, prefs::kIosPromosManagerSingleDisplayActivePromos, local_state_);
single_display_active_promos_ = ActivePromos(
local_state_->GetList(prefs::kIosPromosManagerSingleDisplayActivePromos));
}
void PromosManagerImpl::RegisterPromoForSingleDisplay(
promos_manager::Promo promo,
base::TimeDelta becomes_active_after_period) {
DCHECK(local_state_);
// update the pending promos saved in pref.
ScopedDictPrefUpdate pending_promos_update(
local_state_, prefs::kIosPromosManagerSingleDisplayPendingPromos);
std::string promo_name = promos_manager::NameForPromo(promo);
base::Time becomes_active_time = clock_->Now() + becomes_active_after_period;
pending_promos_update->Set(promo_name,
base::TimeToValue(becomes_active_time));
// keep the in-memory pending promos up-to-date to avoid reading from pref
// frequently.
single_display_pending_promos_[promo] = becomes_active_time;
}
void PromosManagerImpl::DeregisterPromo(promos_manager::Promo promo) {
DCHECK(local_state_);
ScopedListPrefUpdate active_promos_update(
local_state_, prefs::kIosPromosManagerActivePromos);
ScopedListPrefUpdate single_display_promos_update(
local_state_, prefs::kIosPromosManagerSingleDisplayActivePromos);
ScopedDictPrefUpdate pending_promos_update(
local_state_, prefs::kIosPromosManagerSingleDisplayPendingPromos);
std::string promo_name = promos_manager::NameForPromo(promo);
// Erase `promo_name` from the single-display and continuous-display active
// promos lists.
active_promos_update->EraseValue(base::Value(promo_name));
single_display_promos_update->EraseValue(base::Value(promo_name));
pending_promos_update->Remove(promo_name);
active_promos_ =
ActivePromos(local_state_->GetList(prefs::kIosPromosManagerActivePromos));
single_display_active_promos_ = ActivePromos(
local_state_->GetList(prefs::kIosPromosManagerSingleDisplayActivePromos));
single_display_pending_promos_.erase(promo);
}
void PromosManagerImpl::InitializePromoConfigs(PromoConfigsSet promo_configs) {
promo_configs_ = std::move(promo_configs);
}
// Determines which promo to display next.
// Candidates are from active promos and the pending promos that can become
// active at the time this function is called. Coordinate with other internal
// functions to rank and validate the candidates.
std::optional<promos_manager::Promo> PromosManagerImpl::NextPromoForDisplay() {
// Construct a map with the promo from (1) single-display and
// (2) continuous-display promo campaigns. (3) single-display pending promos
// that has become active, as keys. The value is the context that will be used
// for ranking purpose.
std::map<promos_manager::Promo, PromoContext> active_promos_with_context;
for (const auto& promo : active_promos_) {
active_promos_with_context[promo] = PromoContext{
.was_pending = false,
};
}
// Non-destructively insert the single-display promos into
// `all_active_promos`.
for (const auto& promo : single_display_active_promos_) {
active_promos_with_context[promo] = PromoContext{
.was_pending = false,
};
}
// Insert the pending promos that have become active.
// Possibly overrides the same promo from `single_display_active_promos_`, as
// the pending promo has higher priority in current use cases.
const base::Time now = clock_->Now();
for (const auto& [promo, time] : single_display_pending_promos_) {
if (time < now) {
active_promos_with_context[promo] = PromoContext{
.was_pending = true,
};
}
}
std::vector<promos_manager::Promo> sorted_promos =
SortPromos(active_promos_with_context);
if (sorted_promos.empty()) {
return std::nullopt;
}
// Get eligible promo count before ```GetFirstEligiblePromo``` otherwise the
// count might not be accurate.
int valid_promo_count = GetEligiblePromoCount(sorted_promos);
if (valid_promo_count == 0) {
return std::nullopt;
}
std::optional<promos_manager::Promo> first_promo_opt =
GetFirstEligiblePromo(sorted_promos);
if (!first_promo_opt) {
return std::nullopt;
}
// If there is a promo eligible for display then record number of valid promos
// in the queue. This is to understand how often eligible promos don't get
// picked because of other promos.
base::UmaHistogramExactLinear("IOS.PromosManager.EligiblePromosInQueueCount",
valid_promo_count,
static_cast<int>(Promo::kMaxValue) + 1);
return first_promo_opt;
}
std::set<promos_manager::Promo> PromosManagerImpl::ActivePromos(
const base::Value::List& stored_active_promos) const {
std::set<promos_manager::Promo> active_promos;
for (size_t i = 0; i < stored_active_promos.size(); ++i) {
std::optional<promos_manager::Promo> promo =
promos_manager::PromoForName(stored_active_promos[i].GetString());
// Skip malformed active promos data. (This should almost never happen.)
if (!promo.has_value())
continue;
active_promos.insert(promo.value());
}
return active_promos;
}
// Should only be called in the `init` to avoid excessive reading from pref.
void PromosManagerImpl::InitializePendingPromos() {
DCHECK(local_state_);
single_display_pending_promos_.clear();
const base::Value::Dict& stored_pending_promos =
local_state_->GetDict(prefs::kIosPromosManagerSingleDisplayPendingPromos);
for (const auto [name, value] : stored_pending_promos) {
std::optional<promos_manager::Promo> promo =
promos_manager::PromoForName(name);
// Skip malformed promo data.
if (!promo.has_value()) {
continue;
}
std::optional<base::Time> becomes_active_time = ValueToTime(value);
// Skip malformed time data.
if (!becomes_active_time.has_value()) {
continue;
}
single_display_pending_promos_[promo.value()] = becomes_active_time.value();
}
}
bool PromosManagerImpl::CanShowPromoWithoutTrigger(
promos_manager::Promo promo) const {
const base::Feature* feature = FeatureForPromo(promo);
if (!feature) {
return false;
}
return tracker_->WouldTriggerHelpUI(*feature);
}
bool PromosManagerImpl::CanShowPromo(promos_manager::Promo promo) const {
const base::Feature* feature = FeatureForPromo(promo);
if (!feature) {
return false;
}
return tracker_->ShouldTriggerHelpUI(*feature);
}
const base::Feature* PromosManagerImpl::FeatureForPromo(
promos_manager::Promo promo) const {
auto it = promo_configs_.find(promo);
if (it == promo_configs_.end()) {
return nil;
}
return it->feature_engagement_feature;
}
// Sort the promos in the order that they will be displayed.
// Based on the Promo's context and type.
std::vector<promos_manager::Promo> PromosManagerImpl::SortPromos(
const std::map<promos_manager::Promo, PromoContext>&
promos_to_sort_with_context) const {
std::vector<std::pair<promos_manager::Promo, PromoContext>>
promos_list_to_sort;
for (const auto& it : promos_to_sort_with_context) {
promos_list_to_sort.push_back(
std::pair<promos_manager::Promo, PromoContext>(it.first, it.second));
}
// The order: PostRestoreSignIn types are shown first, then Promos with
// pending state, then Promos without pending state. For promos without
// pending state, those never before shown come before those that have been
// shown before.
auto compare_promo = [this](
std::pair<promos_manager::Promo, PromoContext> lhs,
std::pair<promos_manager::Promo, PromoContext> rhs) {
// PostRestoreDefaultBrowser comes first.
if (lhs.first == Promo::PostRestoreDefaultBrowserAlert) {
return true;
}
if (rhs.first == Promo::PostRestoreDefaultBrowserAlert) {
return false;
}
// PostRestoreSignIn types come next.
if (lhs.first == Promo::PostRestoreSignInFullscreen ||
lhs.first == Promo::PostRestoreSignInAlert) {
return true;
}
if (rhs.first == Promo::PostRestoreSignInFullscreen ||
rhs.first == Promo::PostRestoreSignInAlert) {
return false;
}
// Post-default browser abandonment promo comes next.
if (lhs.first == Promo::PostDefaultAbandonment) {
return true;
}
if (rhs.first == Promo::PostDefaultAbandonment) {
return false;
}
// prefer the promo with pending state to the other without.
if (lhs.second.was_pending && !rhs.second.was_pending) {
return true;
}
if (!lhs.second.was_pending && rhs.second.was_pending) {
return false;
}
// Check Feature Engagement Tracker data for promos.
const base::Feature* lhs_feature = FeatureForPromo(lhs.first);
const base::Feature* rhs_feature = FeatureForPromo(rhs.first);
if (!lhs_feature && !rhs_feature) {
return lhs.first < rhs.first;
} else if (!rhs_feature) {
return true;
} else if (!lhs_feature) {
return false;
}
if (!tracker_->IsInitialized()) {
return lhs.first < rhs.first;
}
// Prefer the promo that has not been shown to the
// one that has.
bool lhs_shown = tracker_->HasEverTriggered(*lhs_feature, true);
bool rhs_shown = tracker_->HasEverTriggered(*rhs_feature, true);
if (!lhs_shown && rhs_shown) {
return true;
}
if (lhs_shown && !rhs_shown) {
return false;
}
return lhs.first < rhs.first;
};
sort(promos_list_to_sort.begin(), promos_list_to_sort.end(), compare_promo);
std::vector<promos_manager::Promo> sorted_promos;
for (const auto& it : promos_list_to_sort) {
sorted_promos.push_back(it.first);
}
return sorted_promos;
}
std::optional<promos_manager::Promo> PromosManagerImpl::GetFirstEligiblePromo(
const std::vector<promos_manager::Promo>& promo_queue) {
for (promos_manager::Promo promo : promo_queue) {
if (CanShowPromo(promo)) {
return promo;
}
}
return std::nullopt;
}
int PromosManagerImpl::GetEligiblePromoCount(
const std::vector<promos_manager::Promo>& promo_queue) {
int count = 0;
for (promos_manager::Promo promo : promo_queue) {
if (CanShowPromoWithoutTrigger(promo)) {
count++;
}
}
return count;
}