[workstation_session] start using graphical presenter
Uses the graphical presenter to launch elements in ermine.
TEST= Added many unit tests. Manual testing can be done by
launching an element and then closing it to see that it
goes away.
Change-Id: I713896ddefbf7ae4fd682f39b63b63f9b5974852
diff --git a/session_shells/ermine/session/BUILD.gn b/session_shells/ermine/session/BUILD.gn
index 926c723..3211b80 100644
--- a/session_shells/ermine/session/BUILD.gn
+++ b/session_shells/ermine/session/BUILD.gn
@@ -44,6 +44,9 @@
"//third_party/rust_crates:async-trait",
"//third_party/rust_crates:futures",
"//third_party/rust_crates:rand",
+
+ # TODO: use the shared library when fxb/370681 lands
+ "//src/connectivity/bluetooth/lib/async-helpers",
]
test_deps = []
diff --git a/session_shells/ermine/session/src/element_repository/event_handler.rs b/session_shells/ermine/session/src/element_repository/event_handler.rs
index 8cd2b70..a6220fb 100644
--- a/session_shells/ermine/session/src/element_repository/event_handler.rs
+++ b/session_shells/ermine/session/src/element_repository/event_handler.rs
@@ -3,16 +3,25 @@
// found in the LICENSE file.
use {
+ anyhow::Error,
element_management::Element,
+ fidl::encoding::Decodable,
+ fidl::endpoints::create_proxy,
fidl_fuchsia_session::{
- AnnotationError, ElementControllerRequest, ElementControllerRequestStream,
+ AnnotationError, Annotations, ElementControllerRequest, ElementControllerRequestStream,
+ GraphicalPresenterProxy, ViewControllerMarker, ViewControllerProxy, ViewSpec,
},
- fuchsia_async as fasync,
- futures::TryStreamExt,
+ fidl_fuchsia_ui_app::ViewProviderMarker,
+ fuchsia_async as fasync, fuchsia_scenic as scenic,
+ fuchsia_syslog::fx_log_info,
+ fuchsia_zircon as zx,
+ fuchsia_zircon::AsHandleRef,
+ futures::{select, stream::StreamExt, FutureExt, TryStreamExt},
+ std::{cell::RefCell, rc::Rc},
};
/// A trait which is used by the ElementRepository to respond to incoming events.
-pub(crate) trait EventHandler {
+pub trait EventHandler {
/// Called when a new element is added to the repository.
fn add_element(&mut self, element: Element, stream: Option<ElementControllerRequestStream>);
@@ -21,75 +30,196 @@
fn shutdown(&mut self) {}
}
-pub(crate) struct ElementEventHandler {
- elements: Vec<ElementHolder>,
+pub struct ElementEventHandler {
+ proxy: GraphicalPresenterProxy,
+ termination_event: async_helpers::event::Event,
}
/// The ElementEventHandler is a concrete implementation of the EventHandler trait which
/// manages the lifecycle of elements.
impl ElementEventHandler {
- pub fn new() -> ElementEventHandler {
- ElementEventHandler { elements: vec![] }
+ /// Creates a new instance of the ElementEventHandler.
+ pub fn new(proxy: GraphicalPresenterProxy) -> ElementEventHandler {
+ ElementEventHandler { proxy, termination_event: async_helpers::event::Event::new() }
+ }
+
+ /// Attempts to connect to the element's view provider and if it does
+ /// expose the view provider will tell the proxy to present the view.
+ fn present_view_for_element(
+ &self,
+ element: &mut Element,
+ ) -> Result<ViewControllerProxy, Error> {
+ let view_provider = element.connect_to_service::<ViewProviderMarker>()?;
+ let token_pair = scenic::ViewTokenPair::new()?;
+
+ // note: this call will never fail since connecting to a service is
+ // always successful and create_view doesn't have a return value.
+ // If there is no view provider, the view_holder_token will be invalidated
+ // and the presenter can choose to close the view controller if it
+ // only wants to allow graphical views.
+ view_provider.create_view(token_pair.view_token.value, None, None)?;
+
+ let annotations = element.get_annotations()?;
+
+ let view_spec = ViewSpec {
+ view_holder_token: Some(token_pair.view_holder_token),
+ annotations: Some(annotations),
+ };
+
+ let (view_controller_proxy, server_end) = create_proxy::<ViewControllerMarker>()?;
+ self.proxy.present_view(view_spec, Some(server_end))?;
+
+ Ok(view_controller_proxy)
}
}
impl EventHandler for ElementEventHandler {
- fn add_element(&mut self, element: Element, stream: Option<ElementControllerRequestStream>) {
- self.elements.push(ElementHolder::new(element, stream));
- }
-
- // We do not implement the shutdown method because we do not have any cleanup to do at this time.
-}
-
-struct ElementHolder {
- _element: Option<Element>,
-}
-
-/// An ElementHolder helps to manage the lifetime of an Element.
-///
-/// If an element has an associated ElementController it will serve the ElementController protocol.
-/// If not, it will just hold the elemen to keep it alive.
-impl ElementHolder {
- fn new(element: Element, stream: Option<ElementControllerRequestStream>) -> ElementHolder {
- //TODO(47078): Watch the element and respond when it closes.
- if let Some(stream) = stream {
- ElementHolder::spawn_request_stream_server(element, stream);
- return ElementHolder::empty();
- }
- ElementHolder { _element: Some(element) }
- }
-
- fn spawn_request_stream_server(
+ fn add_element(
+ &mut self,
mut element: Element,
- mut stream: ElementControllerRequestStream,
+ stream: Option<ElementControllerRequestStream>,
) {
- //TODO(47078): need to shut down the request stream when the component the element represents dies.
+ // Attempt to present the view. For now, if this fails we log the error and continue, In the
+ // future we will want to communicate the failure back to the proposer.
+ let view_controller_proxy = self.present_view_for_element(&mut element).ok();
+
+ let termination_event = self.termination_event.clone();
+ // Hold the element in the spawn_local here. when the call closes all
+ // of the proxies will be closed.
fasync::spawn_local(async move {
- while let Ok(Some(request)) = stream.try_next().await {
- match request {
- ElementControllerRequest::SetAnnotations { annotations, responder } => {
- let _ = responder.send(
- &mut element
- .set_annotations(annotations)
- .map_err(|_: anyhow::Error| AnnotationError::Rejected),
- );
- }
- ElementControllerRequest::GetAnnotations { responder } => {
- let _ = responder.send(
- &mut element
- .get_annotations()
- .map_err(|_: anyhow::Error| AnnotationError::NotFound),
- );
- }
- }
- }
+ let element_close_fut =
+ Box::pin(run_until_closed(element, stream, view_controller_proxy));
+ futures::future::select(element_close_fut, termination_event.wait()).await;
});
}
- // An empty ElementHolder is returned to signal that we are managing the lifetime of this
- // element but we are doing so via request stream loop.
- fn empty() -> ElementHolder {
- ElementHolder { _element: None }
+ fn shutdown(&mut self) {
+ self.termination_event.signal();
+ }
+}
+
+/// Runs the Element until it receives a signal to shutdown.
+///
+/// The Element can receive a signal to shut down from any of the
+/// following:
+/// - Element. The component represented by the element can close on its own.
+/// - ElementControllerRequestStream. The element controller can signal that the element should close.
+/// - ViewControllerProxy. The view controller can signal that the element can close.
+///
+/// The Element will shutdown when any of these signals are received.
+///
+/// The Element will also listen for any incoming events from the element controller and
+/// forward them to the view controller.
+async fn run_until_closed(
+ element: Element,
+ stream: Option<ElementControllerRequestStream>,
+ view_controller_proxy: Option<ViewControllerProxy>,
+) {
+ let element = Rc::new(RefCell::new(element));
+
+ select!(
+ _ = await_element_close(element.clone()).fuse() => {
+ // signals that a element has died without being told to close.
+ // We could tell the view to dismiss here but we need to signal
+ // that there was a crash. The current contract is that if the
+ // view controller binding closes without a dismiss then the
+ // presenter should treat this as a crash and respond accordingly.
+ fx_log_info!("An element closed unexpectedly: {:?}", element);
+ },
+ _ = wait_for_optional_view_controller_close(view_controller_proxy.clone()).fuse() => {
+ // signals that the presenter would like to close the element.
+ // We do not need to do anything here but exit which will cause
+ // the element to be dropped and will kill the component.
+ },
+ _ = spawn_element_controller_stream(element.clone(), stream, view_controller_proxy.clone()).fuse() => {
+ // the proposer has decided they want to shut down the element.
+ if let Some(proxy) = view_controller_proxy {
+ // We want to allow the presenter the ability to dismiss
+ // the view so we tell it to dismiss and then wait for
+ // the view controller stream to close.
+ let _ = proxy.dismiss();
+ //TODO(47925) introdue a timeout here
+ wait_for_view_controller_close(proxy).await;
+ }
+ }
+ );
+}
+
+/// Waits for the element to signal that it closed
+async fn await_element_close(element: Rc<RefCell<Element>>) {
+ let element = element.borrow();
+ let channel = element.directory_channel();
+ let _ =
+ fasync::OnSignals::new(&channel.as_handle_ref(), zx::Signals::CHANNEL_PEER_CLOSED).await;
+}
+
+/// Waits for the view controller to signal that it wants to close.
+///
+/// if the ViewControllerProxy is None then this future will never resolve.
+async fn wait_for_optional_view_controller_close(proxy: Option<ViewControllerProxy>) {
+ if let Some(proxy) = proxy {
+ wait_for_view_controller_close(proxy).await;
+ } else {
+ // If the view controller is None then we never exit and rely
+ // on the other futures to signal the end of the element.
+ futures::future::pending::<()>().await;
+ }
+}
+
+/// Waits for this view controller to close.
+async fn wait_for_view_controller_close(proxy: ViewControllerProxy) {
+ let stream = proxy.take_event_stream();
+ let _ = stream.collect::<Vec<_>>().await;
+}
+
+/// watches the element controller request stream and responds to requests.
+///
+/// if the ElementControllerRequestStream is None then this future will never resolve.
+async fn spawn_element_controller_stream(
+ element: Rc<RefCell<Element>>,
+ stream: Option<ElementControllerRequestStream>,
+ view_controller_proxy: Option<ViewControllerProxy>,
+) {
+ if let Some(mut stream) = stream {
+ while let Ok(Some(request)) = stream.try_next().await {
+ match request {
+ ElementControllerRequest::SetAnnotations { annotations, responder } => {
+ let mut element = element.borrow_mut();
+ let _ = responder.send(
+ &mut element
+ .set_annotations(annotations)
+ .map_err(|_: anyhow::Error| AnnotationError::Rejected),
+ );
+
+ if let Some(proxy) = &view_controller_proxy {
+ // Annotations cannot be cloned so we get them from
+ // the element which creates a new copy. This must
+ // be done after the call to set_annotations on
+ // element.
+ let annotations = element.get_annotations().unwrap_or_else(|e: Error| {
+ eprintln!("failed to get annotations from element: {:?}", e);
+ Annotations::new_empty()
+ });
+ let _ =
+ proxy.annotate(annotations).await.unwrap_or_else(|e: fidl::Error| {
+ eprintln!("failed to get annotations from element: {:?}", e)
+ });
+ }
+ }
+ ElementControllerRequest::GetAnnotations { responder } => {
+ let mut element = element.borrow_mut();
+ let _ = responder.send(
+ &mut element
+ .get_annotations()
+ .map_err(|_: anyhow::Error| AnnotationError::NotFound),
+ );
+ }
+ }
+ }
+ } else {
+ // If the element controller is None then we never exit and rely
+ // on the other futures to signal the end of the element.
+ futures::future::pending::<()>().await;
}
}
@@ -98,54 +228,276 @@
use {
super::*,
crate::element_repository::testing_utils::{init_logger, make_mock_element},
- anyhow::Error,
fidl::endpoints::create_proxy_and_stream,
- fidl_fuchsia_session::{Annotation, Annotations, ElementControllerMarker, Value},
+ fidl_fuchsia_session::{
+ Annotation, ElementControllerMarker, GraphicalPresenterMarker, Value,
+ ViewControllerMarker, ViewControllerRequest,
+ },
+ fuchsia_async::{DurationExt, Timer},
+ fuchsia_zircon::DurationNum,
+ futures::{future::Either, task::Poll},
};
- #[test]
- fn add_element_adds_to_the_collection() {
- init_logger();
- let (element, _channel) = make_mock_element();
- let mut handler = ElementEventHandler::new();
- handler.add_element(element, None);
- assert_eq!(handler.elements.len(), 1);
- }
- #[test]
- fn element_holder_without_stream_new() {
- init_logger();
- let (element, _channel) = make_mock_element();
- let holder = ElementHolder::new(element, None);
- assert!(holder._element.is_some());
- }
-
- #[fasync::run_singlethreaded(test)]
- async fn element_holder_with_stream_manages_lifetime_via_stream() -> Result<(), Error> {
- init_logger();
- let (element, _channel) = make_mock_element();
- let (_proxy, stream) = create_proxy_and_stream::<ElementControllerMarker>()?;
- let holder = ElementHolder::new(element, Some(stream));
- assert!(holder._element.is_none());
- Ok(())
- }
-
- #[fasync::run_singlethreaded(test)]
- async fn element_holder_listens_to_event_stream() -> Result<(), Error> {
- init_logger();
- let (element, _channel) = make_mock_element();
- let (proxy, stream) = create_proxy_and_stream::<ElementControllerMarker>()?;
- let _holder = ElementHolder::new(element, Some(stream));
-
- let annotation = Annotation {
- key: "foo".to_string(),
- value: Some(Box::new(Value::Text("bar".to_string()))),
+ macro_rules! expect_element_wait_fut_completion {
+ ($element_wait_fut:expr) => {
+ let timeout = Timer::new(500_i64.millis().after_now());
+ let either = futures::future::select(timeout, $element_wait_fut);
+ let resolved = either.await;
+ match resolved {
+ Either::Left(_) => panic!("failed to end element wait"),
+ Either::Right(_) => (),
+ }
};
- let _ =
- proxy.set_annotations(Annotations { custom_annotations: Some(vec![annotation]) }).await;
- let result = proxy.get_annotations().await?;
+ }
- let key = &result.unwrap().custom_annotations.unwrap()[0].key;
- assert_eq!(key, "foo");
+ #[test]
+ fn spawn_element_controller_stream_never_resolves_if_none_stream() {
+ init_logger();
+ let mut executor = fasync::Executor::new().unwrap();
+
+ let (element, _channel) = make_mock_element();
+ let mut fut =
+ Box::pin(spawn_element_controller_stream(Rc::new(RefCell::new(element)), None, None));
+
+ assert_eq!(Poll::Pending, executor.run_until_stalled(&mut fut));
+ }
+
+ #[test]
+ fn wait_for_optional_view_controller_close_never_resolves_if_none_proxy() {
+ init_logger();
+ let mut executor = fasync::Executor::new().unwrap();
+
+ let mut fut = Box::pin(wait_for_optional_view_controller_close(None));
+
+ assert_eq!(Poll::Pending, executor.run_until_stalled(&mut fut));
+ }
+
+ #[fasync::run_singlethreaded(test)]
+ async fn add_element_keeps_the_element_alive() {
+ init_logger();
+ let (element, channel) = make_mock_element();
+ let (proxy, _stream) =
+ create_proxy_and_stream::<GraphicalPresenterMarker>().expect("failed to create proxy");
+
+ let mut handler = ElementEventHandler::new(proxy);
+ handler.add_element(element, None);
+
+ let timeout = Timer::new(500_i64.millis().after_now());
+ let handle_ref = channel.as_handle_ref();
+ let element_close_fut =
+ fasync::OnSignals::new(&handle_ref, zx::Signals::CHANNEL_PEER_CLOSED);
+
+ let either = futures::future::select(timeout, element_close_fut);
+ let resolved = either.await;
+ match resolved {
+ Either::Left(_) => (),
+ Either::Right(_) => panic!("channel closed before timeout"),
+ }
+ }
+
+ #[fasync::run_singlethreaded(test)]
+ async fn killing_element_ends_wait() {
+ init_logger();
+ let (element, channel) = make_mock_element();
+
+ let element_wait_fut = Box::pin(run_until_closed(element, None, None));
+
+ // signal that the element should close
+ drop(channel);
+
+ expect_element_wait_fut_completion!(element_wait_fut);
+ }
+
+ #[fasync::run_singlethreaded(test)]
+ async fn dropping_element_controller_ends_wait() {
+ init_logger();
+ let (element, _channel) = make_mock_element();
+ let (element_controller, element_stream) =
+ create_proxy_and_stream::<ElementControllerMarker>().expect("failed to create proxy");
+ let element_wait_fut = Box::pin(run_until_closed(element, Some(element_stream), None));
+
+ drop(element_controller);
+
+ expect_element_wait_fut_completion!(element_wait_fut);
+ }
+
+ #[fasync::run_singlethreaded(test)]
+ async fn dropping_element_controller_ends_wait_after_dismissing_proxy() {
+ init_logger();
+ let (element, _channel) = make_mock_element();
+ let (vc_proxy, vc_server_end) =
+ create_proxy::<ViewControllerMarker>().expect("failed to create proxy");
+
+ let (element_controller, element_stream) =
+ create_proxy_and_stream::<ElementControllerMarker>().expect("failed to create proxy");
+
+ let element_wait_fut =
+ Box::pin(run_until_closed(element, Some(element_stream), Some(vc_proxy)));
+
+ drop(element_controller);
+
+ // Our API contract is that when the element controller closes we
+ // tell the view controller to dismiss. The view controller then can
+ // decide how it wants to dismiss the view. When it is done presenting
+ // the view it closes the channel to indicate it is done presenting.
+ fasync::spawn_local(async move {
+ let mut vc_stream = vc_server_end.into_stream().unwrap();
+ while let Ok(Some(request)) = vc_stream.try_next().await {
+ match request {
+ ViewControllerRequest::Dismiss { control_handle } => {
+ control_handle.shutdown();
+ }
+ _ => (),
+ }
+ }
+ });
+
+ expect_element_wait_fut_completion!(element_wait_fut);
+ }
+
+ #[fasync::run_singlethreaded(test)]
+ async fn dropping_view_controller_ends_wait() {
+ init_logger();
+ let (element, _channel) = make_mock_element();
+ let (view_controller, view_controller_stream) =
+ create_proxy_and_stream::<ViewControllerMarker>().expect("failed to create proxy");
+ let element_wait_fut = Box::pin(run_until_closed(element, None, Some(view_controller)));
+
+ drop(view_controller_stream);
+
+ expect_element_wait_fut_completion!(element_wait_fut);
+ }
+ #[fasync::run_singlethreaded(test)]
+ async fn calling_shutdown_on_the_handler_kills_elements() {
+ init_logger();
+ let (element, channel) = make_mock_element();
+ let (proxy, _stream) =
+ create_proxy_and_stream::<GraphicalPresenterMarker>().expect("failed to create proxy");
+
+ let mut handler = ElementEventHandler::new(proxy);
+ handler.add_element(element, None);
+
+ // shutdown should kill elements
+ handler.shutdown();
+
+ let timeout = Timer::new(500_i64.millis().after_now());
+ let handle_ref = channel.as_handle_ref();
+ let element_close_fut =
+ fasync::OnSignals::new(&handle_ref, zx::Signals::CHANNEL_PEER_CLOSED);
+
+ let either = futures::future::select(timeout, element_close_fut);
+ let resolved = either.await;
+ match resolved {
+ Either::Left(_) => panic!("element should have closed before timeout"),
+ Either::Right(_) => (),
+ }
+ }
+
+ #[fasync::run_singlethreaded(test)]
+ async fn spawn_element_controller_stream_set_annotations_updates_element() {
+ init_logger();
+ let (element, _channel) = make_mock_element();
+ let (element_controller, element_stream) =
+ create_proxy_and_stream::<ElementControllerMarker>().expect("failed to create proxy");
+
+ let element = Rc::new(RefCell::new(element));
+
+ let element_clone = element.clone();
+ fasync::spawn_local(async move {
+ spawn_element_controller_stream(element_clone, Some(element_stream), None).await;
+ });
+
+ let new_annotations = Annotations {
+ custom_annotations: Some(vec![Annotation {
+ key: "foo".to_string(),
+ value: Some(Box::new(Value::Text("bar".to_string()))),
+ }]),
+ };
+ let _ = element_controller.set_annotations(new_annotations).await;
+
+ let mut element = element.borrow_mut();
+ let annotations = element.get_annotations().unwrap();
+ let custom_annotations = annotations.custom_annotations.unwrap();
+
+ assert_eq!(custom_annotations.len(), 1);
+ assert_eq!(custom_annotations[0].key, "foo");
+ }
+
+ #[fasync::run_singlethreaded(test)]
+ async fn spawn_element_controller_stream_set_annotations_updates_view_controller_proxy() {
+ init_logger();
+ let (element, _channel) = make_mock_element();
+ let (element_controller, element_stream) =
+ create_proxy_and_stream::<ElementControllerMarker>().expect("failed to create proxy");
+ let (vc_proxy, mut view_controller_stream) =
+ create_proxy_and_stream::<ViewControllerMarker>().expect("failed to create proxy");
+
+ let element = Rc::new(RefCell::new(element));
+
+ let element_clone = element.clone();
+ fasync::spawn_local(async move {
+ spawn_element_controller_stream(element_clone, Some(element_stream), Some(vc_proxy))
+ .await;
+ });
+
+ fasync::spawn_local(async move {
+ let new_annotations = Annotations {
+ custom_annotations: Some(vec![Annotation {
+ key: "foo".to_string(),
+ value: Some(Box::new(Value::Text("bar".to_string()))),
+ }]),
+ };
+ let _ = element_controller.set_annotations(new_annotations).await;
+ });
+
+ let mut got_annotation = false;
+ if let Ok(Some(request)) = view_controller_stream.try_next().await {
+ match request {
+ ViewControllerRequest::Annotate { annotations, responder } => {
+ let custom_annotations = annotations.custom_annotations.unwrap();
+ got_annotation = custom_annotations[0].key == "foo";
+
+ let _ = responder.send();
+ }
+ _ => (),
+ }
+ }
+ assert!(got_annotation);
+ }
+
+ #[fasync::run_singlethreaded(test)]
+ async fn spawn_element_controller_stream_can_get_annotations() -> Result<(), Error> {
+ init_logger();
+
+ let (element, _channel) = make_mock_element();
+ let (element_controller, element_stream) =
+ create_proxy_and_stream::<ElementControllerMarker>().expect("failed to create proxy");
+
+ let element = Rc::new(RefCell::new(element));
+
+ let new_annotations = Annotations {
+ custom_annotations: Some(vec![Annotation {
+ key: "foo".to_string(),
+ value: Some(Box::new(Value::Text("bar".to_string()))),
+ }]),
+ };
+
+ {
+ let mut element = element.borrow_mut();
+ element.set_annotations(new_annotations).expect("failed to set annotationss");
+ }
+
+ let element_clone = element.clone();
+ fasync::spawn_local(async move {
+ spawn_element_controller_stream(element_clone, Some(element_stream), None).await;
+ });
+
+ let mut got_annotation = false;
+ if let Ok(Ok(annotations)) = element_controller.get_annotations().await {
+ let custom_annotations = annotations.custom_annotations.unwrap();
+ got_annotation = custom_annotations[0].key == "foo";
+ }
+ assert!(got_annotation);
Ok(())
}
}
diff --git a/session_shells/ermine/session/src/element_repository/mod.rs b/session_shells/ermine/session/src/element_repository/mod.rs
index 27de4ef..729845c 100644
--- a/session_shells/ermine/session/src/element_repository/mod.rs
+++ b/session_shells/ermine/session/src/element_repository/mod.rs
@@ -11,7 +11,6 @@
use {
anyhow::{Context as _, Error},
element_management::{Element, ElementManager, ElementManagerError},
- event_handler::{ElementEventHandler, EventHandler},
fidl_fuchsia_session::{
ElementControllerMarker, ElementControllerRequestStream, ElementSpec, ProposeElementError,
},
@@ -23,7 +22,10 @@
std::rc::Rc,
};
-pub use element_manager_server::ElementManagerServer;
+pub use {
+ element_manager_server::ElementManagerServer,
+ event_handler::{ElementEventHandler, EventHandler},
+};
/// The child collection to add elements to. This must match a collection name declared in
/// this session's CML file.
@@ -56,17 +58,14 @@
ElementRepository { element_manager, sender, receiver }
}
- /// Starts the event loop which handles incoming events that are handled by the servers
- pub async fn run(&mut self) -> Result<(), Error> {
- self.run_with_handler(&mut ElementEventHandler::new()).await;
- Ok(())
- }
-
/// Runs the repository with a given handler.
///
/// The handler is responsible for responding to incoming events and processing them.
/// A single handler is used for one repository allowing it to be safely mutated.
- async fn run_with_handler<'a>(&mut self, handler: &'a mut impl EventHandler) {
+ pub async fn run_with_handler<'a>(
+ &mut self,
+ handler: &'a mut impl EventHandler,
+ ) -> Result<(), Error> {
while let Some(event) = self.receiver.next().await {
match event {
ElementEvent::ElementAdded { element, element_controller_stream } => {
@@ -78,6 +77,7 @@
}
}
}
+ Ok(())
}
/// Creates a new ElementManagerServer suitable for handling incoming connections.
@@ -182,7 +182,7 @@
}
#[fasync::run_singlethreaded(test)]
- async fn shutdown_event_forwards_to_handler_and_ends_loop() {
+ async fn shutdown_event_forwards_to_handler_and_ends_loop() -> Result<(), Error> {
init_logger();
let mut repo = ElementRepository::new_for_test();
let mut handler = CallCountEventHandler::default();
@@ -192,12 +192,13 @@
sender.unbounded_send(ElementEvent::Shutdown).expect("failed to send event");
});
- repo.run_with_handler(&mut handler).await;
+ repo.run_with_handler(&mut handler).await?;
assert_eq!(handler.shutdown_call_count, 1);
+ Ok(())
}
#[fasync::run_singlethreaded(test)]
- async fn element_added_event_forwards_to_handler() {
+ async fn element_added_event_forwards_to_handler() -> Result<(), Error> {
init_logger();
let mut repo = ElementRepository::new_for_test();
let mut handler = CallCountEventHandler::default();
@@ -216,8 +217,9 @@
sender.unbounded_send(ElementEvent::Shutdown).expect("failed to send event");
});
- repo.run_with_handler(&mut handler).await;
+ repo.run_with_handler(&mut handler).await?;
assert_eq!(handler.add_call_count, 1);
+ Ok(())
}
#[fasync::run_singlethreaded(test)]
diff --git a/session_shells/ermine/session/src/main.rs b/session_shells/ermine/session/src/main.rs
index 9937e38..f99b468 100644
--- a/session_shells/ermine/session/src/main.rs
+++ b/session_shells/ermine/session/src/main.rs
@@ -10,13 +10,15 @@
use {
crate::{
- element_repository::{ElementManagerServer, ElementRepository},
+ element_repository::{ElementEventHandler, ElementManagerServer, ElementRepository},
pointer_hack_server::PointerHackServer,
},
anyhow::{Context as _, Error},
element_management::SimpleElementManager,
fidl::endpoints::DiscoverableService,
- fidl_fuchsia_session::{ElementManagerMarker, ElementManagerRequestStream},
+ fidl_fuchsia_session::{
+ ElementManagerMarker, ElementManagerRequestStream, GraphicalPresenterMarker,
+ },
fidl_fuchsia_sys::LauncherMarker,
fidl_fuchsia_sys2 as fsys,
fidl_fuchsia_ui_app::ViewProviderMarker,
@@ -111,6 +113,9 @@
let (app, pointer_hack_server) = launch_ermine(element_repository.make_server()).await?;
let view_provider = app.connect_to_service::<ViewProviderMarker>()?;
+ let presenter = app.connect_to_service::<GraphicalPresenterMarker>()?;
+ let mut handler = ElementEventHandler::new(presenter);
+
let scenic = connect_to_service::<ScenicMarker>()?;
let mut scene_manager = scene_management::FlatSceneManager::new(scenic, None, None).await?;
@@ -119,7 +124,7 @@
let services_fut = expose_services(element_repository.make_server());
let input_fut = workstation_input_pipeline::handle_input(scene_manager, &pointer_hack_server);
- let element_manager_fut = element_repository.run();
+ let element_manager_fut = element_repository.run_with_handler(&mut handler);
//TODO(47080) monitor the futures to see if they complete in an error.
let _ = try_join!(services_fut, input_fut, element_manager_fut);
diff --git a/session_shells/ermine/shell/BUILD.gn b/session_shells/ermine/shell/BUILD.gn
index 2a42845..18a6d33 100644
--- a/session_shells/ermine/shell/BUILD.gn
+++ b/session_shells/ermine/shell/BUILD.gn
@@ -83,6 +83,7 @@
"src/models/topbar_model.dart",
"src/utils/elevations.dart",
"src/utils/styles.dart",
+ "src/utils/presenter.dart",
"src/utils/suggestion.dart",
"src/utils/suggestions.dart",
"src/utils/utils.dart",
@@ -124,6 +125,7 @@
"//sdk/fidl/fuchsia.intl",
"//sdk/fidl/fuchsia.memory",
"//sdk/fidl/fuchsia.power",
+ "//sdk/fidl/fuchsia.session",
"//sdk/fidl/fuchsia.ui.app",
"//sdk/fidl/fuchsia.ui.input",
"//sdk/fidl/fuchsia.ui.input2",
diff --git a/session_shells/ermine/shell/lib/src/models/app_model.dart b/session_shells/ermine/shell/lib/src/models/app_model.dart
index 3dd0cbb..662a172 100644
--- a/session_shells/ermine/shell/lib/src/models/app_model.dart
+++ b/session_shells/ermine/shell/lib/src/models/app_model.dart
@@ -6,7 +6,6 @@
import 'dart:io';
import 'package:fidl_fuchsia_intl/fidl_async.dart';
-import 'package:fidl_fuchsia_sys/fidl_async.dart';
import 'package:fidl_fuchsia_ui_input/fidl_async.dart' as input;
import 'package:fidl_fuchsia_ui_shortcut/fidl_async.dart' as ui_shortcut
show RegistryProxy;
@@ -20,6 +19,7 @@
show KeyboardShortcuts;
import 'package:lib.widgets/utils.dart' show PointerEventsListener;
+import '../utils/presenter.dart';
import '../utils/suggestions.dart';
import '../widgets/ask/ask.dart';
import 'cluster_model.dart';
@@ -32,9 +32,9 @@
final _pointerEventsListener = PointerEventsListener();
final _shortcutRegistry = ui_shortcut.RegistryProxy();
final _intl = PropertyProviderProxy();
- final _launcher = LauncherProxy();
SuggestionService _suggestionService;
+ PresenterService _presenterService;
/// The [GlobalKey] associated with [Ask] widget.
final GlobalKey<AskState> askKey = GlobalKey(debugLabel: 'ask');
@@ -62,20 +62,24 @@
_startupContext.incoming.connectToService(_shortcutRegistry);
_startupContext.incoming.connectToService(_intl);
_startupContext.incoming.connectToService(_presentation);
- _startupContext.incoming.connectToService(_launcher);
_localeStream = LocaleSource(_intl).stream().asBroadcastStream();
- clustersModel = ClustersModel(_launcher);
+ clustersModel = ClustersModel();
_suggestionService = SuggestionService.fromStartupContext(
startupContext: _startupContext,
- onSuggestion: clustersModel.storyStarted,
+ onSuggestion: clustersModel.storySuggested,
);
topbarModel = TopbarModel(appModel: this);
status = StatusModel.fromStartupContext(_startupContext, onLogout);
+
+ // Expose the presenter service to the environment.
+ _presenterService = PresenterService(clustersModel.presentStory);
+ _startupContext.outgoing
+ .addPublicService(_presenterService.bind, PresenterService.serviceName);
}
SuggestionService get suggestions => _suggestionService;
@@ -236,7 +240,6 @@
_keyboardShortcuts.dispose();
_shortcutRegistry.ctrl.close();
_presentation.ctrl.close();
- _launcher.ctrl.close();
}
void injectTap(Offset offset) {
diff --git a/session_shells/ermine/shell/lib/src/models/cluster_model.dart b/session_shells/ermine/shell/lib/src/models/cluster_model.dart
index d857362..fa33de6 100644
--- a/session_shells/ermine/shell/lib/src/models/cluster_model.dart
+++ b/session_shells/ermine/shell/lib/src/models/cluster_model.dart
@@ -4,9 +4,10 @@
import 'package:flutter/material.dart';
-import 'package:fidl_fuchsia_sys/fidl_async.dart';
+import 'package:fidl_fuchsia_ui_views/fidl_async.dart';
import 'package:tiler/tiler.dart' show TilerModel, TileModel;
+import '../utils/presenter.dart';
import '../utils/suggestion.dart';
import 'ermine_shell.dart';
import 'ermine_story.dart';
@@ -52,10 +53,6 @@
/// Returns a iterable of all [Story] objects.
Iterable<ErmineStory> get stories => _storyToCluster.keys.map(getStory);
- final LauncherProxy _launcher;
-
- ClustersModel(Launcher launcher) : _launcher = launcher;
-
/// Maximize the story to fullscreen: it's visual state to IMMERSIVE.
void maximize(String id) {
if (fullscreenStoryNotifier.value?.id == id) {
@@ -78,18 +75,42 @@
/// Returns the story given it's id.
ErmineStory getStory(String id) => _storyToCluster[id]?.getStory(id);
+ @override
+ void presentStory(
+ ViewHolderToken token,
+ ViewControllerImpl viewController,
+ String id,
+ ) {
+ // Check to see if a story already exists on screen.
+ ErmineStory story = getStory(id);
+
+ if (story == null) {
+ // This view was created by some external source.
+ // create a new story and present it.
+ story = ErmineStory.fromExternalSource(
+ onDelete: storyDeleted,
+ onChange: storyChanged,
+ );
+ _addErmineStory(story);
+ }
+
+ story.presentView(token, viewController);
+ }
+
/// Creates and adds a [Story] to the current cluster given it's [StoryInfo],
/// [SessionShell] and [StoryController]. Returns the created instance.
@override
- void storyStarted(Suggestion suggestion) {
+ void storySuggested(Suggestion suggestion) {
assert(!_storyToCluster.containsKey(suggestion.id));
- final story = ErmineStory(
+ final story = ErmineStory.fromSuggestion(
suggestion: suggestion,
- launcher: _launcher,
onDelete: storyDeleted,
onChange: storyChanged,
);
+ _addErmineStory(story);
+ }
+ void _addErmineStory(ErmineStory story) {
// Add the story to the current cluster
currentCluster.value ??= clusters.first;
_storyToCluster[story.id] = currentCluster.value..add(story);
diff --git a/session_shells/ermine/shell/lib/src/models/ermine_shell.dart b/session_shells/ermine/shell/lib/src/models/ermine_shell.dart
index cf3f288..785e430 100644
--- a/session_shells/ermine/shell/lib/src/models/ermine_shell.dart
+++ b/session_shells/ermine/shell/lib/src/models/ermine_shell.dart
@@ -2,6 +2,9 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
+import 'package:fidl_fuchsia_ui_views/fidl_async.dart';
+
+import '../utils/presenter.dart';
import '../utils/suggestion.dart';
import 'ermine_story.dart';
@@ -14,11 +17,27 @@
/// - programmatically through scripting
abstract class ErmineShell {
/// Creates a new [ErmineStory] with a given [Suggestion].
- void storyStarted(Suggestion suggestion);
+ void storySuggested(Suggestion suggestion);
/// Handles a story removed from the shell.
void storyDeleted(ErmineStory story);
/// Handles story attributes changing and updating its UX in the shell.
void storyChanged(ErmineStory story);
+
+ /// Called when a story should be presented. A story is presented after
+ /// it has been launched. The story may have been proposed by ermine or
+ /// an external source.
+ void presentStory(
+ /// The view holder token used to connect the view to the process
+ ViewHolderToken token,
+
+ /// A view controller which can be used to communicate with the running process
+ /// This value may be null
+ ViewControllerImpl viewController,
+
+ /// A string identifying the launched story. This id is only valid if the
+ /// story was launched from ermine.
+ String id,
+ );
}
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 99bf05b..7ed9502 100644
--- a/session_shells/ermine/shell/lib/src/models/ermine_story.dart
+++ b/session_shells/ermine/shell/lib/src/models/ermine_story.dart
@@ -2,40 +2,78 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
-import 'package:fidl_fuchsia_sys/fidl_async.dart';
-import 'package:fidl_fuchsia_ui_app/fidl_async.dart';
+import 'package:fidl_fuchsia_session/fidl_async.dart';
import 'package:fidl_fuchsia_ui_views/fidl_async.dart';
import 'package:flutter/material.dart';
+import 'package:fuchsia_logger/logger.dart';
import 'package:fuchsia_scenic_flutter/child_view_connection.dart';
import 'package:fuchsia_services/services.dart';
-import 'package:zircon/zircon.dart';
+import 'package:uuid/uuid.dart';
+import '../utils/presenter.dart';
import '../utils/suggestion.dart';
+/// A function which can be used to launch the suggestion.
+@visibleForTesting
+typedef LaunchFunction = Future<void> Function(
+ Suggestion, ElementControllerProxy);
+
/// Defines a class to represent a story in ermine.
class ErmineStory {
- final Suggestion suggestion;
final ValueChanged<ErmineStory> onDelete;
final ValueChanged<ErmineStory> onChange;
- final LauncherProxy _launcher;
- final ComponentControllerProxy _componentController;
+ final String id;
+ // An optional view controller which allows the story to communicate with the
+ // process.
+ @visibleForTesting
+ ViewControllerImpl viewController;
+
+ // An optional element controller which allows the story to communicate with
+ // the element. This will only be available if ermine launched this process.
+ ElementControllerProxy _elementController;
+
+ /// Creates a launches an ermine story.
+ @visibleForTesting
ErmineStory({
- this.suggestion,
- Launcher launcher,
+ this.id,
this.onDelete,
this.onChange,
- ComponentControllerProxy componentController,
- }) : _launcher = launcher,
- _componentController =
- componentController ?? ComponentControllerProxy(),
- nameNotifier = ValueNotifier(suggestion.title),
- childViewConnectionNotifier = ValueNotifier(null) {
- launchSuggestion();
- _componentController.onTerminated.listen((_) => delete());
+ String title = '',
+ }) : nameNotifier = ValueNotifier(title),
+ childViewConnectionNotifier = ValueNotifier(null);
+
+ factory ErmineStory.fromSuggestion({
+ Suggestion suggestion,
+ ValueChanged<ErmineStory> onDelete,
+ ValueChanged<ErmineStory> onChange,
+ LaunchFunction launchFunction = ErmineStory.launchSuggestion,
+ }) {
+ final elementController = ElementControllerProxy();
+ launchFunction(suggestion, elementController);
+ return ErmineStory(
+ id: suggestion.id,
+ title: suggestion.title,
+ onDelete: onDelete,
+ onChange: onChange,
+ ).._elementController = elementController;
}
- String get id => suggestion.id;
+ /// Creates an ermine story which was proposed from an external source.
+ ///
+ /// This method will not attempt to launch a story but will generate
+ /// a random suggestion
+ factory ErmineStory.fromExternalSource({
+ ValueChanged<ErmineStory> onDelete,
+ ValueChanged<ErmineStory> onChange,
+ }) {
+ final id = Uuid().v4();
+ return ErmineStory(
+ id: 'external:$id',
+ onDelete: onDelete,
+ onChange: onChange,
+ );
+ }
final ValueNotifier<String> nameNotifier;
String get name => nameNotifier.value ?? id;
@@ -55,9 +93,9 @@
bool get isImmersive => fullscreenNotifier.value == true;
void delete() {
- _componentController.kill();
- _componentController.ctrl.close();
+ viewController?.close();
onDelete?.call(this);
+ _elementController?.ctrl?.close();
}
void focus() => onChange?.call(this..focused = true);
@@ -69,34 +107,38 @@
ValueNotifier<bool> editStateNotifier = ValueNotifier(false);
void edit() => editStateNotifier.value = !editStateNotifier.value;
- @visibleForTesting
- Future<void> launchSuggestion() async {
- final incoming = Incoming();
+ static Future<void> launchSuggestion(
+ Suggestion suggestion, ElementControllerProxy elementController) async {
+ final proxy = ElementManagerProxy();
- await _launcher.createComponent(
- LaunchInfo(
- url: suggestion.url,
- directoryRequest: incoming.request().passChannel(),
+ StartupContext.fromStartupInfo().incoming.connectToService(proxy);
+
+ final annotations = Annotations(customAnnotations: [
+ Annotation(
+ key: ermineSuggestionIdKey,
+ value: Value.withText(suggestion.id),
),
- _componentController.ctrl.request(),
- );
+ ]);
- ViewProviderProxy viewProvider = ViewProviderProxy();
- incoming.connectToService(viewProvider);
- await incoming.close();
+ final spec =
+ ElementSpec(componentUrl: suggestion.url, annotations: annotations);
- final viewTokens = EventPairPair();
- assert(viewTokens.status == ZX.OK);
- final viewHolderToken = ViewHolderToken(value: viewTokens.first);
- final viewToken = ViewToken(value: viewTokens.second);
+ await proxy
+ .proposeElement(spec, elementController.ctrl.request())
+ .catchError((err) {
+ log.shout('$err: Failed to propose elememnt <${suggestion.url}>');
+ });
- await viewProvider.createView(viewToken.value, null, null);
- viewProvider.ctrl.close();
+ proxy.ctrl.close();
+ }
+ void presentView(ViewHolderToken viewHolderToken, ViewControllerImpl vc) {
childViewConnectionNotifier.value = ChildViewConnection(
viewHolderToken,
onAvailable: (_) {},
onUnavailable: (_) {},
);
+ viewController = vc;
+ viewController?.didPresent();
}
}
diff --git a/session_shells/ermine/shell/lib/src/utils/presenter.dart b/session_shells/ermine/shell/lib/src/utils/presenter.dart
new file mode 100644
index 0000000..c9d3dd1
--- /dev/null
+++ b/session_shells/ermine/shell/lib/src/utils/presenter.dart
@@ -0,0 +1,102 @@
+// 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 'dart:async';
+
+import 'package:fidl_fuchsia_session/fidl_async.dart' as fidl;
+import 'package:fidl_fuchsia_ui_views/fidl_async.dart';
+import 'package:fidl/fidl.dart';
+import 'package:fuchsia_logger/logger.dart';
+
+import 'suggestion.dart';
+
+/// A callback which is invoked when the presenter is asked to present a view.
+///
+/// The ViewControllerImpl will not be null but it may not be bound if the
+/// requesting source did not provide a channel. The methods will still be safe
+/// to call even if it is not bound.
+typedef PresentViewCallback = void Function(
+ ViewHolderToken, ViewControllerImpl, String);
+
+/// A service which implements the fuchsia.session.GraphicalPresenter protocol.
+class PresenterService extends fidl.GraphicalPresenter {
+ static const String serviceName = fidl.GraphicalPresenter.$serviceName;
+
+ final List<fidl.GraphicalPresenterBinding> _bindings = [];
+ final PresentViewCallback _onPresent;
+
+ PresenterService(this._onPresent);
+
+ /// Binds the request to this model.
+ void bind(InterfaceRequest<fidl.GraphicalPresenter> request) {
+ final binding = fidl.GraphicalPresenterBinding();
+ binding
+ ..bind(this, request)
+ ..whenClosed.then((_) {
+ _bindings.remove(binding);
+ });
+ _bindings.add(binding);
+ }
+
+ @override
+ Future<void> presentView(fidl.ViewSpec viewSpec,
+ InterfaceRequest<fidl.ViewController> viewControllerRequest) async {
+ final viewController = ViewControllerImpl()..bind(viewControllerRequest);
+
+ // Check to see if we have an id that we included in the annotation.
+ final idAnnotation = viewSpec.annotations?.customAnnotations
+ ?.firstWhere((a) => a.key == ermineSuggestionIdKey, orElse: () => null);
+
+ final viewHolderToken = viewSpec.viewHolderToken;
+ if (viewHolderToken != null) {
+ _onPresent(
+ viewHolderToken, viewController, idAnnotation?.value?.text ?? '');
+ } else {
+ viewController.close(fidl.ViewControllerEpitaph.invalidViewSpec);
+ }
+ }
+}
+
+class ViewControllerImpl extends fidl.ViewController {
+ final _binding = fidl.ViewControllerBinding();
+ final StreamController<void> _onPresentedStreamController =
+ StreamController.broadcast();
+
+ void bind(InterfaceRequest<fidl.ViewController> interfaceRequest) {
+ if (interfaceRequest != null && interfaceRequest.channel != null) {
+ _binding.bind(this, interfaceRequest);
+ _binding.whenClosed.then((_) {
+ //TODO(47793) Need to watch our binding for unexpected closures and notify the user
+ log.info('binding closed unexpectedly');
+ });
+ }
+ }
+
+ void close([fidl.ViewControllerEpitaph e]) {
+ if (_binding.isBound) {
+ _binding.close(e?.$value);
+ }
+ _onPresentedStreamController.close();
+ }
+
+ @override
+ Future<void> annotate(fidl.Annotations annotations) async {
+ //TODO(47791) need to implement this method
+ }
+
+ @override
+ Future<void> dismiss() async {
+ // TODO(47792): A call to dismiss indicates that the view should go away we need to
+ // allow the user of this class to asynchronously dismiss the view before closing
+ // the channel.
+ close();
+ }
+
+ @override
+ Stream<void> get onPresented => _onPresentedStreamController.stream;
+
+ void didPresent() {
+ _onPresentedStreamController.add(null);
+ }
+}
diff --git a/session_shells/ermine/shell/lib/src/utils/suggestion.dart b/session_shells/ermine/shell/lib/src/utils/suggestion.dart
index 857019c..7ed5511 100644
--- a/session_shells/ermine/shell/lib/src/utils/suggestion.dart
+++ b/session_shells/ermine/shell/lib/src/utils/suggestion.dart
@@ -10,3 +10,5 @@
const Suggestion({this.id, this.url, this.title});
}
+
+const String ermineSuggestionIdKey = 'ermine:suggestion:id';
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 7985c91..49c685b 100644
--- a/session_shells/ermine/shell/lib/src/widgets/story/cluster.dart
+++ b/session_shells/ermine/shell/lib/src/widgets/story/cluster.dart
@@ -53,35 +53,35 @@
return AnimatedBuilder(
animation: story.childViewConnectionNotifier,
builder: (context, child) {
- return story.childViewConnection != null
- ? AnimatedBuilder(
- animation: Listenable.merge([
- story.nameNotifier,
- story.focusedNotifier,
- story.editStateNotifier,
- ]),
- builder: (context, child) => TileChrome(
- name: story.name,
- showTitle: !custom,
- titleFieldController: titleFieldController,
- editing: story.editStateNotifier.value,
- focused: story.focused,
- child: AnimatedBuilder(
- animation: story.fullscreenNotifier,
- builder: (context, child) => story.isImmersive
- ? Offstage()
- : ChildView(connection: story.childViewConnection),
- ),
- onTap: story.focus,
- onDelete: story.delete,
- onFullscreen: story.maximize,
- onMinimize: story.restore,
- onEdit: story.edit,
- onCancelEdit: () => confirmEditNotifier.value = false,
- onConfirmEdit: () => confirmEditNotifier.value = true,
- ),
- )
- : Offstage();
+ return AnimatedBuilder(
+ animation: Listenable.merge([
+ story.nameNotifier,
+ story.focusedNotifier,
+ story.editStateNotifier,
+ ]),
+ builder: (context, child) => TileChrome(
+ name: story.name,
+ showTitle: !custom,
+ titleFieldController: titleFieldController,
+ editing: story.editStateNotifier.value,
+ focused: story.focused,
+ child: AnimatedBuilder(
+ animation: story.fullscreenNotifier,
+ builder: (context, child) => story.isImmersive
+ ? Offstage()
+ : story.childViewConnection != null
+ ? ChildView(connection: story.childViewConnection)
+ : Offstage(), //TODO(47796) show a placeholder until the view loads
+ ),
+ onTap: story.focus,
+ onDelete: story.delete,
+ onFullscreen: story.maximize,
+ onMinimize: story.restore,
+ onEdit: story.edit,
+ onCancelEdit: () => confirmEditNotifier.value = false,
+ onConfirmEdit: () => confirmEditNotifier.value = true,
+ ),
+ );
},
);
}
diff --git a/session_shells/ermine/shell/test/ermine_story_test.dart b/session_shells/ermine/shell/test/ermine_story_test.dart
index 8918f14..e11d5c9 100644
--- a/session_shells/ermine/shell/test/ermine_story_test.dart
+++ b/session_shells/ermine/shell/test/ermine_story_test.dart
@@ -5,111 +5,51 @@
import 'dart:async';
// ignore_for_file: implementation_imports
-import 'package:fidl/fidl.dart';
import 'package:ermine_library/src/models/ermine_story.dart';
+import 'package:ermine_library/src/utils/presenter.dart';
import 'package:ermine_library/src/utils/suggestion.dart';
-import 'package:fidl_fuchsia_sys/fidl_async.dart';
import 'package:mockito/mockito.dart';
import 'package:test/test.dart';
void main() {
- MockComponentController controller;
- MockProxyController proxy;
- StreamController<ComponentController$OnTerminated$Response>
- onTerminatedEventStreamController;
+ test('from suggestion sets id and title', () {
+ final suggestion = Suggestion(id: 'id', title: 'title', url: 'url');
+ final story = ErmineStory.fromSuggestion(
+ suggestion: suggestion,
+ launchFunction: (_, __) async {},
+ );
- setUp(() async {
- controller = MockComponentController();
- proxy = MockProxyController();
- when(controller.ctrl).thenReturn(proxy);
-
- onTerminatedEventStreamController =
- StreamController<ComponentController$OnTerminated$Response>.broadcast(
- sync: true);
- when(controller.onTerminated)
- .thenAnswer((_) => onTerminatedEventStreamController.stream);
+ expect(story.id, 'id');
+ expect(story.name, 'title');
});
- tearDown(() async {
- await onTerminatedEventStreamController.close();
+ test('Creating a story also launches the suggestion', () async {
+ final completer = Completer<bool>();
+ final suggestion = Suggestion(id: 'id', title: 'title', url: 'url');
+ ErmineStory.fromSuggestion(
+ suggestion: suggestion,
+ launchFunction: (s, _) async => completer.complete(s.id == 'id'),
+ );
+ expect(await completer.future, true);
+ });
+
+ test('create from external source generates random id', () {
+ final story1 = ErmineStory.fromExternalSource();
+ final story2 = ErmineStory.fromExternalSource();
+
+ expect(story1.id, isNot(story2.id));
});
test('Delete ErmineStory', () {
- // Creating a story should also launch it.
- final suggestion = Suggestion(id: 'id', title: 'title', url: 'url');
- final story = TestErmineStory(
- suggestion: suggestion,
- launcher: MockLauncher(),
- componentController: controller,
- );
- expect(story.launched, true);
+ final viewController = MockViewControllerImpl();
+ bool didCallDelete = false;
+ ErmineStory.fromExternalSource(onDelete: (_) => didCallDelete = true)
+ ..viewController = viewController
+ ..delete();
- // Delete should call deleted.
- story.delete();
- expect(story.deleted, true);
- verify(controller.kill()).called(1);
- verify(proxy.close()).called(1);
- });
-
- test('Terminate ErmineStory', () {
- // Creating a story should also launch it.
- final suggestion = Suggestion(id: 'id', title: 'title', url: 'url');
- final story = TestErmineStory(
- suggestion: suggestion,
- launcher: MockLauncher(),
- componentController: controller,
- );
- expect(story.launched, true);
-
- // Terminated should call deleted.
- final response =
- ComponentController$OnTerminated$Response(0, TerminationReason.exited);
- onTerminatedEventStreamController.add(response);
- expect(story.deleted, true);
- verify(controller.kill()).called(1);
- verify(proxy.close()).called(1);
+ expect(didCallDelete, isTrue);
+ verify(viewController.close()).called(1);
});
}
-class TestErmineStory extends ErmineStory {
- bool deleted;
- bool changed;
- bool launched;
- MockLauncher launcher;
- MockComponentController componentController;
-
- TestErmineStory({
- Suggestion suggestion,
- this.launcher,
- this.componentController,
- }) : super(
- suggestion: suggestion,
- launcher: launcher,
- componentController: componentController,
- onDelete: _onDelete,
- onChange: _onChange,
- );
-
- static void _onDelete(ErmineStory story) {
- if (story is TestErmineStory) {
- story.deleted = true;
- }
- }
-
- static void _onChange(ErmineStory story) {
- if (story is TestErmineStory) {
- story.changed = true;
- }
- }
-
- @override
- Future<void> launchSuggestion() async => launched = true;
-}
-
-class MockLauncher extends Mock implements LauncherProxy {}
-
-class MockComponentController extends Mock implements ComponentControllerProxy {
-}
-
-class MockProxyController extends Mock
- implements AsyncProxyController<ComponentController> {}
+class MockViewControllerImpl extends Mock implements ViewControllerImpl {}