// 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 #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 CreateIconView(const gfx::VectorIcon& icon) { constexpr int kIconSize = 28; std::unique_ptr view = std::make_unique(); 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 key_message_ids) { auto content_view = std::make_unique(); std::vector key_names; std::vector> 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(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( 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 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 window_state_observation_{this}; base::ScopedObservation 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(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(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::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