| // Copyright 2018 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. |
| |
| #include "ash/assistant/assistant_alarm_timer_controller_impl.h" |
| |
| #include <cmath> |
| #include <utility> |
| |
| #include "ash/assistant/assistant_controller_impl.h" |
| #include "ash/assistant/assistant_notification_controller_impl.h" |
| #include "ash/assistant/util/deep_link_util.h" |
| #include "ash/strings/grit/ash_strings.h" |
| #include "base/i18n/message_formatter.h" |
| #include "base/stl_util.h" |
| #include "base/strings/stringprintf.h" |
| #include "base/strings/utf_string_conversions.h" |
| #include "base/time/time.h" |
| #include "chromeos/services/assistant/public/cpp/assistant_notification.h" |
| #include "chromeos/services/assistant/public/cpp/assistant_service.h" |
| #include "chromeos/services/assistant/public/cpp/features.h" |
| #include "mojo/public/cpp/bindings/pending_receiver.h" |
| #include "mojo/public/cpp/bindings/receiver.h" |
| #include "third_party/icu/source/common/unicode/utypes.h" |
| #include "third_party/icu/source/i18n/unicode/measfmt.h" |
| #include "third_party/icu/source/i18n/unicode/measunit.h" |
| #include "third_party/icu/source/i18n/unicode/measure.h" |
| #include "ui/base/l10n/l10n_util.h" |
| |
| namespace ash { |
| |
| namespace { |
| |
| using assistant::util::AlarmTimerAction; |
| using chromeos::assistant::AssistantNotification; |
| using chromeos::assistant::AssistantNotificationButton; |
| using chromeos::assistant::AssistantNotificationPriority; |
| using chromeos::assistant::features::IsTimersV2Enabled; |
| |
| // Grouping key and ID prefix for timer notifications. |
| constexpr char kTimerNotificationGroupingKey[] = "assistant/timer"; |
| constexpr char kTimerNotificationIdPrefix[] = "assistant/timer"; |
| |
| // Helpers --------------------------------------------------------------------- |
| |
| std::string ToFormattedTimeString(base::TimeDelta time, |
| UMeasureFormatWidth width) { |
| DCHECK(width == UMEASFMT_WIDTH_NARROW || width == UMEASFMT_WIDTH_NUMERIC); |
| |
| // Method aliases to prevent line-wrapping below. |
| const auto createHour = icu::MeasureUnit::createHour; |
| const auto createMinute = icu::MeasureUnit::createMinute; |
| const auto createSecond = icu::MeasureUnit::createSecond; |
| |
| // We round |total_seconds| to the nearest full second since we don't display |
| // our time string w/ millisecond granularity and because this method is |
| // called very near to full second boundaries. Otherwise, values like 4.99 sec |
| // would be displayed to the user as "0:04" instead of the expected "0:05". |
| const int64_t total_seconds = std::abs(std::round(time.InSecondsF())); |
| |
| // Calculate time in hours/minutes/seconds. |
| const int32_t hours = total_seconds / 3600; |
| const int32_t minutes = (total_seconds - hours * 3600) / 60; |
| const int32_t seconds = total_seconds % 60; |
| |
| // Success of the ICU APIs is tracked by |status|. |
| UErrorCode status = U_ZERO_ERROR; |
| |
| // Create our distinct |measures| to be formatted. |
| std::vector<icu::Measure> measures; |
| |
| // We only show |hours| if necessary. |
| if (hours) |
| measures.push_back(icu::Measure(hours, createHour(status), status)); |
| |
| // We only show |minutes| if necessary or if using numeric format |width|. |
| if (minutes || width == UMEASFMT_WIDTH_NUMERIC) |
| measures.push_back(icu::Measure(minutes, createMinute(status), status)); |
| |
| // We only show |seconds| if necessary or if using numeric format |width|. |
| if (seconds || width == UMEASFMT_WIDTH_NUMERIC) |
| measures.push_back(icu::Measure(seconds, createSecond(status), status)); |
| |
| // Format our |measures| into a |unicode_message|. |
| icu::UnicodeString unicode_message; |
| icu::FieldPosition field_position = icu::FieldPosition::DONT_CARE; |
| icu::MeasureFormat measure_format(icu::Locale::getDefault(), width, status); |
| measure_format.formatMeasures(measures.data(), measures.size(), |
| unicode_message, field_position, status); |
| |
| std::string formatted_time; |
| if (U_SUCCESS(status)) { |
| // If formatting was successful, convert our |unicode_message| into UTF-8. |
| unicode_message.toUTF8String(formatted_time); |
| } else { |
| // If something went wrong formatting w/ ICU, fall back to I18N messages. |
| LOG(ERROR) << "Error formatting time string: " << status; |
| formatted_time = |
| base::UTF16ToUTF8(base::i18n::MessageFormatter::FormatWithNumberedArgs( |
| l10n_util::GetStringUTF16( |
| width == UMEASFMT_WIDTH_NARROW |
| ? IDS_ASSISTANT_TIMER_NOTIFICATION_FORMATTED_TIME_NARROW_FALLBACK |
| : IDS_ASSISTANT_TIMER_NOTIFICATION_FORMATTED_TIME_NUMERIC_FALLBACK), |
| hours, minutes, seconds)); |
| } |
| |
| // If necessary, negate the amount of time remaining. |
| if (time.InSeconds() < 0) { |
| formatted_time = |
| base::UTF16ToUTF8(base::i18n::MessageFormatter::FormatWithNumberedArgs( |
| l10n_util::GetStringUTF16( |
| IDS_ASSISTANT_TIMER_NOTIFICATION_FORMATTED_TIME_NEGATE), |
| formatted_time)); |
| } |
| |
| return formatted_time; |
| } |
| |
| // Returns a string representation of the original duration for a given |timer|. |
| std::string ToOriginalDurationString(const AssistantTimer& timer) { |
| return ToFormattedTimeString(timer.original_duration, UMEASFMT_WIDTH_NARROW); |
| } |
| |
| // Returns a string representation of the remaining time for the given |timer|. |
| std::string ToRemainingTimeString(const AssistantTimer& timer) { |
| return ToFormattedTimeString(timer.remaining_time, UMEASFMT_WIDTH_NUMERIC); |
| } |
| |
| // Creates a notification ID for the given |timer|. It is guaranteed that this |
| // method will always return the same notification ID given the same timer. |
| std::string CreateTimerNotificationId(const AssistantTimer& timer) { |
| return std::string(kTimerNotificationIdPrefix) + timer.id; |
| } |
| |
| // Creates a notification title for the given |timer|. |
| std::string CreateTimerNotificationTitle(const AssistantTimer& timer) { |
| if (IsTimersV2Enabled()) |
| return ToRemainingTimeString(timer); |
| return l10n_util::GetStringUTF8(IDS_ASSISTANT_TIMER_NOTIFICATION_TITLE); |
| } |
| |
| // Creates a notification message for the given |timer|. |
| std::string CreateTimerNotificationMessage(const AssistantTimer& timer) { |
| if (IsTimersV2Enabled()) { |
| if (timer.label.empty()) { |
| return base::UTF16ToUTF8( |
| base::i18n::MessageFormatter::FormatWithNumberedArgs( |
| l10n_util::GetStringUTF16( |
| timer.state == AssistantTimerState::kFired |
| ? IDS_ASSISTANT_TIMER_NOTIFICATION_MESSAGE_WHEN_FIRED |
| : IDS_ASSISTANT_TIMER_NOTIFICATION_MESSAGE), |
| ToOriginalDurationString(timer))); |
| } |
| return base::UTF16ToUTF8(base::i18n::MessageFormatter::FormatWithNumberedArgs( |
| l10n_util::GetStringUTF16( |
| timer.state == AssistantTimerState::kFired |
| ? IDS_ASSISTANT_TIMER_NOTIFICATION_MESSAGE_WHEN_FIRED_WITH_LABEL |
| : IDS_ASSISTANT_TIMER_NOTIFICATION_MESSAGE_WITH_LABEL), |
| ToOriginalDurationString(timer), timer.label)); |
| } |
| return ToRemainingTimeString(timer); |
| } |
| |
| // Creates notification action URL for the given |timer|. |
| GURL CreateTimerNotificationActionUrl(const AssistantTimer& timer) { |
| // In timers v2, clicking the notification does nothing. |
| if (IsTimersV2Enabled()) |
| return GURL(); |
| // In timers v1, clicking the notification removes the |timer|. |
| return assistant::util::CreateAlarmTimerDeepLink( |
| AlarmTimerAction::kRemoveAlarmOrTimer, timer.id) |
| .value(); |
| } |
| |
| // Creates notification buttons for the given |timer|. |
| std::vector<AssistantNotificationButton> CreateTimerNotificationButtons( |
| const AssistantTimer& timer) { |
| std::vector<AssistantNotificationButton> buttons; |
| |
| if (!IsTimersV2Enabled()) { |
| // "STOP" button. |
| buttons.push_back( |
| {l10n_util::GetStringUTF8(IDS_ASSISTANT_TIMER_NOTIFICATION_STOP_BUTTON), |
| assistant::util::CreateAlarmTimerDeepLink( |
| AlarmTimerAction::kRemoveAlarmOrTimer, timer.id) |
| .value(), |
| /*remove_notification_on_click=*/true}); |
| |
| // "ADD 1 MIN" button. |
| buttons.push_back({l10n_util::GetStringUTF8( |
| IDS_ASSISTANT_TIMER_NOTIFICATION_ADD_1_MIN_BUTTON), |
| assistant::util::CreateAlarmTimerDeepLink( |
| AlarmTimerAction::kAddTimeToTimer, timer.id, |
| base::TimeDelta::FromMinutes(1)) |
| .value(), |
| /*remove_notification_on_click=*/true}); |
| |
| return buttons; |
| } |
| |
| DCHECK(IsTimersV2Enabled()); |
| |
| if (timer.state != AssistantTimerState::kFired) { |
| if (timer.state == AssistantTimerState::kPaused) { |
| // "RESUME" button. |
| buttons.push_back({l10n_util::GetStringUTF8( |
| IDS_ASSISTANT_TIMER_NOTIFICATION_RESUME_BUTTON), |
| assistant::util::CreateAlarmTimerDeepLink( |
| AlarmTimerAction::kResumeTimer, timer.id) |
| .value(), |
| /*remove_notification_on_click=*/false}); |
| } else { |
| // "PAUSE" button. |
| buttons.push_back({l10n_util::GetStringUTF8( |
| IDS_ASSISTANT_TIMER_NOTIFICATION_PAUSE_BUTTON), |
| assistant::util::CreateAlarmTimerDeepLink( |
| AlarmTimerAction::kPauseTimer, timer.id) |
| .value(), |
| /*remove_notification_on_click=*/false}); |
| } |
| } |
| |
| if (timer.state == AssistantTimerState::kFired) { |
| // "STOP" button. |
| buttons.push_back( |
| {l10n_util::GetStringUTF8(IDS_ASSISTANT_TIMER_NOTIFICATION_STOP_BUTTON), |
| assistant::util::CreateAlarmTimerDeepLink( |
| AlarmTimerAction::kRemoveAlarmOrTimer, timer.id) |
| .value(), |
| /*remove_notification_on_click=*/true}); |
| |
| // "ADD 1 MIN" button. |
| buttons.push_back({l10n_util::GetStringUTF8( |
| IDS_ASSISTANT_TIMER_NOTIFICATION_ADD_1_MIN_BUTTON), |
| assistant::util::CreateAlarmTimerDeepLink( |
| AlarmTimerAction::kAddTimeToTimer, timer.id, |
| base::TimeDelta::FromMinutes(1)) |
| .value(), |
| /*remove_notification_on_click=*/false}); |
| } else { |
| // "CANCEL" button. |
| buttons.push_back({l10n_util::GetStringUTF8( |
| IDS_ASSISTANT_TIMER_NOTIFICATION_CANCEL_BUTTON), |
| assistant::util::CreateAlarmTimerDeepLink( |
| AlarmTimerAction::kRemoveAlarmOrTimer, timer.id) |
| .value(), |
| /*remove_notification_on_click=*/true}); |
| } |
| |
| return buttons; |
| } |
| |
| // Creates a timer notification priority for the given |timer|. |
| AssistantNotificationPriority CreateTimerNotificationPriority( |
| const AssistantTimer& timer) { |
| // In timers v1, all notifications are |kHigh| priority. |
| if (!IsTimersV2Enabled()) |
| return AssistantNotificationPriority::kHigh; |
| |
| // In timers v2, a notification for a |kFired| timer is |kHigh| priority. |
| // This will cause the notification to pop up to the user. |
| if (timer.state == AssistantTimerState::kFired) |
| return AssistantNotificationPriority::kHigh; |
| |
| // If the notification has lived for at least |kPopupThreshold|, drop the |
| // priority to |kLow| so that the notification will not pop up to the user. |
| constexpr base::TimeDelta kPopupThreshold = base::TimeDelta::FromSeconds(6); |
| const base::TimeDelta lifetime = |
| base::Time::Now() - timer.creation_time.value_or(base::Time::Now()); |
| if (lifetime >= kPopupThreshold) |
| return AssistantNotificationPriority::kLow; |
| |
| // Otherwise, the notification is |kDefault| priority. This means that it |
| // may or may not pop up to the user, depending on the presence of other |
| // notifications. |
| return AssistantNotificationPriority::kDefault; |
| } |
| |
| // Creates a notification for the given |timer|. |
| AssistantNotification CreateTimerNotification( |
| const AssistantTimer& timer, |
| const AssistantNotification* existing_notification = nullptr) { |
| AssistantNotification notification; |
| notification.title = CreateTimerNotificationTitle(timer); |
| notification.message = CreateTimerNotificationMessage(timer); |
| notification.action_url = CreateTimerNotificationActionUrl(timer); |
| notification.buttons = CreateTimerNotificationButtons(timer); |
| notification.client_id = CreateTimerNotificationId(timer); |
| notification.grouping_key = kTimerNotificationGroupingKey; |
| notification.priority = CreateTimerNotificationPriority(timer); |
| notification.remove_on_click = !IsTimersV2Enabled(); |
| notification.is_pinned = IsTimersV2Enabled(); |
| |
| // If we are creating a notification to replace an |existing_notification| and |
| // our new notification has higher priority, we want the system to "renotify" |
| // the user of the notification change. This will cause the new notification |
| // to popup to the user even if it was previously marked as read. |
| if (existing_notification && |
| notification.priority > existing_notification->priority) { |
| notification.renotify = true; |
| } |
| |
| return notification; |
| } |
| |
| // Returns whether an |update| from LibAssistant to the specified |original| |
| // timer is allowed. Updates are always allowed in v1, only conditionally in v2. |
| bool ShouldAllowUpdateFromLibAssistant(const AssistantTimer& original, |
| const AssistantTimer& update) { |
| // If |id| is not equal, then |update| does refer to the |original| timer. |
| DCHECK_EQ(original.id, update.id); |
| |
| // In v1, LibAssistant updates are always allowed since we only ever manage a |
| // single timer at a time and only as it transitions from firing to removal. |
| if (!IsTimersV2Enabled()) |
| return true; |
| |
| // In v2, updates are only allowed from LibAssistant if they are significant. |
| // We may receive an update due to a state change in another timer, and we'd |
| // want to discard the update to this timer to avoid introducing UI jank by |
| // updating its notification outside of its regular tick interval. In v2, we |
| // also update timer state from |kScheduled| to |kFired| ourselves to work |
| // around latency in receiving the event from LibAssistant. When we do so, we |
| // expect to later receive the state change from LibAssistant but discard it. |
| return !original.IsEqualInLibAssistantTo(update); |
| } |
| |
| } // namespace |
| |
| // AssistantAlarmTimerControllerImpl ------------------------------------------ |
| |
| AssistantAlarmTimerControllerImpl::AssistantAlarmTimerControllerImpl( |
| AssistantControllerImpl* assistant_controller) |
| : assistant_controller_(assistant_controller) { |
| model_.AddObserver(this); |
| assistant_controller_observer_.Add(AssistantController::Get()); |
| } |
| |
| AssistantAlarmTimerControllerImpl::~AssistantAlarmTimerControllerImpl() { |
| model_.RemoveObserver(this); |
| } |
| |
| void AssistantAlarmTimerControllerImpl::SetAssistant( |
| chromeos::assistant::Assistant* assistant) { |
| assistant_ = assistant; |
| } |
| |
| const AssistantAlarmTimerModel* AssistantAlarmTimerControllerImpl::GetModel() |
| const { |
| return &model_; |
| } |
| |
| void AssistantAlarmTimerControllerImpl::OnTimerStateChanged( |
| std::vector<AssistantTimerPtr> new_or_updated_timers) { |
| // First we remove all old timers that no longer exist. |
| for (const auto* old_timer : model_.GetAllTimers()) { |
| if (std::none_of(new_or_updated_timers.begin(), new_or_updated_timers.end(), |
| [&old_timer](const auto& new_or_updated_timer) { |
| return old_timer->id == new_or_updated_timer->id; |
| })) { |
| model_.RemoveTimer(old_timer->id); |
| } |
| } |
| |
| // Then we add any new timers and update existing ones (if allowed). |
| for (auto& new_or_updated_timer : new_or_updated_timers) { |
| const auto* original_timer = model_.GetTimerById(new_or_updated_timer->id); |
| const bool is_new_timer = original_timer == nullptr; |
| if (is_new_timer || ShouldAllowUpdateFromLibAssistant( |
| *original_timer, *new_or_updated_timer)) { |
| model_.AddOrUpdateTimer(std::move(new_or_updated_timer)); |
| } |
| } |
| } |
| |
| void AssistantAlarmTimerControllerImpl::OnAssistantControllerConstructed() { |
| AssistantState::Get()->AddObserver(this); |
| } |
| |
| void AssistantAlarmTimerControllerImpl::OnAssistantControllerDestroying() { |
| AssistantState::Get()->RemoveObserver(this); |
| } |
| |
| void AssistantAlarmTimerControllerImpl::OnDeepLinkReceived( |
| assistant::util::DeepLinkType type, |
| const std::map<std::string, std::string>& params) { |
| using assistant::util::DeepLinkParam; |
| using assistant::util::DeepLinkType; |
| |
| if (type != DeepLinkType::kAlarmTimer) |
| return; |
| |
| const base::Optional<AlarmTimerAction>& action = |
| assistant::util::GetDeepLinkParamAsAlarmTimerAction(params); |
| if (!action.has_value()) |
| return; |
| |
| const base::Optional<std::string>& alarm_timer_id = |
| assistant::util::GetDeepLinkParam(params, DeepLinkParam::kId); |
| if (!alarm_timer_id.has_value()) |
| return; |
| |
| // Duration is optional. Only used for adding time to timer. |
| const base::Optional<base::TimeDelta>& duration = |
| assistant::util::GetDeepLinkParamAsTimeDelta(params, |
| DeepLinkParam::kDurationMs); |
| |
| PerformAlarmTimerAction(action.value(), alarm_timer_id.value(), duration); |
| } |
| |
| void AssistantAlarmTimerControllerImpl::OnAssistantStatusChanged( |
| chromeos::assistant::AssistantStatus status) { |
| // If LibAssistant is no longer running we need to clear our cache to |
| // accurately reflect LibAssistant alarm/timer state. |
| if (status == chromeos::assistant::AssistantStatus::NOT_READY) |
| model_.RemoveAllTimers(); |
| } |
| |
| void AssistantAlarmTimerControllerImpl::OnTimerAdded( |
| const AssistantTimer& timer) { |
| // Schedule the next tick of |timer|. |
| ScheduleNextTick(timer); |
| |
| // Create a notification for the added alarm/timer. |
| assistant_controller_->notification_controller()->AddOrUpdateNotification( |
| CreateTimerNotification(timer)); |
| } |
| |
| void AssistantAlarmTimerControllerImpl::OnTimerUpdated( |
| const AssistantTimer& timer) { |
| // Schedule the next tick of |timer|. |
| ScheduleNextTick(timer); |
| |
| auto* notification_controller = |
| assistant_controller_->notification_controller(); |
| const auto* existing_notification = |
| notification_controller->model()->GetNotificationById( |
| CreateTimerNotificationId(timer)); |
| |
| // When a |timer| is updated we need to update the corresponding notification |
| // unless it has already been dismissed by the user. |
| if (existing_notification) { |
| notification_controller->AddOrUpdateNotification( |
| CreateTimerNotification(timer, existing_notification)); |
| } |
| } |
| |
| void AssistantAlarmTimerControllerImpl::OnTimerRemoved( |
| const AssistantTimer& timer) { |
| // Clean up the ticker for |timer|, if one exists. |
| tickers_.erase(timer.id); |
| |
| // Remove any notification associated w/ |timer|. |
| assistant_controller_->notification_controller()->RemoveNotificationById( |
| CreateTimerNotificationId(timer), /*from_server=*/false); |
| } |
| |
| void AssistantAlarmTimerControllerImpl::PerformAlarmTimerAction( |
| const AlarmTimerAction& action, |
| const std::string& alarm_timer_id, |
| const base::Optional<base::TimeDelta>& duration) { |
| DCHECK(assistant_); |
| |
| switch (action) { |
| case AlarmTimerAction::kAddTimeToTimer: |
| if (!duration.has_value()) { |
| LOG(ERROR) << "Ignoring add time to timer action duration."; |
| return; |
| } |
| assistant_->AddTimeToTimer(alarm_timer_id, duration.value()); |
| break; |
| case AlarmTimerAction::kPauseTimer: |
| DCHECK(!duration.has_value()); |
| assistant_->PauseTimer(alarm_timer_id); |
| break; |
| case AlarmTimerAction::kRemoveAlarmOrTimer: |
| DCHECK(!duration.has_value()); |
| assistant_->RemoveAlarmOrTimer(alarm_timer_id); |
| break; |
| case AlarmTimerAction::kResumeTimer: |
| DCHECK(!duration.has_value()); |
| assistant_->ResumeTimer(alarm_timer_id); |
| break; |
| } |
| } |
| |
| void AssistantAlarmTimerControllerImpl::ScheduleNextTick( |
| const AssistantTimer& timer) { |
| auto& ticker = tickers_[timer.id]; |
| if (ticker.IsRunning()) |
| return; |
| |
| // The next tick of |timer| should occur at its next full second of remaining |
| // time. Here we are calculating the number of milliseconds to that next full |
| // second. |
| int millis_to_next_full_sec = timer.remaining_time.InMilliseconds() % 1000; |
| |
| // If |timer| has already fired, |millis_to_next_full_sec| will be negative. |
| // In this case, we take the inverse of the value to get the correct number of |
| // milliseconds to the next full second of remaining time. |
| if (millis_to_next_full_sec < 0) |
| millis_to_next_full_sec = 1000 + millis_to_next_full_sec; |
| |
| // If we are exactly at the boundary of a full second, we want to make sure |
| // we wait until the next second to perform the next tick. Otherwise we'll end |
| // up w/ a superfluous tick that is unnecessary. |
| if (millis_to_next_full_sec == 0) |
| millis_to_next_full_sec = 1000; |
| |
| // NOTE: We pass a copy of |timer.id| here as |timer| may no longer exist |
| // when Tick() is called due to the possibility of the |model_| being updated |
| // via a call to OnTimerStateChanged(), such as might happen if a timer is |
| // created, paused, resumed, or removed by LibAssistant. |
| ticker.Start(FROM_HERE, |
| base::TimeDelta::FromMilliseconds(millis_to_next_full_sec), |
| base::BindOnce(&AssistantAlarmTimerControllerImpl::Tick, |
| base::Unretained(this), timer.id)); |
| } |
| |
| void AssistantAlarmTimerControllerImpl::Tick(const std::string& timer_id) { |
| const auto* timer = model_.GetTimerById(timer_id); |
| DCHECK(timer); |
| |
| // We don't tick paused timers. Once the |timer| resumes, ticking will resume. |
| if (timer->state == AssistantTimerState::kPaused) |
| return; |
| |
| // Update |timer| to reflect the new amount of |remaining_time|. |
| AssistantTimerPtr updated_timer = std::make_unique<AssistantTimer>(*timer); |
| updated_timer->remaining_time = updated_timer->fire_time - base::Time::Now(); |
| |
| // If there is no remaining time left on the timer, we ensure that our timer |
| // is marked as |kFired|. Since LibAssistant may be a bit slow to notify us of |
| // the change in state, we set the value ourselves to eliminate UI jank. |
| // NOTE: We use the rounded value of |remaining_time| since that's what we are |
| // displaying to the user and otherwise would be out of sync for ticks |
| // occurring at full second boundary values. |
| if (std::round(updated_timer->remaining_time.InSecondsF()) <= 0.f) |
| updated_timer->state = AssistantTimerState::kFired; |
| |
| model_.AddOrUpdateTimer(std::move(updated_timer)); |
| } |
| |
| } // namespace ash |