[go: up one dir, main page]

blob: ae5ab3f7bf3787afe3a02e84bb1d6b12641c5d50 [file] [log] [blame]
// Copyright 2022 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.
#import "ios/chrome/browser/ui/popup_menu/overflow_menu/destination_usage_history/destination_usage_history.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/sys_string_conversions.h"
#include "base/time/time.h"
#include "components/prefs/pref_service.h"
#include "components/prefs/scoped_user_pref_update.h"
#import "ios/chrome/browser/pref_names.h"
#import "ios/chrome/browser/ui/popup_menu/overflow_menu/overflow_menu_constants.h"
#if !defined(__has_feature) || !__has_feature(objc_arc)
#error "This file requires ARC support."
#endif
namespace {
// kDataExpirationWindow represents the number of days of usage history stored
// for a given user. Data older than kDataExpirationWindow days will be removed
// during the presentation of the overflow menu.
constexpr int kDataExpirationWindow = 365; // days (inclusive)
// kRecencyWindow represents the number of days before the present where usage
// is considered recent.
constexpr int kRecencyWindow = 7; // days (inclusive)
// The percentage by which an app must overtake another app for them to swap
// places.
constexpr double kDampening = 1.1;
// kInitialBufferNumClicks represents the minimum number of clicks a destination
// must have before the user notices a visible change in the carousel sort.
constexpr int kInitialBufferNumClicks = 3; // clicks
// The dictionary key used for storing rankings.
const std::string kRankingKey = "ranking";
// The default destinations ranking, based on statistical usage of the old
// overflow menu.
std::vector<overflow_menu::Destination> kDefaultRanking = {
overflow_menu::Destination::Bookmarks,
overflow_menu::Destination::History,
overflow_menu::Destination::ReadingList,
overflow_menu::Destination::Passwords,
overflow_menu::Destination::Downloads,
overflow_menu::Destination::RecentTabs,
overflow_menu::Destination::SiteInfo,
overflow_menu::Destination::Settings,
};
// The number of days since the Unix epoch; one day, in this context, runs from
// UTC midnight to UTC midnight.
int TodaysDay() {
return (base::Time::Now() - base::Time::UnixEpoch()).InDays();
}
// Returns whether |day| is within a valid window of the present day and
// |window| days ago, inclusive.
bool ValidDay(int day, int window) {
int windowEnd = TodaysDay();
int windowStart = (windowEnd - window) + 1;
return day >= windowStart && day <= windowEnd;
}
// Helper method. Converts day (string) to int, then calls ValidDay(int day,
// int window).
bool ValidDay(const std::string day, int window) {
int dayNumber;
if (base::StringToInt(day, &dayNumber))
return ValidDay(dayNumber, window);
return false;
}
// Returns the number of clicks stored in |history| for a given |destination|.
int NumClicks(overflow_menu::Destination destination,
base::Value::Dict& history) {
return history.FindInt(overflow_menu::StringNameForDestination(destination))
.value_or(0);
}
// Destructively sort |ranking| in ascending or descending (indicated by
// |ascending|) and corresponding number of clicks stored in |flatHistory|.
std::vector<overflow_menu::Destination> SortByUsage(
const std::vector<overflow_menu::Destination>& ranking,
base::Value::Dict& flatHistory,
bool ascending) {
std::vector<overflow_menu::Destination> ordered_ranking(ranking.begin(),
ranking.end());
std::sort(
ordered_ranking.begin(), ordered_ranking.end(),
[&](overflow_menu::Destination a, overflow_menu::Destination b) -> bool {
return ascending
? NumClicks(a, flatHistory) < NumClicks(b, flatHistory)
: NumClicks(a, flatHistory) > NumClicks(b, flatHistory);
});
return ordered_ranking;
}
// Returns above-the-fold destination with the lowest usage in |flatHistory|.
overflow_menu::Destination LowestShown(
const std::vector<overflow_menu::Destination>& ranking,
int numVisibleDestinations,
base::Value::Dict& flatHistory) {
std::vector<overflow_menu::Destination> shown(
ranking.begin(), ranking.begin() + (numVisibleDestinations - 1));
return SortByUsage(shown, flatHistory, true).front();
}
// Returns below-the-fold destination with the highest usage in |flatHistory|
overflow_menu::Destination HighestUnshown(
const std::vector<overflow_menu::Destination>& ranking,
int numVisibleDestinations,
base::Value::Dict& flatHistory) {
std::vector<overflow_menu::Destination> unshown(
ranking.begin() + (numVisibleDestinations - 1), ranking.end());
return SortByUsage(unshown, flatHistory, false).front();
}
// Swaps |from| and |to| in |ranking|.
void Swap(std::vector<overflow_menu::Destination>& ranking,
overflow_menu::Destination from,
overflow_menu::Destination to) {
auto from_loc = std::find(ranking.begin(), ranking.end(), from);
auto to_loc = std::find(ranking.begin(), ranking.end(), to);
*from_loc = to;
*to_loc = from;
}
// Converts base::Value::List* ranking into
// std::vector<overflow_menu::Destination> ranking.
std::vector<overflow_menu::Destination> Vector(
const base::Value::List* ranking) {
std::vector<overflow_menu::Destination> vec;
if (!ranking)
return vec;
for (auto&& rank : *ranking) {
if (!rank.is_string())
NOTREACHED();
vec.push_back(overflow_menu::DestinationForStringName(rank.GetString()));
}
return vec;
}
// Converts std::vector<overflow_menu::Destination> ranking into
// base::Value::List ranking.
base::Value::List List(std::vector<overflow_menu::Destination>& ranking) {
base::Value::List list;
for (overflow_menu::Destination destination : ranking) {
list.Append(overflow_menu::StringNameForDestination(destination));
}
return list;
}
} // namespace
// Tracks destination usage from the new overflow menu and implements a frecency
// algorithm to sort destinations shown in the overflow menu carousel. The
// algorithm, at a high-level, works as follows:
//
// (1) Divide the destinations carousel into two groups: (A) visible
// "above-the-fold" destinations and (B) non-visible "below-the-fold"
// destinations; "below-the-fold" destinations are made visible to the user when
// they scroll the carousel.
//
// (2) Get each destination's numClicks.
//
// (3) Compare destination with highest numClicks in [group B] to destination
// with lowest numClicks in [group A]
//
// (4) Swap (i.e. "promote") the [group B] destination with the [group A] one if
// B's numClicks exceeds A's.
@implementation DestinationUsageHistory
- (instancetype)initWithPrefService:(PrefService*)prefService {
if (self = [super init])
_prefService = prefService;
return self;
}
#pragma mark - Public
- (std::vector<overflow_menu::Destination>)
updatedRankWithCurrentRanking:
(std::vector<overflow_menu::Destination>&)ranking
numAboveFoldDestinations:(int)numAboveFoldDestinations {
// Delete expired usage data older than |kDataExpirationWindow| days before
// running the ranking algorithm.
[self deleteExpiredData];
base::Value::Dict allHistory =
[self flattenedHistoryWithinWindow:kDataExpirationWindow];
base::Value::Dict recentHistory =
[self flattenedHistoryWithinWindow:kRecencyWindow];
std::vector<overflow_menu::Destination> prevRanking = ranking;
overflow_menu::Destination lowestShownAll =
LowestShown(prevRanking, numAboveFoldDestinations, allHistory);
overflow_menu::Destination lowestShownRecent =
LowestShown(prevRanking, numAboveFoldDestinations, recentHistory);
overflow_menu::Destination highestUnshownAll =
HighestUnshown(prevRanking, numAboveFoldDestinations, allHistory);
overflow_menu::Destination highestUnshownRecent =
HighestUnshown(prevRanking, numAboveFoldDestinations, recentHistory);
std::vector<overflow_menu::Destination> newRanking = prevRanking;
if (NumClicks(highestUnshownRecent, recentHistory) >
NumClicks(lowestShownRecent, recentHistory) * kDampening) {
Swap(newRanking, lowestShownRecent, highestUnshownRecent);
} else if (NumClicks(highestUnshownAll, allHistory) >
NumClicks(lowestShownAll, allHistory) * kDampening) {
Swap(newRanking, lowestShownAll, highestUnshownAll);
}
DCHECK_EQ(newRanking.size(), prevRanking.size());
return newRanking;
}
// Track click for |destination| and associate it with TodaysDay().
- (void)trackDestinationClick:(overflow_menu::Destination)destination
numAboveFoldDestinations:(int)numAboveFoldDestinations {
DCHECK(self.prefService);
// Exit early if there's no pref service; this is not expected to happen.
if (!self.prefService)
return;
const base::Value::Dict* history =
self.prefService
->GetDictionary(prefs::kOverflowMenuDestinationUsageHistory)
->GetIfDict();
const std::string path = base::NumberToString(TodaysDay()) + "." +
overflow_menu::StringNameForDestination(destination);
int numClicks = history->FindIntByDottedPath(path).value_or(0) + 1;
DictionaryPrefUpdate update(self.prefService,
prefs::kOverflowMenuDestinationUsageHistory);
update->SetIntPath(path, numClicks);
// User's very first time using Smart Sorting.
if (history->size() == 0)
[self injectDefaultNumClicksForAllDestinations];
// Calculate new ranking and store to prefs; Calculate the new ranking
// ahead of time so overflow menu presentation needn't run ranking algorithm
// each time it presents.
const base::Value::List* currentRanking = [self fetchCurrentRanking];
const base::Value::List newRanking =
[self calculateNewRanking:currentRanking
numAboveFoldDestinations:numAboveFoldDestinations];
update->SetKey(kRankingKey, base::Value(newRanking.Clone()));
}
#pragma mark - Private
// Injects a default number of clicks for all destinations in the history
// dictonary.
- (void)injectDefaultNumClicksForAllDestinations {
DCHECK_GT(kDampening, 1.0);
DCHECK_GT(kInitialBufferNumClicks, 1);
int defaultNumClicks =
(kInitialBufferNumClicks - 1) * (kDampening - 1.0) * 100.0;
std::string today = base::NumberToString(TodaysDay());
DictionaryPrefUpdate update(self.prefService,
prefs::kOverflowMenuDestinationUsageHistory);
const base::Value::Dict* history =
self.prefService
->GetDictionary(prefs::kOverflowMenuDestinationUsageHistory)
->GetIfDict();
for (overflow_menu::Destination destination : kDefaultRanking) {
const std::string path =
today + "." + overflow_menu::StringNameForDestination(destination);
update->SetIntPath(path, history->FindIntByDottedPath(path).value_or(0) +
defaultNumClicks);
}
}
// Delete expired usage data (data older than |kDataExpirationWindow| days) and
// saves back to prefs. Returns true if expired usage data was found/removed,
// false otherwise.
- (void)deleteExpiredData {
const base::Value* pref = self.prefService->GetDictionary(
prefs::kOverflowMenuDestinationUsageHistory);
const base::Value::Dict* history = pref->GetIfDict();
if (!history)
return;
base::Value::Dict prunedHistory = history->Clone();
for (auto&& [day, dayHistory] : *history) {
// Skip over entry corresponding to previous ranking.
if (day == kRankingKey)
continue;
if (!ValidDay(day, kDataExpirationWindow))
prunedHistory.Remove(day);
}
self.prefService->SetDict(prefs::kOverflowMenuDestinationUsageHistory,
std::move(prunedHistory));
}
// Fetches the current ranking saved in prefs and returns it.
- (const base::Value::List*)fetchCurrentRanking {
const base::Value* pref = self.prefService->GetDictionary(
prefs::kOverflowMenuDestinationUsageHistory);
const base::Value::Dict* history = pref->GetIfDict();
if (!history)
return nullptr;
return history->FindList(kRankingKey);
}
// Fetches the current ranking stored in Chrome Prefs and returns a sorted list
// of OverflowMenuDestination* which match the ranking.
- (NSArray<OverflowMenuDestination*>*)generateDestinationsList:
(NSArray<OverflowMenuDestination*>*)unrankedDestinations {
return [self destinationList:[self fetchCurrentRanking]
options:unrankedDestinations];
}
// Runs the ranking algorithm given a |previousRanking|. If |previousRanking| is
// invalid or doesn't exist, use the default ranking, based on statistical usage
// of the old overflow menu.
- (const base::Value::List)calculateNewRanking:
(const base::Value::List*)previousRanking
numAboveFoldDestinations:(int)numAboveFoldDestinations {
if (!previousRanking)
return List(kDefaultRanking);
if (numAboveFoldDestinations >= static_cast<int>(previousRanking->size()))
return previousRanking->Clone();
std::vector<overflow_menu::Destination> prevRanking =
previousRanking ? Vector(previousRanking) : kDefaultRanking;
std::vector<overflow_menu::Destination> newRanking =
[self updatedRankWithCurrentRanking:prevRanking
numAboveFoldDestinations:numAboveFoldDestinations];
return List(newRanking);
}
// Returns the flattened destination usage history as a dictionary of the
// following shape: destinationName (std::string) -> total number of clicks
// (int). Only usage data within previous |window| days will be included in the
// returned result.
- (base::Value::Dict)flattenedHistoryWithinWindow:(int)window {
const base::Value* pref = self.prefService->GetDictionary(
prefs::kOverflowMenuDestinationUsageHistory);
const base::Value::Dict* history = pref->GetIfDict();
base::Value::Dict flatHistory;
if (!history)
return flatHistory;
for (auto&& [day, dayHistory] : *history) {
// Skip over entry corresponding to previous ranking.
if (day == kRankingKey) {
continue;
}
// Skip over expired day history; this `continue` is not expected to be
// called, as expired data should be already pruned.
if (!ValidDay(day, window)) {
continue;
}
const base::Value::Dict* dayHistoryDict = dayHistory.GetIfDict();
// Skip over malformed day history; this `continue` is not expected to be
// called.
if (!dayHistoryDict) {
NOTREACHED();
continue;
}
for (auto&& [destination, numClicks] : *dayHistoryDict) {
int totalNumClicks = numClicks.GetIfInt().value_or(0) +
flatHistory.FindInt(destination).value_or(0);
flatHistory.Set(destination, totalNumClicks);
}
}
return flatHistory;
}
// Constructs OverflowMenuDestination* lookup-by-name dictionary of the
// following shape: destinationName (NSString*) -> destination
// (OverflowMenuDestination*).
- (NSDictionary<NSString*, OverflowMenuDestination*>*)destinationsByName:
(NSArray<OverflowMenuDestination*>*)destinations {
NSMutableDictionary<NSString*, OverflowMenuDestination*>* dictionary =
[[NSMutableDictionary alloc] init];
for (OverflowMenuDestination* destination : destinations) {
dictionary[destination.destinationName] = destination;
}
return dictionary;
}
// Converts base::Value::List* ranking to
// NSArray<OverflowMenuDestination*>* ranking given a list, |options|, of
// OverflowMenuDestination* options.
- (NSArray<OverflowMenuDestination*>*)
destinationList:(const base::Value::List*)ranking
options:(NSArray<OverflowMenuDestination*>*)options {
if (!ranking)
// If no valid ranking, return unsorted options.
return options;
NSMutableArray<OverflowMenuDestination*>* result =
[[NSMutableArray alloc] init];
NSDictionary<NSString*, OverflowMenuDestination*>* destinations =
[self destinationsByName:options];
for (auto&& destinationName : *ranking) {
// If at any point a stored ranking is invalid, return unsorted options.
if (!destinationName.is_string())
return options;
NSString* name = base::SysUTF8ToNSString(destinationName.GetString());
if (destinations[name])
[result addObject:destinations[name]];
}
return result;
}
@end