[ermine] Add support to request focus on child views

This change allows requesting focus on child when:
- upon launch, you don't have click to set keyboard focus.
  Keyboard input should immediately route to the launched view.
- when clicked on the view's thumbnail in Overview
- changing focus between views by clicking their title bars.

Some cases still don't work:
- clicking on any surface other than a child view causes the
  view to lose focus, even when it's focus ux is still active
- invoking ask bar moves the focus from the view, even when
  it's focus ux is still active
These cases will be addressed in subsequent cls.

Bug: 60533, 60530
Test: Add new test for request focus functionality to ErmineStory

Change-Id: I82f70c400f4ca4977d03cd354876bd8fee8a2edc
Reviewed-on: https://fuchsia-review.googlesource.com/c/experiences/+/428355
Commit-Queue: Sanjay Chouksey <sanjayc@google.com>
Reviewed-by: Chase Latta <chaselatta@google.com>
Testability-Review: Chase Latta <chaselatta@google.com>
diff --git a/session_shells/ermine/session/meta/workstation_session.cml b/session_shells/ermine/session/meta/workstation_session.cml
index fa178d5..f99571f 100644
--- a/session_shells/ermine/session/meta/workstation_session.cml
+++ b/session_shells/ermine/session/meta/workstation_session.cml
@@ -32,6 +32,7 @@
                 "/svc/fuchsia.ui.input3.Keyboard",
                 "/svc/fuchsia.ui.scenic.Scenic",
                 "/svc/fuchsia.ui.shortcut.Manager",
+                "/svc/fuchsia.ui.views.ViewRefInstalled",
             ],
         },
     ],
diff --git a/session_shells/ermine/shell/BUILD.gn b/session_shells/ermine/shell/BUILD.gn
index 852cff0..6bf98a2 100644
--- a/session_shells/ermine/shell/BUILD.gn
+++ b/session_shells/ermine/shell/BUILD.gn
@@ -70,6 +70,7 @@
     "src/widgets/story/cluster.dart",
     "src/widgets/story/clusters.dart",
     "src/widgets/story/fullscreen_story.dart",
+    "src/widgets/story/post_render.dart",
     "src/widgets/story/thumbnails.dart",
     "src/widgets/story/tile_chrome.dart",
     "src/widgets/story/tile_sizer.dart",
diff --git a/session_shells/ermine/shell/lib/src/models/ermine_story.dart b/session_shells/ermine/shell/lib/src/models/ermine_story.dart
index 3830c78..87de475 100644
--- a/session_shells/ermine/shell/lib/src/models/ermine_story.dart
+++ b/session_shells/ermine/shell/lib/src/models/ermine_story.dart
@@ -9,10 +9,13 @@
 import 'package:fuchsia_scenic_flutter/child_view_connection.dart';
 import 'package:fuchsia_services/services.dart';
 import 'package:uuid/uuid.dart';
+import 'package:zircon/zircon.dart';
 
 import '../utils/presenter.dart';
 import '../utils/suggestion.dart';
 
+const double kFrameTime60FpsInMilliseconds = 16.3333;
+
 /// A function which can be used to launch the suggestion.
 typedef LaunchSuggestion = Future<void> Function(
     Suggestion, ElementControllerProxy);
@@ -101,7 +104,17 @@
     _elementController?.ctrl?.close();
   }
 
-  void focus() => onChange?.call(this..focused = true);
+  /// Sets the focus state on this story.
+  ///
+  /// Also invokes [onChange] callback and request scenic to transfer input
+  /// focus to the associated [viewRef].
+  ///
+  /// Takes an optional [ViewRefInstalledProxy] to allow passing in a mocked
+  /// instance during test.
+  void focus([ViewRefInstalledProxy viewRefInstalled]) {
+    onChange?.call(this..focused = true);
+    requestFocus(viewRefInstalled);
+  }
 
   void maximize() => onChange?.call(this..fullscreen = true);
 
@@ -139,4 +152,35 @@
     viewController = vc;
     viewController?.didPresent();
   }
+
+  /// Requests focus to be transfered to this view given it's [viewRef].
+  Future<void> requestFocus([ViewRefInstalledProxy viewRefInstalled]) async {
+    // [requestFocus] is called for 'every' post render of ChildView widget,
+    // even when that child view is not focused. Skip focusing those views here.
+    if (childViewConnection == null || !focused) {
+      return;
+    }
+
+    // TODO(60528): Wait until child view is connected to view tree. Until the
+    // view connected event is plumbed to [ChildViewConnection], we use a flaky
+    // delay of 3 frames before attempting to request focus for the child view.
+    await Future.delayed(Duration(
+      milliseconds: (kFrameTime60FpsInMilliseconds * 3).toInt(),
+    ));
+
+    final viewRefService = viewRefInstalled ?? ViewRefInstalledProxy();
+    if (viewRefInstalled == null) {
+      StartupContext.fromStartupInfo()
+          .incoming
+          .connectToService(viewRefService);
+    }
+    try {
+      // Wait for [viewRef] to be attached to the view tree.
+      final eventPair = viewRef.reference.duplicate(ZX.RIGHT_SAME_RIGHTS);
+      assert(eventPair.isValid);
+
+      await viewRefService.watch(ViewRef(reference: eventPair));
+      childViewConnection.requestFocus();
+    } on Error catch (_) {}
+  }
 }
diff --git a/session_shells/ermine/shell/lib/src/utils/presenter.dart b/session_shells/ermine/shell/lib/src/utils/presenter.dart
index f388ad0..7daa2f2 100644
--- a/session_shells/ermine/shell/lib/src/utils/presenter.dart
+++ b/session_shells/ermine/shell/lib/src/utils/presenter.dart
@@ -61,8 +61,7 @@
     if (viewHolderToken != null) {
       final connection = ChildViewConnection(
         viewHolderToken,
-        onAvailable: (_) {},
-        onUnavailable: (_) {},
+        viewRef: viewSpec.viewRef,
       );
       onPresent(
         connection,
diff --git a/session_shells/ermine/shell/lib/src/widgets/story/cluster.dart b/session_shells/ermine/shell/lib/src/widgets/story/cluster.dart
index 97250d7..67e545f 100644
--- a/session_shells/ermine/shell/lib/src/widgets/story/cluster.dart
+++ b/session_shells/ermine/shell/lib/src/widgets/story/cluster.dart
@@ -8,6 +8,7 @@
 
 import '../../models/cluster_model.dart';
 import '../../models/ermine_story.dart';
+import 'post_render.dart';
 import 'tile_chrome.dart';
 import 'tile_sizer.dart';
 import 'tile_tab.dart';
@@ -66,7 +67,10 @@
                   showTitle: !custom,
                   focused: story.focused,
                   //TODO(47796) show a placeholder until the view loads
-                  child: ChildView(connection: story.childViewConnection),
+                  child: PostRender(
+                    child: ChildView(connection: story.childViewConnection),
+                    onRender: story.requestFocus,
+                  ),
                   onTap: story.focus,
                   onDelete: story.delete,
                   onFullscreen: story.maximize,
diff --git a/session_shells/ermine/shell/lib/src/widgets/story/fullscreen_story.dart b/session_shells/ermine/shell/lib/src/widgets/story/fullscreen_story.dart
index d5c4fce..3a18ac8 100644
--- a/session_shells/ermine/shell/lib/src/widgets/story/fullscreen_story.dart
+++ b/session_shells/ermine/shell/lib/src/widgets/story/fullscreen_story.dart
@@ -7,6 +7,7 @@
 
 import '../../models/app_model.dart';
 import '../../utils/styles.dart';
+import 'post_render.dart';
 import 'tile_chrome.dart';
 
 /// Defines a widget to display a story fullscreen.
@@ -34,7 +35,10 @@
                       name: story.name,
                       focused: story.focused,
                       fullscreen: true,
-                      child: ChildView(connection: story.childViewConnection),
+                      child: PostRender(
+                        child: ChildView(connection: story.childViewConnection),
+                        onRender: story.requestFocus,
+                      ),
                       onDelete: story.delete,
                       onMinimize: story.restore,
                       onFullscreen: story.maximize,
diff --git a/session_shells/ermine/shell/lib/src/widgets/story/post_render.dart b/session_shells/ermine/shell/lib/src/widgets/story/post_render.dart
new file mode 100644
index 0000000..440c501
--- /dev/null
+++ b/session_shells/ermine/shell/lib/src/widgets/story/post_render.dart
@@ -0,0 +1,22 @@
+// Copyright 2020 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.
+
+import 'package:flutter/material.dart';
+
+/// Defines a class to receive a callback when the supplied [child] widget
+/// is rendered.
+///
+/// The [onRender] callback is invoked on subsequent frame after every [build].
+class PostRender extends StatelessWidget {
+  final Widget child;
+  final VoidCallback onRender;
+
+  const PostRender({this.child, this.onRender});
+
+  @override
+  Widget build(BuildContext context) {
+    WidgetsBinding.instance.addPostFrameCallback((_) => onRender?.call());
+    return child;
+  }
+}
diff --git a/session_shells/ermine/shell/meta/ermine.cmx b/session_shells/ermine/shell/meta/ermine.cmx
index c1224c6..693152d 100644
--- a/session_shells/ermine/shell/meta/ermine.cmx
+++ b/session_shells/ermine/shell/meta/ermine.cmx
@@ -33,7 +33,8 @@
             "fuchsia.ui.policy.Presentation",
             "fuchsia.ui.policy.Presenter",
             "fuchsia.ui.scenic.Scenic",
-            "fuchsia.ui.shortcut.Registry"
+            "fuchsia.ui.shortcut.Registry",
+            "fuchsia.ui.views.ViewRefInstalled"
         ]
     }
 }
diff --git a/session_shells/ermine/shell/test/ermine_story_test.dart b/session_shells/ermine/shell/test/ermine_story_test.dart
index c1d7e92..1a0306f 100644
--- a/session_shells/ermine/shell/test/ermine_story_test.dart
+++ b/session_shells/ermine/shell/test/ermine_story_test.dart
@@ -8,8 +8,11 @@
 import 'package:ermine/src/models/ermine_story.dart';
 import 'package:ermine/src/utils/presenter.dart';
 import 'package:ermine/src/utils/suggestion.dart';
+import 'package:fidl_fuchsia_ui_views/fidl_async.dart';
+import 'package:fuchsia_scenic_flutter/child_view_connection.dart';
 import 'package:mockito/mockito.dart';
 import 'package:test/test.dart';
+import 'package:zircon/zircon.dart';
 
 void main() {
   test('from suggestion sets id and title', () {
@@ -50,6 +53,58 @@
     expect(didCallDelete, isTrue);
     verify(viewController.close()).called(1);
   });
+
+  test('Focus ErmineStory', () async {
+    final eventPair = MockEventPair();
+    final eventPairDup = MockEventPair();
+    final viewRef = MockViewRef();
+    final viewRefInstalled = MockViewRefInstalled();
+    final childViewConnection = MockChildViewConnection();
+
+    when(viewRef.reference).thenReturn(eventPair);
+    when(eventPair.duplicate(ZX.RIGHT_SAME_RIGHTS)).thenReturn(eventPairDup);
+    when(eventPairDup.isValid).thenReturn(true);
+
+    bool onChangeCalled = false;
+    final completer = Completer<bool>();
+    TestErmineStory(
+      onChange: (_) => onChangeCalled = true,
+      requestFocusCompleter: completer,
+    )
+      ..viewRef = viewRef
+      ..childViewConnectionNotifier.value = childViewConnection
+      ..focus(viewRefInstalled);
+
+    expect(onChangeCalled, true);
+    await completer.future;
+
+    verify(viewRefInstalled.watch(ViewRef(reference: eventPairDup))).called(1);
+    verify(childViewConnection.requestFocus()).called(1);
+  });
+}
+
+class TestErmineStory extends ErmineStory {
+  final Completer<bool> requestFocusCompleter;
+
+  TestErmineStory({
+    void Function(ErmineStory) onChange,
+    this.requestFocusCompleter,
+  }) : super(id: 'id', onChange: onChange);
+
+  @override
+  Future<void> requestFocus([ViewRefInstalledProxy viewRefInstalled]) async {
+    final result = await super.requestFocus(viewRefInstalled);
+    requestFocusCompleter.complete(true);
+    return result;
+  }
 }
 
 class MockViewControllerImpl extends Mock implements ViewControllerImpl {}
+
+class MockChildViewConnection extends Mock implements ChildViewConnection {}
+
+class MockViewRefInstalled extends Mock implements ViewRefInstalledProxy {}
+
+class MockViewRef extends Mock implements ViewRef {}
+
+class MockEventPair extends Mock implements EventPair {}