blob: 5b24a0c5db45f16cfe8b7aa6bee79de5954c4eac [file] [log] [blame]
// Copyright 2020 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 "components/exo/ui_lock_controller.h"
#include <memory>
#include "ash/bluetooth_devices_observer.h"
#include "ash/constants/app_types.h"
#include "ash/constants/ash_features.h"
#include "ash/public/cpp/keyboard/keyboard_controller.h"
#include "ash/public/cpp/session/session_controller.h"
#include "ash/resources/vector_icons/vector_icons.h"
#include "ash/wm/window_state.h"
#include "ash/wm/window_state_observer.h"
#include "base/bind.h"
#include "base/feature_list.h"
#include "base/scoped_observation.h"
#include "base/time/time.h"
#include "base/timer/timer.h"
#include "chromeos/ui/base/window_properties.h"
#include "components/exo/pointer.h"
#include "components/exo/seat.h"
#include "components/exo/shell_surface_util.h"
#include "components/exo/surface.h"
#include "components/exo/wm_helper.h"
#include "components/exo/wm_helper_chromeos.h"
#include "components/fullscreen_control/fullscreen_control_popup.h"
#include "components/fullscreen_control/subtle_notification_view.h"
#include "components/strings/grit/components_strings.h"
#include "third_party/abseil-cpp/absl/types/optional.h"
#include "ui/aura/client/aura_constants.h"
#include "ui/aura/window.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/user_activity/user_activity_detector.h"
#include "ui/base/user_activity/user_activity_observer.h"
#include "ui/events/devices/device_data_manager.h"
#include "ui/events/event_constants.h"
#include "ui/events/keycodes/dom/dom_code.h"
#include "ui/gfx/paint_vector_icon.h"
#include "ui/strings/grit/ui_strings.h"
#include "ui/views/widget/widget.h"
namespace {
// The Esc hold notification shows a message to press and hold Esc to exit
// fullscreen. It will hide after a 4s timeout but shows again each time the
// window goes to fullscreen.
//
// The exit popup is a circle with an 'X' close icon which exits fullscreen when
// the user clicks it.
// * It is only shown on windows with property kEscHoldToExitFullscreen.
// * It is displayed when the mouse moves to the top 3px of the screen.
// * It will hide after a 3s timeout, or the user moves below 150px.
// * After hiding, there is a cooldown where it will not display again until the
// mouse moves below 150px.
// Duration to show notifications.
constexpr auto kNotificationDuration = base::Seconds(4);
// Position of Esc notification from top of screen.
const int kEscNotificationTopPx = 45;
// Duration to show the exit 'X' popup.
constexpr auto kExitPopupDuration = base::Seconds(3);
// Display the exit popup if mouse is above this height.
constexpr float kExitPopupDisplayHeight = 3.f;
// Hide the exit popup if mouse is below this height.
constexpr float kExitPopupHideHeight = 150.f;
// Once the pointer capture notification has finished showing without
// being interrupted, don't show it again until this long has passed.
constexpr auto kPointerCaptureNotificationCooldown = base::Minutes(5);
constexpr auto kReshowNotificationsWhenIdleFor = base::Minutes(5);
constexpr int kUILockControllerSeatObserverPriority = 1;
static_assert(
exo::Seat::IsValidObserverPriority(kUILockControllerSeatObserverPriority),
"kUILockCOntrollerSeatObserverPriority is not in the valid range");
bool IsUILockControllerEnabled(aura::Window* window) {
if (!window)
return false;
if (window->GetProperty(chromeos::kEscHoldToExitFullscreen) ||
window->GetProperty(chromeos::kUseOverviewToExitFullscreen) ||
window->GetProperty(chromeos::kUseOverviewToExitPointerLock)) {
return true;
}
return false;
}
// Return true if an external keyboard is attached to the device.
//
// Note: May mistakenly return true when certain non-keyboard devices are
// attached; see crbug/882410, crbug/884096.
//
// Copied from ash/keyboard/virtual_keyboard_controller.cc.
// TODO(cpelling): Refactor to avoid duplicating this logic.
bool HasExternalKeyboard() {
ui::DeviceDataManager* device_data_manager =
ui::DeviceDataManager::GetInstance();
ash::BluetoothDevicesObserver bluetooth(base::DoNothing());
for (const ui::InputDevice& device :
device_data_manager->GetKeyboardDevices()) {
ui::InputDeviceType type = device.type;
if (type == ui::InputDeviceType::INPUT_DEVICE_USB ||
(type == ui::InputDeviceType::INPUT_DEVICE_BLUETOOTH &&
bluetooth.IsConnectedBluetoothDevice(device))) {
return true;
}
}
return false;
}
// Creates the separator view between bubble views of modifiers and key.
std::unique_ptr<views::View> CreateIconView(const gfx::VectorIcon& icon) {
constexpr int kIconSize = 28;
std::unique_ptr<views::ImageView> view = std::make_unique<views::ImageView>();
gfx::ImageSkia image = gfx::CreateVectorIcon(icon, SK_ColorWHITE);
view->SetImage(ui::ImageModel::FromImageSkia(image));
view->SetImageSize(gfx::Size(kIconSize, kIconSize));
return view;
}
// Create and position Esc notification.
views::Widget* CreateEscNotification(
aura::Window* parent,
int message_id,
std::initializer_list<int> key_message_ids) {
auto content_view = std::make_unique<SubtleNotificationView>();
std::vector<std::u16string> key_names;
std::vector<std::unique_ptr<views::View>> icons;
for (int key_message_id : key_message_ids) {
key_names.push_back(l10n_util::GetStringUTF16(key_message_id));
if (key_message_id == IDS_APP_OVERVIEW_KEY) {
icons.push_back(CreateIconView(ash::kKsvOverviewIcon));
} else {
icons.push_back(nullptr);
}
}
content_view->UpdateContent(
l10n_util::GetStringFUTF16(message_id, key_names, nullptr),
std::move(icons));
gfx::Size size = content_view->GetPreferredSize();
views::Widget* popup = SubtleNotificationView::CreatePopupWidget(
parent, std::move(content_view));
popup->SetZOrderLevel(ui::ZOrderLevel::kSecuritySurface);
gfx::Rect bounds = parent->GetBoundsInScreen();
int y = bounds.y() + kEscNotificationTopPx;
bounds.ClampToCenteredSize(size);
bounds.set_y(y);
popup->SetBounds(bounds);
return popup;
}
// Exits fullscreen to previous state.
void ExitFullscreen(aura::Window* window) {
ash::WindowState* window_state = ash::WindowState::Get(window);
if (window_state->IsFullscreen())
window_state->Restore();
}
// Owns the widgets for messages prompting to exit fullscreen/mouselock, and
// the exit popup. Owned as a window property.
class ExitNotifier : public ui::EventHandler,
public exo::UILockController::Notifier,
public ash::WindowStateObserver {
public:
explicit ExitNotifier(exo::UILockController* controller, aura::Window* window)
: window_(window) {
controller_observation_.Observe(controller);
ash::WindowState* window_state = ash::WindowState::Get(window);
window_state_observation_.Observe(window_state);
if (window_state->IsFullscreen())
OnFullscreen();
}
ExitNotifier(const ExitNotifier&) = delete;
ExitNotifier& operator=(const ExitNotifier&) = delete;
~ExitNotifier() override {
OnExitFullscreen();
ClosePointerCaptureNotification();
}
void OnPointerCaptureEnabled() {
pointer_is_captured_ = true;
MaybeShowPointerCaptureNotification();
}
void OnPointerCaptureDisabled() { pointer_is_captured_ = false; }
// If this window is currently in a state that would have triggered a
// notification when entered, re-show that notification as a reminder.
void NotifyAgain() override {
// Always reset the notification cooldown, to ensure notifications show in
// the case where pointer lock is not currently active but will be soon.
next_pointer_notify_time_ = base::TimeTicks::Now();
ash::WindowState* window_state = ash::WindowState::Get(window_);
if (window_state->IsFullscreen()) {
OnFullscreen();
} else if (pointer_is_captured_) {
MaybeShowPointerCaptureNotification();
}
}
void OnUILockControllerDestroying() override {
controller_observation_.Reset();
}
views::Widget* fullscreen_esc_notification() {
return fullscreen_esc_notification_;
}
views::Widget* pointer_capture_notification() {
return pointer_capture_notification_;
}
FullscreenControlPopup* exit_popup() { return exit_popup_.get(); }
private:
void MaybeShowPointerCaptureNotification() {
// Respect cooldown.
if (base::TimeTicks::Now() < next_pointer_notify_time_)
return;
want_pointer_capture_notification_ = true;
// Don't show in fullscreen; the fullscreen notification will show and is
// prioritized.
ash::WindowState* window_state = ash::WindowState::Get(window_);
if (window_state->IsFullscreen())
return;
if (pointer_capture_notification_) {
pointer_capture_notification_->CloseWithReason(
views::Widget::ClosedReason::kUnspecified);
}
if (HasExternalKeyboard()) {
if (ash::KeyboardController::Get()->AreTopRowKeysFunctionKeys()) {
pointer_capture_notification_ =
CreateEscNotification(window_, IDS_PRESS_TO_EXIT_MOUSELOCK_TWO_KEYS,
{IDS_APP_META_KEY, IDS_APP_F5_KEY});
} else {
pointer_capture_notification_ = CreateEscNotification(
window_, IDS_PRESS_TO_EXIT_MOUSELOCK, {IDS_APP_F5_KEY});
}
} else {
if (ash::KeyboardController::Get()->AreTopRowKeysFunctionKeys()) {
pointer_capture_notification_ =
CreateEscNotification(window_, IDS_PRESS_TO_EXIT_MOUSELOCK_TWO_KEYS,
{IDS_APP_SEARCH_KEY, IDS_APP_OVERVIEW_KEY});
} else {
pointer_capture_notification_ = CreateEscNotification(
window_, IDS_PRESS_TO_EXIT_MOUSELOCK, {IDS_APP_OVERVIEW_KEY});
}
}
pointer_capture_notification_->Show();
// Close Esc notification after 4s.
pointer_capture_notify_timer_.Start(
FROM_HERE, kNotificationDuration,
base::BindOnce(&ExitNotifier::OnPointerCaptureNotifyTimerFinished,
base::Unretained(this)));
}
void ClosePointerCaptureNotification() {
pointer_capture_notify_timer_.Stop();
if (pointer_capture_notification_) {
pointer_capture_notification_->CloseWithReason(
views::Widget::ClosedReason::kUnspecified);
pointer_capture_notification_ = nullptr;
}
}
void OnPointerCaptureNotifyTimerFinished() {
// Start the cooldown when the timer successfully elapses, to ensure the
// notification was shown for a sufficiently long time.
next_pointer_notify_time_ =
base::TimeTicks::Now() + kPointerCaptureNotificationCooldown;
ClosePointerCaptureNotification();
want_pointer_capture_notification_ = false;
}
// Overridden from ui::EventHandler:
void OnMouseEvent(ui::MouseEvent* event) override {
gfx::PointF point = event->location_f();
aura::Window::ConvertPointToTarget(
static_cast<aura::Window*>(event->target()), window_, &point);
if (!fullscreen_esc_notification_ && !exit_popup_cooldown_ &&
window_ == exo::WMHelper::GetInstance()->GetActiveWindow() &&
point.y() <= kExitPopupDisplayHeight) {
// Show exit popup if mouse is above 3px, unless esc notification is
// visible, or during cooldown (popup shown and mouse still at top).
if (!exit_popup_) {
exit_popup_ = std::make_unique<FullscreenControlPopup>(
window_, base::BindRepeating(&ExitFullscreen, window_),
base::DoNothing());
}
views::Widget* widget =
views::Widget::GetTopLevelWidgetForNativeView(window_);
exit_popup_->Show(widget->GetClientAreaBoundsInScreen());
exit_popup_timer_.Start(
FROM_HERE, kExitPopupDuration,
base::BindOnce(&ExitNotifier::HideExitPopup, base::Unretained(this),
/*animate=*/true));
exit_popup_cooldown_ = true;
} else if (point.y() > kExitPopupHideHeight) {
// Hide exit popup if mouse is below 150px, reset cooloff.
HideExitPopup(/*animate=*/true);
exit_popup_cooldown_ = false;
}
}
// Overridden from ash::WindowStateObserver:
void OnPostWindowStateTypeChange(
ash::WindowState* window_state,
chromeos::WindowStateType old_type) override {
DCHECK_EQ(window_, window_state->window());
if (window_state->IsFullscreen()) {
OnFullscreen();
} else {
OnExitFullscreen();
}
}
void OnFullscreen() {
// Register ui::EventHandler to watch if mouse goes to top of screen.
if (!is_handling_events_ &&
window_->GetProperty(chromeos::kEscHoldToExitFullscreen)) {
window_->AddPreTargetHandler(this);
is_handling_events_ = true;
}
// Only show Esc notification when window is active.
if (window_ != exo::WMHelper::GetInstance()->GetActiveWindow())
return;
// Fullscreen notifications override pointer capture notifications.
ClosePointerCaptureNotification();
if (fullscreen_esc_notification_) {
fullscreen_esc_notification_->CloseWithReason(
views::Widget::ClosedReason::kUnspecified);
}
if (window_->GetProperty(chromeos::kUseOverviewToExitFullscreen)) {
if (HasExternalKeyboard()) {
if (ash::KeyboardController::Get()->AreTopRowKeysFunctionKeys()) {
fullscreen_esc_notification_ = CreateEscNotification(
window_, IDS_FULLSCREEN_PRESS_TO_EXIT_FULLSCREEN_TWO_KEYS,
{IDS_APP_META_KEY, IDS_APP_F5_KEY});
} else {
fullscreen_esc_notification_ = CreateEscNotification(
window_, IDS_FULLSCREEN_PRESS_TO_EXIT_FULLSCREEN,
{IDS_APP_F5_KEY});
}
} else {
if (ash::KeyboardController::Get()->AreTopRowKeysFunctionKeys()) {
fullscreen_esc_notification_ = CreateEscNotification(
window_, IDS_FULLSCREEN_PRESS_TO_EXIT_FULLSCREEN_TWO_KEYS,
{IDS_APP_SEARCH_KEY, IDS_APP_OVERVIEW_KEY});
} else {
fullscreen_esc_notification_ = CreateEscNotification(
window_, IDS_FULLSCREEN_PRESS_TO_EXIT_FULLSCREEN,
{IDS_APP_OVERVIEW_KEY});
}
}
} else {
fullscreen_esc_notification_ = CreateEscNotification(
window_,
window_->GetProperty(chromeos::kEscHoldToExitFullscreen)
? IDS_FULLSCREEN_HOLD_TO_EXIT_FULLSCREEN
: IDS_FULLSCREEN_PRESS_TO_EXIT_FULLSCREEN,
{IDS_APP_ESC_KEY});
}
fullscreen_esc_notification_->Show();
// Close Esc notification after 4s.
fullscreen_notify_timer_.Start(
FROM_HERE, kNotificationDuration,
base::BindOnce(&ExitNotifier::CloseFullscreenEscNotification,
base::Unretained(this)));
}
void OnExitFullscreen() {
if (is_handling_events_) {
window_->RemovePreTargetHandler(this);
is_handling_events_ = false;
}
CloseFullscreenEscNotification();
HideExitPopup();
}
void CloseFullscreenEscNotification() {
if (!fullscreen_esc_notification_)
return;
fullscreen_esc_notification_->CloseWithReason(
views::Widget::ClosedReason::kUnspecified);
fullscreen_esc_notification_ = nullptr;
// If a pointer capture notification was previously requested and didn't
// show (or didn't complete its timer), show it now.
//
// This is to prevent the following scenario:
// 1. App goes fullscreen
// 2. App immediately requests pointer capture; no notification is shown,
// since the fullscreen notification is already visible.
// 3. App immediately unfullscreens; the fullscreen notification closes.
//
// Without this check, the app would have gained pointer capture without
// any notification showing.
if (want_pointer_capture_notification_)
MaybeShowPointerCaptureNotification();
}
void HideExitPopup(bool animate = false) {
if (exit_popup_)
exit_popup_->Hide(animate);
}
aura::Window* const window_;
views::Widget* fullscreen_esc_notification_ = nullptr;
views::Widget* pointer_capture_notification_ = nullptr;
bool want_pointer_capture_notification_ = false;
bool pointer_is_captured_ = false;
std::unique_ptr<FullscreenControlPopup> exit_popup_;
bool is_handling_events_ = false;
bool exit_popup_cooldown_ = false;
base::OneShotTimer fullscreen_notify_timer_;
base::OneShotTimer pointer_capture_notify_timer_;
base::TimeTicks next_pointer_notify_time_;
base::OneShotTimer exit_popup_timer_;
base::ScopedObservation<ash::WindowState, ash::WindowStateObserver>
window_state_observation_{this};
base::ScopedObservation<exo::UILockController,
exo::UILockController::Notifier>
controller_observation_{this};
};
} // namespace
DEFINE_UI_CLASS_PROPERTY_TYPE(ExitNotifier*)
namespace exo {
namespace {
DEFINE_OWNED_UI_CLASS_PROPERTY_KEY(ExitNotifier, kExitNotifierKey, nullptr)
ExitNotifier* GetExitNotifier(UILockController* controller,
aura::Window* window,
bool create) {
if (!base::FeatureList::IsEnabled(chromeos::features::kExoLockNotification))
return nullptr;
if (!window)
return nullptr;
aura::Window* toplevel = window->GetToplevelWindow();
if (!IsUILockControllerEnabled(toplevel))
return nullptr;
ExitNotifier* notifier = toplevel->GetProperty(kExitNotifierKey);
if (!notifier && create) {
// Object is owned as a window property.
notifier = toplevel->SetProperty(
kExitNotifierKey, std::make_unique<ExitNotifier>(controller, toplevel));
}
return notifier;
}
} // namespace
constexpr auto kLongPressEscapeDuration = base::Seconds(2);
constexpr auto kExcludedFlags = ui::EF_SHIFT_DOWN | ui::EF_CONTROL_DOWN |
ui::EF_ALT_DOWN | ui::EF_COMMAND_DOWN |
ui::EF_ALTGR_DOWN | ui::EF_IS_REPEAT;
UILockController::UILockController(Seat* seat) : seat_(seat) {
last_activity_time_ = base::TimeTicks::Now();
WMHelper::GetInstance()->AddPreTargetHandler(this);
seat_->AddObserver(this, kUILockControllerSeatObserverPriority);
WMHelper::GetInstance()->AddPowerObserver(this);
auto* session_controller = ash::SessionController::Get();
if (session_controller)
session_controller->AddObserver(this);
ui::UserActivityDetector::Get()->AddObserver(this);
}
UILockController::~UILockController() {
ui::UserActivityDetector::Get()->RemoveObserver(this);
auto* session_controller = ash::SessionController::Get();
if (session_controller)
session_controller->RemoveObserver(this);
WMHelper::GetInstance()->RemovePowerObserver(this);
seat_->RemoveObserver(this);
WMHelper::GetInstance()->RemovePreTargetHandler(this);
for (Notifier& notifier : notifiers_)
notifier.OnUILockControllerDestroying();
}
void UILockController::OnKeyEvent(ui::KeyEvent* event) {
// TODO(oshima): Rather than handling key event here, add a hook in
// keyboard.cc to intercept key event and handle this.
// If no surface is focused, let another handler process the event.
aura::Window* window = static_cast<aura::Window*>(event->target());
if (!GetTargetSurfaceForKeyboardFocus(window))
return;
if (event->code() == ui::DomCode::ESCAPE &&
(event->flags() & kExcludedFlags) == 0) {
OnEscapeKey(event->type() == ui::ET_KEY_PRESSED);
}
}
void UILockController::SuspendDone() {
ReshowAllNotifications();
}
void UILockController::ScreenBrightnessChanged(double percent) {
// Show alert when the device returns from low (epsilon) brightness which
// covers three cases.
// 1. The device returns from sleep.
// 2. The device lid is opened (with sleep on).
// 3. The device returns from low display brightness.
double epsilon = std::numeric_limits<double>::epsilon();
if (percent <= epsilon) {
device_in_dark_ = true;
} else {
if (device_in_dark_)
ReshowAllNotifications();
device_in_dark_ = false;
}
}
void UILockController::LidEventReceived(bool opened) {
// Show alert when the lid is opened. This also covers the case when the user
// turns off "Sleep when cover is closed".
if (opened)
ReshowAllNotifications();
}
void UILockController::OnLockStateChanged(bool locked) {
if (!locked)
ReshowAllNotifications();
}
void UILockController::OnSurfaceFocused(Surface* gained_focus,
Surface* lost_focus,
bool has_focused_surface) {
if (gained_focus != focused_surface_to_unlock_)
StopTimer();
if (gained_focus)
GetExitNotifier(this, gained_focus->window(), true);
}
void UILockController::OnPointerCaptureEnabled(Pointer* pointer,
aura::Window* window) {
aura::Window* toplevel = window ? window->GetToplevelWindow() : nullptr;
if (!toplevel ||
!toplevel->GetProperty(chromeos::kUseOverviewToExitPointerLock))
return;
captured_pointers_.insert(pointer);
ExitNotifier* notifier = GetExitNotifier(this, window, false);
if (notifier)
notifier->OnPointerCaptureEnabled();
}
void UILockController::OnPointerCaptureDisabled(Pointer* pointer,
aura::Window* window) {
if (captured_pointers_.empty())
return;
captured_pointers_.erase(pointer);
if (captured_pointers_.empty()) {
ExitNotifier* notifier = GetExitNotifier(this, window, false);
if (notifier)
notifier->OnPointerCaptureDisabled();
}
}
void UILockController::OnUserActivity(const ui::Event* event) {
base::TimeTicks now = base::TimeTicks::Now();
if (now - last_activity_time_ >= kReshowNotificationsWhenIdleFor) {
ReshowAllNotifications();
}
last_activity_time_ = now;
}
views::Widget* UILockController::GetPointerCaptureNotificationForTesting(
aura::Window* window) {
return window->GetProperty(kExitNotifierKey)->pointer_capture_notification();
}
views::Widget* UILockController::GetEscNotificationForTesting(
aura::Window* window) {
return window->GetProperty(kExitNotifierKey)->fullscreen_esc_notification();
}
FullscreenControlPopup* UILockController::GetExitPopupForTesting(
aura::Window* window) {
return window->GetProperty(kExitNotifierKey)->exit_popup();
}
void UILockController::AddObserver(UILockController::Notifier* notifier) {
notifiers_.AddObserver(notifier);
}
void UILockController::RemoveObserver(UILockController::Notifier* notifier) {
notifiers_.RemoveObserver(notifier);
}
void UILockController::ReshowAllNotifications() {
VLOG(1) << "ReshowAllNotifications";
for (Notifier& notifier : notifiers_)
notifier.NotifyAgain();
}
namespace {
bool EscapeHoldShouldExitFullscreen(Seat* seat) {
auto* surface = seat->GetFocusedSurface();
if (!surface)
return false;
auto* widget =
views::Widget::GetTopLevelWidgetForNativeView(surface->window());
if (!widget)
return false;
aura::Window* window = widget->GetNativeWindow();
if (!window || !window->GetProperty(chromeos::kEscHoldToExitFullscreen)) {
return false;
}
auto* window_state = ash::WindowState::Get(window);
return window_state && window_state->IsFullscreen();
}
} // namespace
void UILockController::OnEscapeKey(bool pressed) {
if (pressed) {
if (EscapeHoldShouldExitFullscreen(seat_) &&
!exit_fullscreen_timer_.IsRunning()) {
focused_surface_to_unlock_ = seat_->GetFocusedSurface();
exit_fullscreen_timer_.Start(
FROM_HERE, kLongPressEscapeDuration,
base::BindOnce(&UILockController::OnEscapeHeld,
base::Unretained(this)));
}
} else {
StopTimer();
}
}
void UILockController::OnEscapeHeld() {
auto* surface = seat_->GetFocusedSurface();
if (!surface || surface != focused_surface_to_unlock_) {
focused_surface_to_unlock_ = nullptr;
return;
}
focused_surface_to_unlock_ = nullptr;
ExitFullscreen(surface->window()->GetToplevelWindow());
}
void UILockController::StopTimer() {
if (exit_fullscreen_timer_.IsRunning()) {
exit_fullscreen_timer_.Stop();
focused_surface_to_unlock_ = nullptr;
}
}
} // namespace exo