[root_presenter] Report focus changes to the keyboard subsystem

The keyboard subsystem needs to know when the focus changes so that
it can dispatch key events correctly.  Root presenter's old code did
not report the focus changes, so this commit is adding a connection
that does just that.

In the process, I discovered that the `FocusChain` spec claims that
`focus_chain` is always set, which is not actually the case: early
in the setup we may end up with unset `focus_chain`, so modified the
protocol spec for `FocusChain` to document this.

Tested: fx test //src/ui/bin/root_presenter/tests
  fx make-integration-patch ...
  fx ... lsc ...
Fixed: 70966
Change-Id: Ia54f29f816b61673eafffdf8a7137ac928490067
Reviewed-on: https://fuchsia-review.googlesource.com/c/fuchsia/+/511640
Fuchsia-Auto-Submit: Filip Filmar <fmil@google.com>
Reviewed-by: Alice Neels <neelsa@google.com>
Reviewed-by: Mukesh Agrawal <quiche@google.com>
API-Review: Alice Neels <neelsa@google.com>
API-Review: Jaeheon Yi <jaeheon@google.com>
Commit-Queue: Auto-Submit <auto-submit@fuchsia-infra.iam.gserviceaccount.com>
diff --git a/sdk/fidl/fuchsia.ui.focus/focus_chain.fidl b/sdk/fidl/fuchsia.ui.focus/focus_chain.fidl
index 203ac1e..d84958b 100644
--- a/sdk/fidl/fuchsia.ui.focus/focus_chain.fidl
+++ b/sdk/fidl/fuchsia.ui.focus/focus_chain.fidl
@@ -8,24 +8,38 @@
 
 /// A FocusChain tracks the status of the View hierarchy as View focus changes.
 ///
-/// Description. The `focus_chain` is a vector of ViewRefs in order of
-/// dominance in the View hierarchy; each pair of elements represents a
-/// parent-child relationship.  The root View is always present and occupies
-/// slot 0.  The newly-focused View receives a fuchsia.ui.input.FocusEvent and
-/// occupies the final slot in the vector.  If a View gets destroyed, a
-/// FocusChain holder that listens will receive a `ZX_EVENTPAIR_PEER_CLOSED`
-/// signal on the corresponding ViewRef.
-///
-/// Invalidation. A FocusChain is invalid if one if its ViewRefs is invalid.
-///
 /// Reception. Only certain components may receive a FocusChain, as it
 /// captures global information about the scene graph.
 resource table FocusChain {
+    /// The `focus_chain` is reported in order of dominance in the View
+    /// hierarchy; each adjacent pair of elements represents a
+    /// parent-child relationship.
+    ///
+    /// The `focus_chain` MAY be unset when `FocusChain` message is received, if
+    /// the message is sent very early in the scene setup, before the first
+    /// view is available.
+    ///
+    /// When `focus_chain` is set, however, the root View is always present
+    /// and occupies slot 0 in the `focus_chain`.  The newly-focused View
+    /// receives a `fuchsia.ui.input.FocusEvent` and occupies the final slot
+    /// in the vector.
+    ///
+    /// If a View gets destroyed, a `FocusChain` holder that listens will
+    /// receive a `ZX_EVENTPAIR_PEER_CLOSED` signal on the corresponding
+    /// `ViewRef`.
+    ///
+    /// ## Invalidation.
+    ///
+    /// A FocusChain is invalid if any one if its ViewRefs is
+    /// invalid.
     1: vector<fuchsia.ui.views.ViewRef> focus_chain;
 };
 
 /// A FocusChainListener receives an updated FocusChain when focus changes.
 protocol FocusChainListener {
+    /// Sent when a focus change occurs.  Since `focus_chain` may contain an
+    /// empty update, every handler MUST respond to the message even
+    /// if its contents are not immediately useful.
     OnFocusChange(FocusChain focus_chain) -> ();
 };
 
diff --git a/src/ui/bin/root_presenter/BUILD.gn b/src/ui/bin/root_presenter/BUILD.gn
index 60925b8..c870a639 100644
--- a/src/ui/bin/root_presenter/BUILD.gn
+++ b/src/ui/bin/root_presenter/BUILD.gn
@@ -75,6 +75,7 @@
 
   public_deps = [
     ":factory_reset_manager",
+    ":focus_dispatcher",
     "//sdk/fidl/fuchsia.accessibility",
     "//sdk/fidl/fuchsia.media.sounds",
     "//sdk/fidl/fuchsia.recovery",
@@ -83,6 +84,7 @@
     "//sdk/fidl/fuchsia.ui.gfx",
     "//sdk/fidl/fuchsia.ui.input",
     "//sdk/fidl/fuchsia.ui.input.accessibility",
+    "//sdk/fidl/fuchsia.ui.keyboard.focus",
     "//sdk/fidl/fuchsia.ui.pointerinjector",
     "//sdk/fidl/fuchsia.ui.policy",
     "//sdk/fidl/fuchsia.ui.policy.accessibility",
@@ -160,6 +162,20 @@
   ]
 }
 
+source_set("focus_dispatcher") {
+  sources = [
+    "focus_dispatcher.cc",
+    "focus_dispatcher.h",
+  ]
+
+  public_deps = [
+    "//sdk/fidl/fuchsia.ui.focus",
+    "//sdk/fidl/fuchsia.ui.keyboard.focus",
+    "//sdk/lib/sys/cpp",
+    "//src/lib/fxl",
+  ]
+}
+
 source_set("factory_reset_manager") {
   sources = [
     "factory_reset_manager.cc",
diff --git a/src/ui/bin/root_presenter/app.cc b/src/ui/bin/root_presenter/app.cc
index 317eca7..db4b1c4 100644
--- a/src/ui/bin/root_presenter/app.cc
+++ b/src/ui/bin/root_presenter/app.cc
@@ -5,6 +5,7 @@
 #include "src/ui/bin/root_presenter/app.h"
 
 #include <fuchsia/ui/input/cpp/fidl.h>
+#include <fuchsia/ui/keyboard/focus/cpp/fidl.h>
 #include <lib/async/dispatcher.h>
 #include <lib/fidl/cpp/clone.h>
 #include <lib/fostr/fidl/fuchsia/ui/input/formatting.h>
@@ -218,6 +219,8 @@
       Reset();
     });
 
+    focus_dispatcher_ = std::make_unique<FocusDispatcher>(component_context_->svc());
+
     view_focuser_.set_error_handler([](zx_status_t error) {
       FX_LOGS(ERROR) << "ViewFocuser died with error " << zx_status_get_string(error);
     });
@@ -326,6 +329,7 @@
     focuser_binding_.Close(ZX_ERR_BAD_STATE);
     return;
   }
+
   view_focuser_->RequestFocus(std::move(view_ref), std::move(callback));
 }
 
diff --git a/src/ui/bin/root_presenter/app.h b/src/ui/bin/root_presenter/app.h
index 6fbb494..6cccc9e 100644
--- a/src/ui/bin/root_presenter/app.h
+++ b/src/ui/bin/root_presenter/app.h
@@ -30,6 +30,7 @@
 #include "src/ui/bin/root_presenter/color_transform_handler.h"
 #include "src/ui/bin/root_presenter/constants.h"
 #include "src/ui/bin/root_presenter/factory_reset_manager.h"
+#include "src/ui/bin/root_presenter/focus_dispatcher.h"
 #include "src/ui/bin/root_presenter/inspect.h"
 #include "src/ui/bin/root_presenter/media_buttons_handler.h"
 #include "src/ui/bin/root_presenter/presentation.h"
@@ -183,6 +184,9 @@
   bool is_scenic_initialized_ = false;
   std::unique_ptr<ColorTransformHandler> color_transform_handler_;
 
+  // Used to dispatch the focus change messages to interested downstream clients.
+  std::unique_ptr<FocusDispatcher> focus_dispatcher_;
+
   FXL_DISALLOW_COPY_AND_ASSIGN(App);
 };
 
diff --git a/src/ui/bin/root_presenter/color_transform_handler.cc b/src/ui/bin/root_presenter/color_transform_handler.cc
index 38f0fde..b455105 100644
--- a/src/ui/bin/root_presenter/color_transform_handler.cc
+++ b/src/ui/bin/root_presenter/color_transform_handler.cc
@@ -37,7 +37,8 @@
   FX_DCHECK(safe_presenter_);
   component_context->svc()->Connect(color_transform_manager_.NewRequest());
   color_transform_manager_.set_error_handler([](zx_status_t status) {
-    FX_LOGS(ERROR) << "Unable to connect to ColorTransformManager" << zx_status_get_string(status);
+    FX_LOGS(ERROR) << "Unable to connect to ColorTransformManager: "
+                   << zx_status_get_string(status);
   });
   fidl::InterfaceHandle<fuchsia::accessibility::ColorTransformHandler> handle;
   color_transform_handler_bindings_.Bind(handle.NewRequest());
diff --git a/src/ui/bin/root_presenter/focus_dispatcher.cc b/src/ui/bin/root_presenter/focus_dispatcher.cc
new file mode 100644
index 0000000..054f797
--- /dev/null
+++ b/src/ui/bin/root_presenter/focus_dispatcher.cc
@@ -0,0 +1,60 @@
+// Copyright 2021 The Fuchsia 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 "src/ui/bin/root_presenter/focus_dispatcher.h"
+
+#include <fuchsia/ui/focus/cpp/fidl.h>
+#include <fuchsia/ui/keyboard/focus/cpp/fidl.h>
+#include <lib/fidl/cpp/binding_set.h>
+#include <lib/sys/cpp/component_context.h>
+#include <lib/syslog/cpp/macros.h>
+#include <zircon/status.h>
+
+namespace root_presenter {
+
+using fuchsia::ui::focus::FocusChain;
+using fuchsia::ui::focus::FocusChainListener;
+using fuchsia::ui::focus::FocusChainListenerRegistry;
+using fuchsia::ui::keyboard::focus::Controller;
+using sys::ServiceDirectory;
+
+FocusDispatcher::FocusDispatcher(const std::shared_ptr<ServiceDirectory>& svc) {
+  // Connect to `fuchsia.ui.keyboard.focus.Controller`.
+  keyboard_focus_ctl_ = svc->Connect<Controller>();
+  keyboard_focus_ctl_.set_error_handler([](zx_status_t status) {
+    FX_LOGS(ERROR) << "Error from fuchsia.ui.keyboard.focus.Controller: "
+                   << zx_status_get_string(status);
+  });
+
+  // Connect to `fuchsia.ui.focus.FocusChainListenerRegistry`, then send it
+  // a client-side handle to `fuchsia.ui.focus.FocusChainListener`.
+  focus_chain_listener_registry_ = svc->Connect<FocusChainListenerRegistry>();
+  focus_chain_listener_registry_.set_error_handler([](zx_status_t status) {
+    FX_LOGS(ERROR) << "Error from fuchsia.ui.focus.FocusChainListenerRegistry: "
+                   << zx_status_get_string(status);
+  });
+  auto handle = focus_chain_listeners_.AddBinding(this);
+  focus_chain_listener_registry_->Register(handle.Bind());
+}
+
+void FocusDispatcher::OnFocusChange(FocusChain new_focus_chain,
+                                  FocusChainListener::OnFocusChangeCallback callback) {
+  if (new_focus_chain.has_focus_chain()) {
+    auto& focus_chain = new_focus_chain.focus_chain();
+    if (focus_chain.empty()) {
+      FX_LOGS(ERROR) << "OnFocusChange: empty focus chain - should not happen";
+    } else {
+      auto& last_view_ref = focus_chain.back();
+
+      keyboard_focus_ctl_->Notify(fidl::Clone(last_view_ref), [] {
+        FX_LOGS(DEBUG) << "FocusDispatcher::OnFocusChange: notify succeeded.";
+      });
+    }
+  }
+  // Callback is invoked regardless of whether `Notify` succeeds, and
+  // asynchronouly with Controller.Notify above.
+  callback();
+}
+
+}  // namespace root_presenter
diff --git a/src/ui/bin/root_presenter/focus_dispatcher.h b/src/ui/bin/root_presenter/focus_dispatcher.h
new file mode 100644
index 0000000..ee113d2
--- /dev/null
+++ b/src/ui/bin/root_presenter/focus_dispatcher.h
@@ -0,0 +1,51 @@
+// Copyright 2021 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#ifndef SRC_UI_BIN_ROOT_PRESENTER_FOCUS_DISPATCHER_H_
+#define SRC_UI_BIN_ROOT_PRESENTER_FOCUS_DISPATCHER_H_
+
+#include <fuchsia/ui/focus/cpp/fidl.h>
+#include <fuchsia/ui/keyboard/focus/cpp/fidl.h>
+#include <lib/fidl/cpp/binding_set.h>
+#include <lib/sys/cpp/component_context.h>
+
+#include <memory>
+
+namespace root_presenter {
+
+// Forwards the focus change messages from fuchsia.ui.focus.FocusChainListener to
+// fuchsia.ui.keyboard.focus.Controller.
+//
+// When constructed via `FocusDispatcher::New()`, it registers itself as a handler
+// for `OnFocusChange` notifications from `fuchsia.ui.focus.FocusChainListener`.
+// When a focus change notification comes in, the information about the most precise
+// view reference is forwarded on to `fuchsia.ui.keyboard.focus.Controller.Notify`.
+class FocusDispatcher : public fuchsia::ui::focus::FocusChainListener {
+ public:
+  // Makes a new `FocusDispatcher`.
+  //
+  // svc is a directory into which to serve Controller.
+  explicit FocusDispatcher(const std::shared_ptr<sys::ServiceDirectory>& svc);
+
+  // Implements `fuchsia.ui.focus.FocusChainListener`.
+  //
+  // When an `OnFocusChange` message arrives, it is sent to `keyboard.focus.Controller.Notify`.
+  void OnFocusChange(
+      fuchsia::ui::focus::FocusChain new_focus_chain,
+      fuchsia::ui::focus::FocusChainListener::OnFocusChangeCallback callback) override;
+
+ private:
+  // A client-side connection to Controller.
+  fidl::InterfacePtr<fuchsia::ui::keyboard::focus::Controller> keyboard_focus_ctl_;
+
+  // A client-side connection to FocusChainListenerRegistry.
+  fidl::InterfacePtr<fuchsia::ui::focus::FocusChainListenerRegistry> focus_chain_listener_registry_;
+
+  // A server-side binding to FocusChainListener.
+  fidl::BindingSet<fuchsia::ui::focus::FocusChainListener> focus_chain_listeners_;
+};
+
+}  // namespace root_presenter
+
+#endif  // SRC_UI_BIN_ROOT_PRESENTER_FOCUS_DISPATCHER_H_
diff --git a/src/ui/bin/root_presenter/meta/root_presenter.cml b/src/ui/bin/root_presenter/meta/root_presenter.cml
index ed85162..59525ee 100644
--- a/src/ui/bin/root_presenter/meta/root_presenter.cml
+++ b/src/ui/bin/root_presenter/meta/root_presenter.cml
@@ -36,6 +36,8 @@
         {
             protocol: [
                 "fuchsia.tracing.provider.Registry",
+                "fuchsia.ui.focus.FocusChainListenerRegistry",
+                "fuchsia.ui.keyboard.focus.Controller",
                 "fuchsia.ui.pointerinjector.Registry",
                 "fuchsia.ui.scenic.Scenic",
 
diff --git a/src/ui/bin/root_presenter/meta/root_presenter_base.cmx b/src/ui/bin/root_presenter/meta/root_presenter_base.cmx
index e70b600..7bb1bf0 100644
--- a/src/ui/bin/root_presenter/meta/root_presenter_base.cmx
+++ b/src/ui/bin/root_presenter/meta/root_presenter_base.cmx
@@ -16,6 +16,8 @@
             "fuchsia.media.sounds.Player",
             "fuchsia.recovery.FactoryReset",
             "fuchsia.tracing.provider.Registry",
+            "fuchsia.ui.focus.FocusChainListenerRegistry",
+            "fuchsia.ui.keyboard.focus.Controller",
             "fuchsia.ui.pointerinjector.Registry",
             "fuchsia.ui.policy.accessibility.PointerEventRegistry",
             "fuchsia.ui.scenic.Scenic"
diff --git a/src/ui/bin/root_presenter/tests/BUILD.gn b/src/ui/bin/root_presenter/tests/BUILD.gn
index ac03080..d893f8e 100644
--- a/src/ui/bin/root_presenter/tests/BUILD.gn
+++ b/src/ui/bin/root_presenter/tests/BUILD.gn
@@ -7,7 +7,16 @@
 
 group("tests") {
   testonly = true
-  public_deps = [ ":root_presenter_tests" ]
+  public_deps = [
+    ":focus_dispatcher_unittests",
+    ":root_presenter_tests",
+  ]
+}
+
+fuchsia_component("focus_dispatcher_unittests") {
+  testonly = true
+  deps = [ ":focus_dispatcher_unittests_bin" ]
+  manifest = "meta/focus_dispatcher_unittests.cmx"
 }
 
 fuchsia_component("root_presenter_apptests") {
@@ -25,6 +34,7 @@
   test_components = [
     ":root_presenter_apptests",
     ":root_presenter_unittests",
+    ":focus_dispatcher_unittests",
   ]
   test_specs = {
     log_settings = {
@@ -100,3 +110,24 @@
     "//src/ui/bin/root_presenter/tests/fakes",
   ]
 }
+
+executable("focus_dispatcher_unittests_bin") {
+  testonly = true
+  output_name = "focus_dispatcher_unittests"
+
+  sources = [ "focus_dispatcher_unittest.cc" ]
+
+  deps = [
+    "//garnet/public/lib/gtest",
+    "//sdk/fidl/fuchsia.ui.input.accessibility",
+    "//sdk/fidl/fuchsia.ui.policy.accessibility",
+    "//sdk/lib/fidl/cpp",
+    "//sdk/lib/sys/cpp",
+    "//sdk/lib/sys/cpp/testing:integration",
+    "//sdk/lib/sys/cpp/testing:unit",
+    "//sdk/lib/syslog/cpp",
+    "//src/lib/fxl/test:gtest_main",
+    "//src/ui/bin/root_presenter:lib",
+    "//src/ui/bin/root_presenter/tests/fakes",
+  ]
+}
diff --git a/src/ui/bin/root_presenter/tests/accessibility_focuser_registry_test.cc b/src/ui/bin/root_presenter/tests/accessibility_focuser_registry_test.cc
index b04666d..83a13f2 100644
--- a/src/ui/bin/root_presenter/tests/accessibility_focuser_registry_test.cc
+++ b/src/ui/bin/root_presenter/tests/accessibility_focuser_registry_test.cc
@@ -10,6 +10,7 @@
 #include <lib/ui/scenic/cpp/view_token_pair.h>
 
 #include "src/ui/bin/root_presenter/app.h"
+#include "src/ui/bin/root_presenter/tests/fakes/fake_keyboard_focus_controller.h"
 #include "src/ui/bin/root_presenter/tests/fakes/fake_scenic.h"
 
 namespace root_presenter {
@@ -36,6 +37,8 @@
     ASSERT_EQ(ZX_OK, status);
 
     services->AddService(fake_scenic_.GetHandler(), fuchsia::ui::scenic::Scenic::Name_);
+    services->AddService(fake_keyboard_focus_controller_.GetHandler(),
+                         fuchsia::ui::keyboard::focus::Controller::Name_);
 
     // Create the synthetic environment.
     environment_ =
@@ -57,6 +60,7 @@
 
   FakeScenic fake_scenic_;
   std::unique_ptr<sys::testing::EnclosingEnvironment> environment_;
+  FakeKeyboardFocusController fake_keyboard_focus_controller_;
 };
 
 TEST_F(AccessibilityFocuserRegistryTest, AccessibilityFocusRequestIsDeferredUntilScenicConnects) {
diff --git a/src/ui/bin/root_presenter/tests/accessibility_pointer_event_registry_test.cc b/src/ui/bin/root_presenter/tests/accessibility_pointer_event_registry_test.cc
index 508b473..1846a41 100644
--- a/src/ui/bin/root_presenter/tests/accessibility_pointer_event_registry_test.cc
+++ b/src/ui/bin/root_presenter/tests/accessibility_pointer_event_registry_test.cc
@@ -9,6 +9,7 @@
 #include <lib/ui/scenic/cpp/view_token_pair.h>
 
 #include "src/ui/bin/root_presenter/app.h"
+#include "src/ui/bin/root_presenter/tests/fakes/fake_keyboard_focus_controller.h"
 #include "src/ui/bin/root_presenter/tests/fakes/fake_scenic.h"
 
 namespace root_presenter {
@@ -98,6 +99,8 @@
                          fuchsia::ui::policy::accessibility::PointerEventRegistry::Name_);
 
     services->AddService(fake_scenic_.GetHandler(), fuchsia::ui::scenic::Scenic::Name_);
+    services->AddService(fake_keyboard_focus_controller_.GetHandler(),
+                         fuchsia::ui::keyboard::focus::Controller::Name_);
 
     // Create the synthetic environment.
     environment_ =
@@ -121,6 +124,8 @@
 
   FakeScenic fake_scenic_;
   std::unique_ptr<sys::testing::EnclosingEnvironment> environment_;
+
+  FakeKeyboardFocusController fake_keyboard_focus_controller_;
 };
 
 TEST_F(AccessibilityPointerEventRegistryTest, RegistersBeforeScenicIsReady) {
diff --git a/src/ui/bin/root_presenter/tests/color_transform_handler_unittest.cc b/src/ui/bin/root_presenter/tests/color_transform_handler_unittest.cc
index 2887eb5..465e4bf 100644
--- a/src/ui/bin/root_presenter/tests/color_transform_handler_unittest.cc
+++ b/src/ui/bin/root_presenter/tests/color_transform_handler_unittest.cc
@@ -17,6 +17,7 @@
 #include "src/lib/testing/loop_fixture/test_loop_fixture.h"
 #include "src/ui/bin/root_presenter/safe_presenter.h"
 #include "src/ui/bin/root_presenter/tests/fakes/fake_color_transform_manager.h"
+#include "src/ui/bin/root_presenter/tests/fakes/fake_keyboard_focus_controller.h"
 #include "src/ui/bin/root_presenter/tests/fakes/fake_scenic.h"
 #include "src/ui/bin/root_presenter/tests/fakes/fake_session.h"
 
@@ -44,6 +45,7 @@
     context_provider_.service_directory_provider()->AddService(fake_scenic_.GetHandler());
     context_provider_.service_directory_provider()->AddService(
         fake_color_transform_manager_.GetHandler());
+    context_provider_.service_directory_provider()->AddService(fake_keyboard_ctl_.GetHandler());
   }
 
   void SetUp() final {
@@ -70,6 +72,7 @@
   FakeSession* fake_session_ = nullptr;  // Owned by fake_scenic_.
   FakeScenic fake_scenic_;
   FakeColorTransformManager fake_color_transform_manager_;
+  FakeKeyboardFocusController fake_keyboard_ctl_;
   std::unique_ptr<ColorTransformHandler> color_transform_handler_;
   std::unique_ptr<SafePresenter> safe_presenter_;
 };
diff --git a/src/ui/bin/root_presenter/tests/fakes/BUILD.gn b/src/ui/bin/root_presenter/tests/fakes/BUILD.gn
index b05cbde..0cf5d6e 100644
--- a/src/ui/bin/root_presenter/tests/fakes/BUILD.gn
+++ b/src/ui/bin/root_presenter/tests/fakes/BUILD.gn
@@ -10,6 +10,8 @@
     "fake_focuser.h",
     "fake_injector_registry.cc",
     "fake_injector_registry.h",
+    "fake_keyboard_focus_controller.cc",
+    "fake_keyboard_focus_controller.h",
     "fake_scenic.cc",
     "fake_scenic.h",
     "fake_session.cc",
@@ -19,6 +21,7 @@
   deps = [
     "//garnet/public/lib/gtest",
     "//sdk/fidl/fuchsia.accessibility",
+    "//sdk/fidl/fuchsia.ui.keyboard.focus",
     "//sdk/fidl/fuchsia.ui.pointerinjector",
     "//sdk/lib/sys/cpp/testing:unit",
     "//src/lib/fsl",
diff --git a/src/ui/bin/root_presenter/tests/fakes/fake_keyboard_focus_controller.cc b/src/ui/bin/root_presenter/tests/fakes/fake_keyboard_focus_controller.cc
new file mode 100644
index 0000000..77289e3
--- /dev/null
+++ b/src/ui/bin/root_presenter/tests/fakes/fake_keyboard_focus_controller.cc
@@ -0,0 +1,44 @@
+// Copyright 2021 The Fuchsia 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 "src/ui/bin/root_presenter/tests/fakes/fake_keyboard_focus_controller.h"
+
+#include <lib/sys/cpp/testing/component_context_provider.h>
+#include <lib/syslog/cpp/macros.h>
+
+namespace root_presenter {
+namespace testing {
+
+using fuchsia::ui::keyboard::focus::Controller;
+using fuchsia::ui::views::ViewRef;
+
+FakeKeyboardFocusController::FakeKeyboardFocusController() {}
+
+FakeKeyboardFocusController::FakeKeyboardFocusController(
+    sys::testing::ComponentContextProvider& context_provider) {
+  context_provider.service_directory_provider()->AddService<Controller>(bindings_.GetHandler(this));
+}
+
+fidl::InterfaceRequestHandler<Controller> FakeKeyboardFocusController::GetHandler(
+    async_dispatcher_t* dispatcher) {
+  return [this, dispatcher](fidl::InterfaceRequest<Controller> request) {
+    bindings_.AddBinding(this, std::move(request), dispatcher);
+  };
+}
+
+void FakeKeyboardFocusController::SetOnNotify(
+    std::function<void(const ViewRef& view_ref)> callback) {
+  on_notify_callback_ = callback;
+}
+
+void FakeKeyboardFocusController::Notify(ViewRef view_ref, NotifyCallback callback) {
+  num_calls_++;
+  if (on_notify_callback_) {
+    on_notify_callback_(view_ref);
+  }
+  callback();
+}
+
+}  // namespace testing
+}  // namespace root_presenter
diff --git a/src/ui/bin/root_presenter/tests/fakes/fake_keyboard_focus_controller.h b/src/ui/bin/root_presenter/tests/fakes/fake_keyboard_focus_controller.h
new file mode 100644
index 0000000..bc94182
--- /dev/null
+++ b/src/ui/bin/root_presenter/tests/fakes/fake_keyboard_focus_controller.h
@@ -0,0 +1,58 @@
+// Copyright 2021 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#ifndef SRC_UI_BIN_ROOT_PRESENTER_TESTS_FAKES_FAKE_KEYBOARD_FOCUS_CONTROLLER_H_
+#define SRC_UI_BIN_ROOT_PRESENTER_TESTS_FAKES_FAKE_KEYBOARD_FOCUS_CONTROLLER_H_
+
+#include <fuchsia/ui/keyboard/focus/cpp/fidl.h>
+#include <fuchsia/ui/keyboard/focus/cpp/fidl_test_base.h>
+#include <lib/fidl/cpp/binding_set.h>
+#include <lib/sys/cpp/testing/component_context_provider.h>
+
+namespace root_presenter::testing {
+
+// A fake server for "fuchsia.ui.keyboard.focus.Controller".
+//
+// It does very little: it can be bound as a server for this protocol, responds with a success on
+// each call to Notify (the only method), and keeps a count on how many times Notify has been
+// called.
+class FakeKeyboardFocusController
+    : public fuchsia::ui::keyboard::focus::testing::Controller_TestBase {
+ public:
+  FakeKeyboardFocusController();
+
+  // Creates a new fake keyboard focus controller.  The `context_provider` is used to
+  // connect to the FIDL endpoints needed.
+  explicit FakeKeyboardFocusController(sys::testing::ComponentContextProvider& context_provider);
+
+  ~FakeKeyboardFocusController() override = default;
+
+  // Call to get a working handler for this protocol.  It still needs to be exposed as a service.
+  fidl::InterfaceRequestHandler<fuchsia::ui::keyboard::focus::Controller> GetHandler(
+      async_dispatcher_t* dispatcher = nullptr);
+
+  // Sets a callback to be invoked when a `Notify` call is received.  The callback will be passed
+  // the value of the ViewRef that was forwarded.
+  void SetOnNotify(std::function<void(const fuchsia::ui::views::ViewRef&)> on_notify_callback);
+
+  void NotImplemented_(const std::string& name) final {}
+
+  // Implements `fuchsia.ui.keyboard.focus.Controller.Notify`.
+  void Notify(fuchsia::ui::views::ViewRef view_ref, NotifyCallback callback) override;
+
+  // Returns the number of calls issued to this fake.
+  int num_calls() const { return num_calls_; }
+
+  // Returns the kernel object ID (koid) of the last view ref that was received.
+  zx_koid_t get_last_view_ref_koid();
+
+ private:
+  fidl::BindingSet<fuchsia::ui::keyboard::focus::Controller> bindings_;
+  int num_calls_{};
+  std::function<void(const fuchsia::ui::views::ViewRef& received_view_ref)> on_notify_callback_;
+};
+
+}  // namespace root_presenter::testing
+
+#endif  // SRC_UI_BIN_ROOT_PRESENTER_TESTS_FAKES_FAKE_KEYBOARD_FOCUS_CONTROLLER_H_
diff --git a/src/ui/bin/root_presenter/tests/focus_dispatcher_unittest.cc b/src/ui/bin/root_presenter/tests/focus_dispatcher_unittest.cc
new file mode 100644
index 0000000..7a014e9
--- /dev/null
+++ b/src/ui/bin/root_presenter/tests/focus_dispatcher_unittest.cc
@@ -0,0 +1,143 @@
+// Copyright 2021 The Fuchsia 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 "src/ui/bin/root_presenter/focus_dispatcher.h"
+
+#include <fuchsia/ui/focus/cpp/fidl.h>
+#include <fuchsia/ui/input/cpp/fidl.h>
+#include <lib/async/dispatcher.h>
+#include <lib/gtest/real_loop_fixture.h>
+#include <lib/sys/cpp/testing/component_context_provider.h>
+#include <lib/ui/scenic/cpp/commands.h>
+#include <lib/ui/scenic/cpp/view_ref_pair.h>
+#include <lib/ui/scenic/cpp/view_token_pair.h>
+#include <zircon/status.h>
+
+#include <vector>
+
+#include <gtest/gtest.h>
+#include <src/lib/fxl/macros.h>
+#include <src/lib/testing/loop_fixture/test_loop_fixture.h>
+
+#include "src/ui/bin/root_presenter/tests/fakes/fake_keyboard_focus_controller.h"
+
+namespace root_presenter {
+
+using fuchsia::ui::focus::FocusChain;
+using fuchsia::ui::focus::FocusChainListener;
+using fuchsia::ui::focus::FocusChainListenerRegistry;
+using fuchsia::ui::keyboard::focus::Controller;
+using fuchsia::ui::views::ViewRef;
+
+class FocusDispatcherTest : public gtest::RealLoopFixture, public FocusChainListenerRegistry {
+ public:
+  void SetUp() final {
+    // Installs 'this' as a fake server for FocusChainListenerRegistry.
+    context_provider_.service_directory_provider()->AddService(
+        focus_listener_registry_.GetHandler(this));
+
+    // Installs a fake receiver for keyboard focus events, and asks it to flip
+    // a flag if a notification comes in.
+    fake_keyboard_focus_controller_ =
+        std::make_unique<testing::FakeKeyboardFocusController>(context_provider_);
+    fake_keyboard_focus_controller_->SetOnNotify(
+        [&](const ViewRef& view_ref) { keyboard_notification_received_ = true; });
+
+    controller_handler_ = fake_keyboard_focus_controller_->GetHandler();
+
+    // Finally, initializes the unit under test.
+    focus_dispatch_ = std::make_unique<FocusDispatcher>(context_provider_.context()->svc());
+  }
+
+  // Implements `fuchsia.ui.focus.FocusChainListenerRegistry`, but only for a single
+  // listener registration.
+  void Register(fidl::InterfaceHandle<FocusChainListener> listener) override {
+    ASSERT_EQ(ZX_OK, focus_chain_listener_.Bind(std::move(listener)));
+    focus_chain_listener_.set_error_handler([](zx_status_t status) {
+      FAIL() << "error while talking to focus chain listener: " << zx_status_get_string(status);
+    });
+    register_calls_++;
+  }
+
+  void SendFocusChain(FocusChain focus_chain) {
+    focus_chain_listener_->OnFocusChange(std::move(focus_chain), [&] { focus_dispatched_++; });
+  }
+
+  void ChangeFocus(std::vector<ViewRef> view_refs) {
+    FocusChain focus_chain;
+    focus_chain.set_focus_chain(std::move(view_refs));
+    SendFocusChain(std::move(focus_chain));
+  }
+
+  void SendEmptyFocus() {
+    FocusChain focus_chain;
+    SendFocusChain(std::move(focus_chain));
+  }
+
+  ViewRef MakeViewRef() {
+    auto view_ref_pair = scenic::ViewRefPair::New();
+    return fidl::Clone(view_ref_pair.view_ref);
+  }
+
+ protected:
+  sys::testing::ComponentContextProvider context_provider_;
+
+  fidl::BindingSet<fuchsia::ui::focus::FocusChainListenerRegistry> focus_listener_registry_;
+
+  std::unique_ptr<testing::FakeKeyboardFocusController> fake_keyboard_focus_controller_;
+
+  // The client-end connection to a test FocusChainListener.
+  fidl::InterfacePtr<FocusChainListener> focus_chain_listener_;
+
+  fidl::InterfaceRequestHandler<Controller> controller_handler_;
+
+  // Class under test.
+  std::unique_ptr<FocusDispatcher> focus_dispatch_;
+
+  bool keyboard_notification_received_{};
+  int focus_dispatched_{};
+  int register_calls_{};
+};
+
+TEST_F(FocusDispatcherTest, Forward) {
+  // Give the opportunity for Register(...) to get called.
+  RunLoopUntilIdle();
+  ASSERT_NE(0, register_calls_) << "FocusDispatcher should call Register";
+
+  std::vector<ViewRef> v;
+  v.emplace_back(MakeViewRef());
+  ChangeFocus(std::move(v));
+
+  RunLoopUntilIdle();
+  EXPECT_NE(0, focus_dispatched_) << "ChangeFocus should have dispatched OnFocusChange";
+  EXPECT_TRUE(keyboard_notification_received_);
+}
+
+TEST_F(FocusDispatcherTest, EmptyFocusChain) {
+  RunLoopUntilIdle();
+  ASSERT_NE(0, register_calls_) << "FocusDispatcher should call Register";
+
+  ChangeFocus({});
+
+  RunLoopUntilIdle();
+  EXPECT_NE(0, focus_dispatched_) << "ChangeFocus should have dispatched OnFocusChange";
+
+  // Nothing is called with an empty focus chain.
+  EXPECT_FALSE(keyboard_notification_received_);
+}
+
+TEST_F(FocusDispatcherTest, UnsetFocusChain) {
+  RunLoopUntilIdle();
+  ASSERT_NE(0, register_calls_) << "FocusDispatcher should call Register";
+
+  SendEmptyFocus();
+
+  RunLoopUntilIdle();
+  EXPECT_NE(0, focus_dispatched_) << "ChangeFocus should have dispatched OnFocusChange";
+
+  // Nothing is called with an empty focus chain.
+  EXPECT_FALSE(keyboard_notification_received_);
+}
+
+}  // namespace root_presenter
diff --git a/src/ui/bin/root_presenter/tests/meta/focus_dispatcher_unittests.cmx b/src/ui/bin/root_presenter/tests/meta/focus_dispatcher_unittests.cmx
new file mode 100644
index 0000000..aa7a48e
--- /dev/null
+++ b/src/ui/bin/root_presenter/tests/meta/focus_dispatcher_unittests.cmx
@@ -0,0 +1,15 @@
+{
+    "include": [
+        "sdk/lib/diagnostics/syslog/client.shard.cmx"
+    ],
+    "program": {
+        "binary": "bin/focus_dispatcher_unittests"
+    },
+    "sandbox": {
+        "services": [
+            "fuchsia.recovery.FactoryReset",
+            "fuchsia.sys.Environment",
+            "fuchsia.sys.Loader"
+        ]
+    }
+}
diff --git a/src/ui/bin/root_presenter/tests/meta/root_presenter_unittests.cmx b/src/ui/bin/root_presenter/tests/meta/root_presenter_unittests.cmx
index 40f54f6..80e77f1 100644
--- a/src/ui/bin/root_presenter/tests/meta/root_presenter_unittests.cmx
+++ b/src/ui/bin/root_presenter/tests/meta/root_presenter_unittests.cmx
@@ -5,6 +5,7 @@
                 "fuchsia.hardware.display.Provider": "fuchsia-pkg://fuchsia.com/fake-hardware-display-controller-provider#meta/hdcp.cmx",
                 "fuchsia.tracing.provider.Registry": "fuchsia-pkg://fuchsia.com/trace_manager#meta/trace_manager.cmx",
                 "fuchsia.ui.focus.FocusChainListenerRegistry": "fuchsia-pkg://fuchsia.com/scenic#meta/scenic.cmx",
+                "fuchsia.ui.keyboard.focus.Controller": "fuchsia-pkg://fuchsia.com/ime_service#meta/ime_service.cmx",
                 "fuchsia.ui.scenic.Scenic": "fuchsia-pkg://fuchsia.com/scenic#meta/scenic.cmx"
             }
         }
@@ -28,6 +29,7 @@
             "fuchsia.sys.Environment",
             "fuchsia.sys.Launcher",
             "fuchsia.ui.focus.FocusChainListenerRegistry",
+            "fuchsia.ui.keyboard.focus.Controller",
             "fuchsia.ui.scenic.Scenic"
         ]
     }
diff --git a/src/ui/bin/root_presenter/tests/root_presenter_unittest.cc b/src/ui/bin/root_presenter/tests/root_presenter_unittest.cc
index 9d09d05..ff9b30f 100644
--- a/src/ui/bin/root_presenter/tests/root_presenter_unittest.cc
+++ b/src/ui/bin/root_presenter/tests/root_presenter_unittest.cc
@@ -22,6 +22,7 @@
 #include "src/ui/bin/root_presenter/app.h"
 #include "src/ui/bin/root_presenter/presentation.h"
 #include "src/ui/bin/root_presenter/tests/fakes/fake_injector_registry.h"
+#include "src/ui/bin/root_presenter/tests/fakes/fake_keyboard_focus_controller.h"
 
 namespace root_presenter {
 namespace {
@@ -45,16 +46,30 @@
     real_component_context_ = sys::ComponentContext::CreateAndServeOutgoingDirectory();
 
     // Proxy real APIs through the fake component_context.
-    context_provider_.service_directory_provider()->AddService<fuchsia::ui::scenic::Scenic>(
-        [this](fidl::InterfaceRequest<fuchsia::ui::scenic::Scenic> request) {
-          real_component_context_->svc()->Connect(std::move(request));
-        });
+    // TODO(fxbug.dev/74262): The test should set up a test environment instead of
+    // injecting a real scenic in the sandbox.
+    ASSERT_EQ(
+        ZX_OK,
+        context_provider_.service_directory_provider()->AddService<fuchsia::ui::scenic::Scenic>(
+            [this](fidl::InterfaceRequest<fuchsia::ui::scenic::Scenic> request) {
+              real_component_context_->svc()->Connect(std::move(request));
+            }));
+    // Connect FocusChainListenerRegistry to the real Scenic injected in the test sandbox.
+    ASSERT_EQ(ZX_OK,
+              context_provider_.service_directory_provider()
+                  ->AddService<fuchsia::ui::focus::FocusChainListenerRegistry>(
+                      [this](fidl::InterfaceRequest<fuchsia::ui::focus::FocusChainListenerRegistry>
+                                 request) {
+                        real_component_context_->svc()->Connect(std::move(request));
+                      }));
 
     injector_registry_ = std::make_unique<testing::FakeInjectorRegistry>(context_provider_);
+    keyboard_focus_ctl_ = std::make_unique<testing::FakeKeyboardFocusController>(context_provider_);
 
     // Start RootPresenter with fake context.
     root_presenter_ = std::make_unique<App>(context_provider_.context(), dispatcher());
   }
+
   void TearDown() final { root_presenter_.reset(); }
 
   App* root_presenter() { return root_presenter_.get(); }
@@ -151,6 +166,7 @@
   }
 
   std::unique_ptr<testing::FakeInjectorRegistry> injector_registry_;
+  std::unique_ptr<testing::FakeKeyboardFocusController> keyboard_focus_ctl_;
   fuchsia::ui::input::InputDeviceRegistryPtr input_device_registry_ptr_;
   sys::testing::ComponentContextProvider context_provider_;
 
@@ -561,11 +577,21 @@
   auto [view_token, view_holder_token] = scenic::ViewTokenPair::New();
   auto [control_ref, view_ref] = scenic::ViewRefPair::New();
   const zx_koid_t child_view_koid = ExtractKoid(view_ref);
+
   fuchsia::ui::views::ViewRef clone;
   fidl::Clone(view_ref, &clone);
   root_presenter()->PresentOrReplaceView2(std::move(view_holder_token), std::move(clone), nullptr);
   RunLoopUntil([this]() { return root_presenter()->is_presentation_initialized(); });
 
+  zx_koid_t keyboard_focus_view_koid = ZX_KOID_INVALID;
+  bool keyboard_received_focus = false;
+  // Callback to verify that a focus change triggered a notification.
+  keyboard_focus_ctl_->SetOnNotify([&keyboard_focus_view_koid, &keyboard_received_focus](
+                                       const fuchsia::ui::views::ViewRef& view_ref) {
+    keyboard_focus_view_koid = ExtractKoid(view_ref);
+    keyboard_received_focus = true;
+  });
+
   // Connect to focus chain registry after Scenic has been set up.
   zx_koid_t focused_view_koid = ZX_KOID_INVALID;
   SetUpFocusChainListener([&focused_view_koid](fuchsia::ui::focus::FocusChain focus_chain) {
@@ -583,9 +609,15 @@
   session.Present(0, [](auto) {});
 
   // Expect focus to change to the child view.
-  RunLoopUntil(
-      [&focused_view_koid, child_view_koid]() { return focused_view_koid == child_view_koid; });
+  RunLoopUntil([&focused_view_koid, &keyboard_received_focus, child_view_koid]() {
+    return focused_view_koid == child_view_koid && keyboard_received_focus == true;
+  });
   EXPECT_EQ(focused_view_koid, child_view_koid);
+
+  // Verifies that the keyboard focus listener got the appropriate view ref when
+  // the focus was updated.
+  EXPECT_EQ(focused_view_koid, keyboard_focus_view_koid);
+  EXPECT_NE(ZX_KOID_INVALID, keyboard_focus_view_koid);
 }
 
 // Tests that we can handle both an automatic focus request on startup and a simultaneous one