From 04375267e1438b4d0cf9134dc661755fad75c217 Mon Sep 17 00:00:00 2001 From: JunsuChoi Date: Fri, 24 Jul 2026 12:44:17 +0900 Subject: [PATCH 1/2] Send view focus events and handle focus change requests The engine's view-focus embedder API (FlutterEngineSendViewFocusEvent, view_focus_change_request_callback) is declared in embedder.h but was never wired up, so PlatformDispatcher.onViewFocusChange and requestViewFocusChange did not work on Tizen. Since the embedder has no native window focus event handler, the app lifecycle is used as a proxy for view focus: kFocused is sent to the engine when the app is resumed, and kUnfocused when it becomes inactive or paused. Focus change requests from the framework are handled by activating the window. Windows created with focusable = false are marked with ecore_wl2_window_focus_skip_set and can never receive native key focus, so kFocused events and window activation are skipped for them. Fixes flutter-tizen/embedder#187 --- flutter/shell/platform/tizen/flutter_tizen.cc | 18 +++++++++-- .../platform/tizen/flutter_tizen_engine.cc | 14 +++++++++ .../platform/tizen/flutter_tizen_engine.h | 3 ++ .../platform/tizen/flutter_tizen_view.cc | 31 +++++++++++++++++++ .../shell/platform/tizen/flutter_tizen_view.h | 9 ++++++ flutter/shell/platform/tizen/tizen_window.h | 2 ++ 6 files changed, 74 insertions(+), 3 deletions(-) diff --git a/flutter/shell/platform/tizen/flutter_tizen.cc b/flutter/shell/platform/tizen/flutter_tizen.cc index a89a2574..91a29171 100644 --- a/flutter/shell/platform/tizen/flutter_tizen.cc +++ b/flutter/shell/platform/tizen/flutter_tizen.cc @@ -167,15 +167,27 @@ void FlutterDesktopEngineNotifyLowMemoryWarning( } void FlutterDesktopEngineNotifyAppIsInactive(FlutterDesktopEngineRef engine) { - EngineFromHandle(engine)->lifecycle_channel()->AppIsInactive(); + flutter::FlutterTizenEngine* tizen_engine = EngineFromHandle(engine); + tizen_engine->lifecycle_channel()->AppIsInactive(); + if (tizen_engine->view()) { + tizen_engine->view()->OnFocus(kUnfocused); + } } void FlutterDesktopEngineNotifyAppIsResumed(FlutterDesktopEngineRef engine) { - EngineFromHandle(engine)->lifecycle_channel()->AppIsResumed(); + flutter::FlutterTizenEngine* tizen_engine = EngineFromHandle(engine); + tizen_engine->lifecycle_channel()->AppIsResumed(); + if (tizen_engine->view()) { + tizen_engine->view()->OnFocus(kFocused); + } } void FlutterDesktopEngineNotifyAppIsPaused(FlutterDesktopEngineRef engine) { - EngineFromHandle(engine)->lifecycle_channel()->AppIsPaused(); + flutter::FlutterTizenEngine* tizen_engine = EngineFromHandle(engine); + tizen_engine->lifecycle_channel()->AppIsPaused(); + if (tizen_engine->view()) { + tizen_engine->view()->OnFocus(kUnfocused); + } } void FlutterDesktopEngineNotifyAppIsDetached(FlutterDesktopEngineRef engine) { diff --git a/flutter/shell/platform/tizen/flutter_tizen_engine.cc b/flutter/shell/platform/tizen/flutter_tizen_engine.cc index 3c3611ea..2de1a11a 100644 --- a/flutter/shell/platform/tizen/flutter_tizen_engine.cc +++ b/flutter/shell/platform/tizen/flutter_tizen_engine.cc @@ -195,6 +195,13 @@ bool FlutterTizenEngine::RunEngine() { auto* engine = static_cast(user_data); engine->OnUpdateSemantics(update); }; + args.view_focus_change_request_callback = + [](const FlutterViewFocusChangeRequest* request, void* user_data) { + auto* engine = static_cast(user_data); + if (engine->view()) { + engine->view()->OnFocusChangeRequest(*request); + } + }; if (IsHeaded() && dynamic_cast(renderer_.get())) { vsync_waiter_ = std::make_unique(this); @@ -341,6 +348,13 @@ void FlutterTizenEngine::SendPointerEvent(const FlutterPointerEvent& event) { embedder_api_.SendPointerEvent(engine_, &event, 1); } +void FlutterTizenEngine::SendViewFocusEvent( + const FlutterViewFocusEvent& event) { + if (engine_) { + embedder_api_.SendViewFocusEvent(engine_, &event); + } +} + void FlutterTizenEngine::SendWindowMetrics(int32_t x, int32_t y, int32_t width, diff --git a/flutter/shell/platform/tizen/flutter_tizen_engine.h b/flutter/shell/platform/tizen/flutter_tizen_engine.h index c6cfa341..ffe34d4d 100644 --- a/flutter/shell/platform/tizen/flutter_tizen_engine.h +++ b/flutter/shell/platform/tizen/flutter_tizen_engine.h @@ -144,6 +144,9 @@ class FlutterTizenEngine { // Informs the engine of an incoming pointer event. void SendPointerEvent(const FlutterPointerEvent& event); + // Informs the engine of a native view focus change. + void SendViewFocusEvent(const FlutterViewFocusEvent& event); + // Sends a window metrics update to the Flutter engine using current window // dimensions in physical void SendWindowMetrics(int32_t x, diff --git a/flutter/shell/platform/tizen/flutter_tizen_view.cc b/flutter/shell/platform/tizen/flutter_tizen_view.cc index 030e12db..f748c356 100644 --- a/flutter/shell/platform/tizen/flutter_tizen_view.cc +++ b/flutter/shell/platform/tizen/flutter_tizen_view.cc @@ -151,6 +151,37 @@ void FlutterTizenView::OnRotate(int32_t degree) { SendWindowMetrics(geometry.left, geometry.top, width, height, 0.0); } +void FlutterTizenView::OnFocus(FlutterViewFocusState focus_state) { + if (focus_state == kFocused) { + auto* window = dynamic_cast(tizen_view_.get()); + if (window && !window->focusable()) { + return; + } + } + if (focus_state == last_focus_state_) { + return; + } + last_focus_state_ = focus_state; + FlutterViewFocusEvent event = {}; + event.struct_size = sizeof(event); + event.view_id = view_id_; + event.state = focus_state; + event.direction = FlutterViewFocusDirection::kUndefined; + engine_->SendViewFocusEvent(event); +} + +void FlutterTizenView::OnFocusChangeRequest( + const FlutterViewFocusChangeRequest& request) { + if (request.view_id != view_id_ || request.state != kFocused) { + return; + } + if (auto* window = dynamic_cast(tizen_view_.get())) { + if (window->focusable()) { + window->ActivateWindow(); + } + } +} + FlutterTizenView::PointerState* FlutterTizenView::GetOrCreatePointerState( FlutterPointerDeviceKind device_kind, int32_t device_id) { diff --git a/flutter/shell/platform/tizen/flutter_tizen_view.h b/flutter/shell/platform/tizen/flutter_tizen_view.h index 4533acc8..821ded2d 100644 --- a/flutter/shell/platform/tizen/flutter_tizen_view.h +++ b/flutter/shell/platform/tizen/flutter_tizen_view.h @@ -51,6 +51,10 @@ class FlutterTizenView : public TizenViewEventHandlerDelegate { void OnRotate(int32_t degree) override; + void OnFocus(FlutterViewFocusState focus_state); + + void OnFocusChangeRequest(const FlutterViewFocusChangeRequest& request); + void OnPointerMove(double x, double y, size_t timestamp, @@ -166,6 +170,11 @@ class FlutterTizenView : public TizenViewEventHandlerDelegate { // Keeps track of pointer states. std::unordered_map> pointer_states_; + // The focus state most recently sent to the engine. Views start out + // unfocused. Used to avoid sending duplicate focus events when multiple + // lifecycle transitions map to the same focus state. + FlutterViewFocusState last_focus_state_ = kUnfocused; + // The plugin registrar managing internal plugins. std::unique_ptr internal_plugin_registrar_; diff --git a/flutter/shell/platform/tizen/tizen_window.h b/flutter/shell/platform/tizen/tizen_window.h index 90395bdc..a77b8508 100644 --- a/flutter/shell/platform/tizen/tizen_window.h +++ b/flutter/shell/platform/tizen/tizen_window.h @@ -36,6 +36,8 @@ class TizenWindow : public TizenViewBase { virtual void LowerWindow() = 0; + bool focusable() { return focusable_; } + protected: explicit TizenWindow(TizenGeometry geometry, bool transparent, From c7dbca3e812b21697d6d2fc74cf97689fd14fe4f Mon Sep 17 00:00:00 2001 From: JunsuChoi Date: Wed, 5 Aug 2026 13:46:20 +0900 Subject: [PATCH 2/2] test(tizen): cover view focus behavior --- flutter/shell/platform/tizen/BUILD.gn | 1 + .../tizen/flutter_tizen_engine_unittest.cc | 1 + .../tizen/flutter_tizen_view_unittests.cc | 118 ++++++++++++++++++ .../platform/tizen/testing/engine_modifier.h | 4 + 4 files changed, 124 insertions(+) create mode 100644 flutter/shell/platform/tizen/flutter_tizen_view_unittests.cc diff --git a/flutter/shell/platform/tizen/BUILD.gn b/flutter/shell/platform/tizen/BUILD.gn index d942cdec..3c21911a 100644 --- a/flutter/shell/platform/tizen/BUILD.gn +++ b/flutter/shell/platform/tizen/BUILD.gn @@ -235,6 +235,7 @@ executable("flutter_tizen_unittests") { "flutter_project_bundle_unittests.cc", "flutter_tizen_engine_unittest.cc", "flutter_tizen_texture_registrar_unittests.cc", + "flutter_tizen_view_unittests.cc", ] ldflags = [ "-Wl,--unresolved-symbols=ignore-in-shared-libs" ] diff --git a/flutter/shell/platform/tizen/flutter_tizen_engine_unittest.cc b/flutter/shell/platform/tizen/flutter_tizen_engine_unittest.cc index 55978b69..15665584 100644 --- a/flutter/shell/platform/tizen/flutter_tizen_engine_unittest.cc +++ b/flutter/shell/platform/tizen/flutter_tizen_engine_unittest.cc @@ -65,6 +65,7 @@ TEST_F(FlutterTizenEngineTest, RunDoesExpectedInitialization) { EXPECT_STREQ(args->icu_data_path, "/foo/icudtl.dat"); EXPECT_EQ(args->dart_entrypoint_argc, 0); EXPECT_NE(args->platform_message_callback, nullptr); + EXPECT_NE(args->view_focus_change_request_callback, nullptr); EXPECT_NE(args->custom_task_runners, nullptr); EXPECT_EQ(args->custom_task_runners->platform_task_runner, args->custom_task_runners->ui_task_runner); diff --git a/flutter/shell/platform/tizen/flutter_tizen_view_unittests.cc b/flutter/shell/platform/tizen/flutter_tizen_view_unittests.cc new file mode 100644 index 00000000..0ac71df2 --- /dev/null +++ b/flutter/shell/platform/tizen/flutter_tizen_view_unittests.cc @@ -0,0 +1,118 @@ +// Copyright 2026 Samsung Electronics Co., Ltd. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "flutter/shell/platform/tizen/flutter_tizen_view.h" + +#include + +#include + +#include "flutter/shell/platform/embedder/test_utils/proc_table_replacement.h" +#include "flutter/shell/platform/tizen/testing/engine_modifier.h" +#include "flutter/shell/platform/tizen/tizen_window.h" +#include "gtest/gtest.h" + +namespace flutter { +namespace testing { +namespace { + +class TestTizenView : public TizenWindow { + public: + TestTizenView() : TizenWindow({}, false, true, false) { + input_method_context_ = std::make_unique(0); + } + + void* GetRenderTarget() override { return nullptr; } + void* GetNativeHandle() override { return nullptr; } + uintptr_t GetWindowId() override { return 0; } + TizenGeometry GetGeometry() override { return {0, 0, 100, 100}; } + bool SetGeometry(TizenGeometry geometry) override { return true; } + int32_t GetDpi() override { return 160; } + uint32_t GetResourceId() override { return 0; } + void UpdateFlutterCursor(const std::string& kind) override {} + void Show() override {} + int32_t GetRotation() override { return 0; } + void SetPreferredOrientations(const std::vector& rotations) override {} + void* GetRenderTargetDisplay() override { return nullptr; } + TizenGeometry GetScreenGeometry() override { return GetGeometry(); } + void BindKeys(const std::vector& keys) override {} + void ActivateWindow() override { activated = true; } + void RaiseWindow() override {} + void LowerWindow() override {} + + void SetFocusable(bool focusable) { focusable_ = focusable; } + + bool activated = false; +}; + +TEST(FlutterTizenViewTest, SendsAndRequestsViewFocus) { + ecore_init(); + { + FlutterDesktopEngineProperties properties = {}; + properties.assets_path = "/foo/flutter_assets"; + properties.icu_data_path = "/foo/icudtl.dat"; + properties.aot_library_path = "/foo/libapp.so"; + + FlutterProjectBundle project(properties); + auto engine = std::make_unique(project); + std::vector sent_events; + EngineModifier modifier(engine.get()); + modifier.SetEngine(reinterpret_cast(1)); + modifier.embedder_api().SendViewFocusEvent = MOCK_ENGINE_PROC( + SendViewFocusEvent, + ([&sent_events](auto engine, const FlutterViewFocusEvent* event) { + sent_events.push_back(*event); + return kSuccess; + })); + modifier.embedder_api().Shutdown = [](auto engine) { return kSuccess; }; + + auto tizen_view = std::make_unique(); + TestTizenView* tizen_view_ptr = tizen_view.get(); + FlutterTizenView view(kImplicitViewId, std::move(tizen_view), + std::move(engine), kEVulkan); + + view.OnFocus(kFocused); + view.OnFocus(kFocused); + ASSERT_EQ(sent_events.size(), 1u); + EXPECT_EQ(sent_events[0].struct_size, sizeof(FlutterViewFocusEvent)); + EXPECT_EQ(sent_events[0].view_id, kImplicitViewId); + EXPECT_EQ(sent_events[0].state, kFocused); + EXPECT_EQ(sent_events[0].direction, kUndefined); + + view.OnFocus(kUnfocused); + view.OnFocus(kUnfocused); + ASSERT_EQ(sent_events.size(), 2u); + EXPECT_EQ(sent_events[1].state, kUnfocused); + + FlutterViewFocusChangeRequest request = { + .struct_size = sizeof(FlutterViewFocusChangeRequest), + .view_id = kImplicitViewId, + .state = kUnfocused, + .direction = kUndefined, + }; + view.OnFocusChangeRequest(request); + EXPECT_FALSE(tizen_view_ptr->activated); + + request.state = kFocused; + request.view_id = kImplicitViewId + 1; + view.OnFocusChangeRequest(request); + EXPECT_FALSE(tizen_view_ptr->activated); + + request.view_id = kImplicitViewId; + view.OnFocusChangeRequest(request); + EXPECT_TRUE(tizen_view_ptr->activated); + + tizen_view_ptr->activated = false; + tizen_view_ptr->SetFocusable(false); + view.OnFocus(kFocused); + EXPECT_EQ(sent_events.size(), 2u); + view.OnFocusChangeRequest(request); + EXPECT_FALSE(tizen_view_ptr->activated); + } + ecore_shutdown(); +} + +} // namespace +} // namespace testing +} // namespace flutter diff --git a/flutter/shell/platform/tizen/testing/engine_modifier.h b/flutter/shell/platform/tizen/testing/engine_modifier.h index 4075ff77..0d2988f8 100644 --- a/flutter/shell/platform/tizen/testing/engine_modifier.h +++ b/flutter/shell/platform/tizen/testing/engine_modifier.h @@ -23,6 +23,10 @@ class EngineModifier { // engine unless overwritten again. FlutterEngineProcTable& embedder_api() { return engine_->embedder_api_; } + void SetEngine(FLUTTER_API_SYMBOL(FlutterEngine) engine) { + engine_->engine_ = engine; + } + private: FlutterTizenEngine* engine_; };