// Copyright 2021 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "ash/system/scheduled_feature/scheduled_feature.h"
#include <algorithm>
#include <cmath>
#include <memory>
#include <utility>
#include "ash/constants/ash_features.h"
#include "ash/constants/ash_pref_names.h"
#include "ash/public/cpp/notification_utils.h"
#include "ash/session/session_controller_impl.h"
#include "ash/shell.h"
#include "ash/strings/grit/ash_strings.h"
#include "ash/system/geolocation/geolocation_controller.h"
#include "ash/system/model/system_tray_model.h"
#include "ash/system/scheduled_feature/schedule_utils.h"
#include "base/functional/bind.h"
#include "base/i18n/time_formatting.h"
#include "base/logging.h"
#include "base/metrics/histogram_functions.h"
#include "base/notreached.h"
#include "base/strings/stringprintf.h"
#include "base/task/sequenced_task_runner.h"
#include "base/time/time.h"
#include "components/prefs/pref_registry_simple.h"
#include "components/prefs/pref_service.h"
#include "third_party/icu/source/i18n/astro.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/gfx/geometry/vector3d_f.h"
namespace ash {
namespace {
// Default start time at 6:00 PM as an offset from 00:00.
constexpr int kDefaultStartTimeOffsetMinutes = 18 * 60;
// Default end time at 6:00 AM as an offset from 00:00.
constexpr int kDefaultEndTimeOffsetMinutes = 6 * 60;
// The only known `Refresh()` failure currently is b/285187343, where getting
// the default local sunrise/sunset times fails. Getting local time is not
// a network request; the current theory is an unknown bad kernel state.
// Therefore, a more aggressive retry policy is acceptable here.
constexpr net::BackoffEntry::Policy kRefreshFailureBackoffPolicy = {
0, // Number of initial errors to ignore.
500, // Initial delay in ms.
2.0, // Factor by which the waiting time will be multiplied.
0.2, // Fuzzing percentage.
60 * 1000, // Maximum delay in ms. (1 minute)
-1, // Never discard the entry.
true, // Use initial delay.
};
bool IsEnabledAtCheckpoint(ScheduleCheckpoint checkpoint) {
switch (checkpoint) {
case ScheduleCheckpoint::kDisabled:
case ScheduleCheckpoint::kSunrise:
case ScheduleCheckpoint::kMorning:
case ScheduleCheckpoint::kLateAfternoon:
return false;
case ScheduleCheckpoint::kEnabled:
case ScheduleCheckpoint::kSunset:
return true;
}
}
// Converts a boolean feature `is_enabled` state to the appropriate
// `ScheduleCheckpoint` for the given `schedule_type`.
ScheduleCheckpoint GetCheckpointForEnabledState(bool is_enabled,
ScheduleType schedule_type) {
switch (schedule_type) {
case ScheduleType::kNone:
case ScheduleType::kCustom:
return is_enabled ? ScheduleCheckpoint::kEnabled
: ScheduleCheckpoint::kDisabled;
case ScheduleType::kSunsetToSunrise:
return is_enabled ? ScheduleCheckpoint::kSunset
: ScheduleCheckpoint::kSunrise;
}
}
} // namespace
base::Time ScheduledFeature::Clock::Now() const {
return base::Time::Now();
}
base::TimeTicks ScheduledFeature::Clock::NowTicks() const {
return base::TimeTicks::Now();
}
ScheduledFeature::ScheduledFeature(
const std::string prefs_path_enabled,
const std::string prefs_path_schedule_type,
const std::string prefs_path_custom_start_time,
const std::string prefs_path_custom_end_time)
: timer_(std::make_unique<base::OneShotTimer>()),
prefs_path_enabled_(prefs_path_enabled),
prefs_path_schedule_type_(prefs_path_schedule_type),
prefs_path_custom_start_time_(prefs_path_custom_start_time),
prefs_path_custom_end_time_(prefs_path_custom_end_time),
geolocation_controller_(ash::GeolocationController::Get()),
clock_(&default_clock_),
refresh_failure_backoff_(&kRefreshFailureBackoffPolicy) {
Shell::Get()->session_controller()->AddObserver(this);
chromeos::PowerManagerClient::Get()->AddObserver(this);
// Check that both start or end times are supplied or both are absent.
DCHECK_EQ(prefs_path_custom_start_time_.empty(),
prefs_path_custom_end_time_.empty());
}
ScheduledFeature::~ScheduledFeature() {
geolocation_controller_->RemoveObserver(this);
chromeos::PowerManagerClient::Get()->RemoveObserver(this);
Shell::Get()->session_controller()->RemoveObserver(this);
}
bool ScheduledFeature::GetEnabled() const {
return active_user_pref_service_ &&
active_user_pref_service_->GetBoolean(prefs_path_enabled_);
}
ScheduleType ScheduledFeature::GetScheduleType() const {
if (active_user_pref_service_) {
return static_cast<ScheduleType>(
active_user_pref_service_->GetInteger(prefs_path_schedule_type_));
}
return ScheduleType::kNone;
}
TimeOfDay ScheduledFeature::GetCustomStartTime() const {
DCHECK(!prefs_path_custom_start_time_.empty());
return TimeOfDay(active_user_pref_service_
? active_user_pref_service_->GetInteger(
prefs_path_custom_start_time_)
: kDefaultStartTimeOffsetMinutes)
.SetClock(clock_)
.SetLocalTimeConverter(local_time_converter_);
}
TimeOfDay ScheduledFeature::GetCustomEndTime() const {
DCHECK(!prefs_path_custom_end_time_.empty());
return TimeOfDay(active_user_pref_service_
? active_user_pref_service_->GetInteger(
prefs_path_custom_end_time_)
: kDefaultEndTimeOffsetMinutes)
.SetClock(clock_)
.SetLocalTimeConverter(local_time_converter_);
}
void ScheduledFeature::SetEnabled(bool enabled) {
SetEnabledInternal(enabled, RefreshReason::kExternal);
}
void ScheduledFeature::SetScheduleType(ScheduleType type) {
if (!active_user_pref_service_)
return;
if (type == ScheduleType::kCustom && (prefs_path_custom_start_time_.empty() ||
prefs_path_custom_end_time_.empty())) {
NOTREACHED();
}
active_user_pref_service_->SetInteger(prefs_path_schedule_type_,
static_cast<int>(type));
}
void ScheduledFeature::SetCustomStartTime(TimeOfDay start_time) {
DCHECK(!prefs_path_custom_start_time_.empty());
if (active_user_pref_service_) {
active_user_pref_service_->SetInteger(
prefs_path_custom_start_time_,
start_time.offset_minutes_from_zero_hour());
}
}
void ScheduledFeature::SetCustomEndTime(TimeOfDay end_time) {
DCHECK(!prefs_path_custom_end_time_.empty());
if (active_user_pref_service_) {
active_user_pref_service_->SetInteger(
prefs_path_custom_end_time_, end_time.offset_minutes_from_zero_hour());
}
}
void ScheduledFeature::AddCheckpointObserver(CheckpointObserver* obs) {
checkpoint_observers_.AddObserver(obs);
}
void ScheduledFeature::RemoveCheckpointObserver(CheckpointObserver* obs) {
checkpoint_observers_.RemoveObserver(obs);
}
void ScheduledFeature::OnActiveUserPrefServiceChanged(
PrefService* pref_service) {
if (pref_service == active_user_pref_service_)
return;
// TODO(afakhry|yjliu): Remove this VLOG when https://crbug.com/1015474 is
// fixed.
auto vlog_helper = [this](const PrefService* pref_service) -> std::string {
if (!pref_service) {
return "None";
}
return base::StringPrintf(
"{State %s, Schedule Type: %d}",
pref_service->GetBoolean(prefs_path_enabled_) ? "enabled" : "disabled",
pref_service->GetInteger(prefs_path_schedule_type_));
};
VLOG(1) << "Switching user pref service from "
<< vlog_helper(active_user_pref_service_) << " to "
<< vlog_helper(pref_service) << ".";
// Initial login and user switching in multi profiles.
active_user_pref_service_ = pref_service;
// Give the feature a chance to do its own initialization before the first
// call to `RefreshFeatureState()` (made within `InitFromUserPrefs()`).
InitFeatureForNewActiveUser();
InitFromUserPrefs();
}
void ScheduledFeature::OnGeopositionChanged(bool possible_change_in_timezone) {
DCHECK(GetScheduleType() != ScheduleType::kNone);
VLOG(1) << "Received new geoposition.";
// We only keep manual toggles if there's no change in timezone.
const bool keep_manual_toggles_during_schedules =
!possible_change_in_timezone;
Refresh(RefreshReason::kReset, keep_manual_toggles_during_schedules);
}
void ScheduledFeature::SuspendDone(base::TimeDelta sleep_duration) {
// Time changes while the device is suspended. We need to refresh the schedule
// upon device resume to know what the status should be now.
Refresh(RefreshReason::kReset,
/*keep_manual_toggles_during_schedules=*/true);
}
base::Time ScheduledFeature::Now() const {
return clock_->Now();
}
void ScheduledFeature::SetClockForTesting(const Clock* clock) {
CHECK(clock);
clock_ = clock;
CHECK(!timer_->IsRunning());
timer_ = std::make_unique<base::OneShotTimer>(clock_);
}
void ScheduledFeature::SetLocalTimeConverterForTesting(
const LocalTimeConverter* local_time_converter) {
local_time_converter_ = local_time_converter;
}
void ScheduledFeature::SetTaskRunnerForTesting(
scoped_refptr<base::SequencedTaskRunner> task_runner) {
CHECK(!timer_->IsRunning());
timer_->SetTaskRunner(std::move(task_runner));
}
const char* ScheduledFeature::GetScheduleTypeHistogramName() const {
return nullptr;
}
bool ScheduledFeature::MaybeRestoreSchedule() {
DCHECK(active_user_pref_service_);
DCHECK_NE(GetScheduleType(), ScheduleType::kNone);
auto iter = per_user_schedule_snapshot_.find(active_user_pref_service_);
if (iter == per_user_schedule_snapshot_.end()) {
return false;
}
const ScheduleSnapshot& snapshot_to_restore = iter->second;
const base::Time now = Now();
// It may be that the device was suspended for a very long time that the
// target time is no longer valid.
if (snapshot_to_restore.target_time <= now) {
return false;
}
VLOG(1) << "Restoring a previous schedule.";
current_checkpoint_ = snapshot_to_restore.current_checkpoint;
ScheduleNextRefresh(snapshot_to_restore, now);
return true;
}
void ScheduledFeature::StartWatchingPrefsChanges() {
DCHECK(active_user_pref_service_);
pref_change_registrar_ = std::make_unique<PrefChangeRegistrar>();
pref_change_registrar_->Init(active_user_pref_service_);
pref_change_registrar_->Add(
prefs_path_enabled_,
base::BindRepeating(&ScheduledFeature::OnEnabledPrefChanged,
base::Unretained(this)));
pref_change_registrar_->Add(
prefs_path_schedule_type_,
base::BindRepeating(&ScheduledFeature::OnScheduleTypePrefChanged,
base::Unretained(this)));
if (!prefs_path_custom_start_time_.empty()) {
pref_change_registrar_->Add(
prefs_path_custom_start_time_,
base::BindRepeating(&ScheduledFeature::OnCustomSchedulePrefsChanged,
base::Unretained(this)));
}
if (!prefs_path_custom_end_time_.empty()) {
pref_change_registrar_->Add(
prefs_path_custom_end_time_,
base::BindRepeating(&ScheduledFeature::OnCustomSchedulePrefsChanged,
base::Unretained(this)));
}
ListenForPrefChanges(*pref_change_registrar_);
}
void ScheduledFeature::InitFromUserPrefs() {
StartWatchingPrefsChanges();
RefreshForSettingsChanged(/*keep_manual_toggles_during_schedules=*/true);
}
void ScheduledFeature::SetEnabledInternal(bool enabled, RefreshReason reason) {
DVLOG(1) << "Setting " << GetFeatureName() << " enabled to " << enabled
<< " at " << Now();
set_enabled_refresh_reason_ = reason;
if (active_user_pref_service_)
active_user_pref_service_->SetBoolean(prefs_path_enabled_, enabled);
}
void ScheduledFeature::OnEnabledPrefChanged() {
const bool enabled = GetEnabled();
VLOG(1) << "Enable state changed. New state: " << enabled << ".";
DCHECK(active_user_pref_service_);
const RefreshReason current_reason = set_enabled_refresh_reason_;
// Reset the reason to `kExternal` in case an external caller directly
// modifies the pref afterwards.
set_enabled_refresh_reason_ = RefreshReason::kExternal;
Refresh(current_reason,
/*keep_manual_toggles_during_schedules=*/false);
}
void ScheduledFeature::OnScheduleTypePrefChanged() {
const ScheduleType schedule_type = GetScheduleType();
VLOG(1) << "Schedule type changed. New type: "
<< static_cast<int>(schedule_type) << ".";
if (const char* const schedule_type_histogram =
GetScheduleTypeHistogramName()) {
base::UmaHistogramEnumeration(schedule_type_histogram, schedule_type);
}
RefreshForSettingsChanged(/*keep_manual_toggles_during_schedules=*/false);
}
void ScheduledFeature::RefreshForSettingsChanged(
bool keep_manual_toggles_during_schedules) {
// To prevent adding an observer twice in a row when switching between
// different users, we need to check `HasObserver()`.
if (GetScheduleType() == ScheduleType::kNone) {
geolocation_controller_->RemoveObserver(this);
} else if (!geolocation_controller_->HasObserver(this)) {
geolocation_controller_->AddObserver(this);
}
Refresh(RefreshReason::kSettingsChanged,
keep_manual_toggles_during_schedules);
}
void ScheduledFeature::OnCustomSchedulePrefsChanged() {
DCHECK(active_user_pref_service_);
Refresh(RefreshReason::kSettingsChanged,
/*keep_manual_toggles_during_schedules=*/false);
}
void ScheduledFeature::Refresh(RefreshReason reason,
bool keep_manual_toggles_during_schedules) {
std::optional<base::Time> start_time;
std::optional<base::Time> end_time;
const ScheduleType schedule_type = GetScheduleType();
switch (schedule_type) {
case ScheduleType::kNone:
timer_->Stop();
RefreshFeatureState(reason);
SetCurrentCheckpoint(
GetCheckpointForEnabledState(GetEnabled(), ScheduleType::kNone));
return;
case ScheduleType::kSunsetToSunrise: {
const base::expected<base::Time, GeolocationController::SunRiseSetError>
sunrise_time = geolocation_controller_->GetSunriseTime();
const base::expected<base::Time, GeolocationController::SunRiseSetError>
sunset_time = geolocation_controller_->GetSunsetTime();
if (sunrise_time == GeolocationController::kNoSunRiseSet ||
sunset_time == GeolocationController::kNoSunRiseSet) {
// Simply disable the feature in this corner case. Since sunset and
// sunrise are exactly the same, there is no time for it to be enabled.
start_time = Now();
end_time = start_time;
} else if (sunrise_time.has_value() && sunset_time.has_value()) {
start_time = sunset_time.value();
end_time = sunrise_time.value();
} else {
// Sunrise or sunset is temporarily unavailable. Leave `start_time` and
// `end_time` unset.
}
break;
}
case ScheduleType::kCustom:
start_time = GetCustomStartTime().ToTimeToday();
end_time = GetCustomEndTime().ToTimeToday();
break;
}
// b/285187343: Timestamps can legitimately be null if getting local time
// fails.
if (!start_time || !end_time) {
LOG(ERROR) << "Received null start/end times at " << Now();
ScheduleNextRefreshRetry(keep_manual_toggles_during_schedules);
// Best effort to still make `current_checkpoint_` as accurate as possible
// before exiting and not be in an inconsistent state. The next successful
// `Refresh()` will make `current_checkpoint_` 100% accurate again.
SetCurrentCheckpoint(
GetCheckpointForEnabledState(GetEnabled(), schedule_type));
return;
}
RefreshScheduleTimer(*start_time, *end_time, reason,
keep_manual_toggles_during_schedules);
}
// The `ScheduleCheckpoint` usage in this method does not directly apply
// to `ScheduleType::kCustom`, but the business logic still works for that
// `ScheduleType` with no caller-facing impact. The internal `timer_` may just
// fire a couple more times a day and be no-ops.
void ScheduledFeature::RefreshScheduleTimer(
base::Time start_time,
base::Time end_time,
RefreshReason reason,
bool keep_manual_toggles_during_schedules) {
const ScheduleType schedule_type = GetScheduleType();
DCHECK(schedule_type != ScheduleType::kNone);
if (keep_manual_toggles_during_schedules && MaybeRestoreSchedule()) {
RefreshFeatureState(reason);
return;
}
const base::Time now = Now();
const schedule_utils::Position schedule_position =
schedule_utils::GetCurrentPosition(now, start_time, end_time,
schedule_type);
const bool enable_now =
IsEnabledAtCheckpoint(schedule_position.current_checkpoint);
const bool current_enabled = GetEnabled();
base::TimeDelta time_until_next_refresh;
bool next_feature_status = false;
ScheduleCheckpoint new_checkpoint = current_checkpoint_;
if (enable_now == current_enabled) {
// The most standard case:
next_feature_status =
IsEnabledAtCheckpoint(schedule_position.next_checkpoint);
time_until_next_refresh = schedule_position.time_until_next_checkpoint;
new_checkpoint = schedule_position.current_checkpoint;
} else if (reason == RefreshReason::kSettingsChanged ||
reason == RefreshReason::kReset) {
// If the change in the schedule or environment introduces a change in the
// status, then calling `SetEnabledInternal()` is all we need, since it will
// trigger a change in the user prefs to which we will respond by calling
// Refresh(). This will end up in this function again and enter the case
// above, adjusting all the needed schedules.
SetEnabledInternal(enable_now, reason);
return;
} else {
// Either of these is true:
// 1) The user manually toggled the feature status to the opposite of what
// the schedule says.
// 2) Sunrise tomorrow is later in the day than sunrise today. For example:
// * Sunrise Today: 6:00 AM
// * Now/Sunset Today: 6:00 PM
// * Calculated sunrise tomorrow: 6:00 AM + 1 day.
// * Actual Sunrise Tomorrow: 6:01 AM
// * At 6:00 AM the next day, feature is disabled. `RefreshScheduleTimer()`
// uses the new sunrise time of 6:01 AM. The feature's currently disabled
// even though today's sunrise/sunset times say it should be enabled. This
// effectively acts as a manual toggle.
//
// Maintain the current enabled status and keep scheduling refresh
// operations until the enabled status matches the schedule again. When that
// happens, the first case in this branch will be hit and normal scheduling
// logic should resume thereafter.
next_feature_status = current_enabled;
time_until_next_refresh = schedule_position.time_until_next_checkpoint;
new_checkpoint =
GetCheckpointForEnabledState(current_enabled, schedule_type);
}
ScheduleNextRefresh(
{now + time_until_next_refresh, next_feature_status, new_checkpoint},
now);
RefreshFeatureState(reason);
// Should be called after `ScheduleNextRefresh` and `RefreshFeatureState()`
// so that all of the feature's internal bookkeeping has been updated before
// broadcasting to users that a new feature state has been reached. This
// ensures that the feature is in a stable internal state in case a
// `CheckpointObserver` tries to use the feature immediately within its
// observer method.
SetCurrentCheckpoint(new_checkpoint);
}
void ScheduledFeature::ScheduleNextRefresh(
const ScheduleSnapshot& current_snapshot,
base::Time now) {
DCHECK(active_user_pref_service_);
const base::TimeDelta delay = current_snapshot.target_time - now;
DCHECK_GE(delay, base::TimeDelta());
refresh_failure_backoff_.Reset();
per_user_schedule_snapshot_[active_user_pref_service_] = current_snapshot;
base::OnceClosure timer_cb;
if (current_snapshot.target_status == GetEnabled()) {
timer_cb = base::BindOnce(&ScheduledFeature::Refresh,
base::Unretained(this), RefreshReason::kScheduled,
/*keep_manual_toggles_during_schedules=*/false);
} else {
timer_cb = base::BindOnce(
&ScheduledFeature::SetEnabledInternal, base::Unretained(this),
current_snapshot.target_status, RefreshReason::kScheduled);
}
VLOG(1) << "Setting " << GetFeatureName() << " to refresh to "
<< (current_snapshot.target_status ? "enabled" : "disabled") << " at "
<< current_snapshot.target_time << " in " << delay << " now= " << now;
timer_->Start(FROM_HERE, delay, std::move(timer_cb));
}
void ScheduledFeature::ScheduleNextRefreshRetry(
bool keep_manual_toggles_during_schedules) {
refresh_failure_backoff_.InformOfRequest(/*succeeded=*/false);
const base::TimeDelta retry_delay =
refresh_failure_backoff_.GetTimeUntilRelease();
LOG(ERROR) << "Refresh() failed. Scheduling retry in " << retry_delay;
// The refresh failure puts the schedule in an inaccurate state (the
// feature can be the opposite of what the schedule says it should be).
// 'RefreshReason::kReset` is appropriate and necessary to return it
// to the correct state the next time `Refresh()` can succeed.
timer_->Start(FROM_HERE, retry_delay,
base::BindOnce(&ScheduledFeature::Refresh,
base::Unretained(this), RefreshReason::kReset,
keep_manual_toggles_during_schedules));
}
void ScheduledFeature::SetCurrentCheckpoint(ScheduleCheckpoint new_checkpoint) {
if (new_checkpoint == current_checkpoint_) {
return;
}
DVLOG(1) << "Setting " << GetFeatureName() << " ScheduleCheckpoint from "
<< current_checkpoint_ << " to " << new_checkpoint << " at "
<< Now();
current_checkpoint_ = new_checkpoint;
for (CheckpointObserver& obs : checkpoint_observers_) {
obs.OnCheckpointChanged(this, current_checkpoint_);
}
}
} // namespace ash