| // Copyright 2019 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. |
| |
| /// Deserialization of Actions. |
| use { |
| crate::story_context_store::ContextEntity, |
| fidl_fuchsia_app_discover::Suggestion as FidlSuggestion, |
| fidl_fuchsia_modular::{ |
| AddMod as FidlAddModInfo, DisplayInfo as FidlDisplayInfo, Intent as FidlIntent, |
| IntentParameter as FidlIntentParameter, IntentParameterData as FidlIntentParameterData, |
| SurfaceArrangement, SurfaceDependency, SurfaceRelation, |
| }, |
| maplit::btreeset, |
| serde_derive::{Deserialize, Serialize}, |
| std::collections::{BTreeSet, HashMap}, |
| uuid::Uuid, |
| }; |
| |
| #[derive(Clone, Deserialize, Debug, Eq, PartialEq, Hash)] |
| pub struct Action { |
| pub name: String, |
| #[serde(default)] |
| pub parameters: Vec<Parameter>, |
| pub action_display: Option<ActionDisplayInfo>, |
| pub web_fulfillment: Option<WebFulfillment>, |
| pub fuchsia_fulfillment: Option<FuchsiaFulfillment>, |
| } |
| |
| #[derive(Clone, Deserialize, Debug, Eq, PartialEq, Hash)] |
| pub struct Parameter { |
| #[serde(rename = "type")] |
| pub parameter_type: String, |
| pub name: String, |
| } |
| |
| #[derive(Clone, Deserialize, Debug, Eq, PartialEq, Hash)] |
| pub struct ActionDisplayInfo { |
| pub display_info: Option<DisplayInfo>, |
| #[serde(default)] |
| pub parameter_mapping: Vec<ParameterMapping>, |
| } |
| |
| pub struct OutputConsumer { |
| pub entity_reference: String, |
| /// The id of the module outputting the entity. |
| pub module_id: String, |
| pub output_name: String, |
| pub consume_type: String, |
| } |
| |
| impl OutputConsumer { |
| pub fn new( |
| entity_reference: impl Into<String>, |
| module_id: impl Into<String>, |
| output_name: impl Into<String>, |
| consume_type: impl Into<String>, |
| ) -> Self { |
| OutputConsumer { |
| entity_reference: entity_reference.into(), |
| module_id: module_id.into(), |
| output_name: output_name.into(), |
| consume_type: consume_type.into(), |
| } |
| } |
| } |
| |
| pub struct StoryMetadata { |
| pub story_name: String, |
| pub story_title: String, |
| pub last_executed_timestamp: u128, |
| } |
| |
| impl StoryMetadata { |
| pub fn new( |
| story_name: impl Into<String>, |
| story_title: impl Into<String>, |
| last_executed_timestamp: u128, |
| ) -> Self { |
| StoryMetadata { |
| story_name: story_name.into(), |
| story_title: story_title.into(), |
| last_executed_timestamp, |
| } |
| } |
| } |
| |
| #[derive(Clone, Deserialize, Debug, Eq, PartialEq, Hash)] |
| pub struct DisplayInfo { |
| pub icon: Option<String>, |
| pub title: Option<String>, |
| pub subtitle: Option<String>, |
| } |
| |
| #[derive(Clone, Deserialize, Debug, Eq, PartialEq, Hash)] |
| pub struct ParameterMapping { |
| name: String, |
| parameter_property: String, |
| } |
| |
| #[derive(Clone, Deserialize, Debug, Eq, PartialEq, Hash)] |
| pub struct WebFulfillment { |
| url_template: String, |
| #[serde(default)] |
| parameter_mapping: Vec<ParameterMapping>, |
| } |
| |
| #[derive(Debug, Clone, Eq, PartialEq)] |
| pub struct RestoreStoryInfo { |
| pub story_name: String, |
| } |
| |
| impl RestoreStoryInfo { |
| pub fn new(story_name: impl Into<String>) -> Self { |
| RestoreStoryInfo { story_name: story_name.into() } |
| } |
| } |
| |
| #[derive(Debug, Clone, Eq, PartialEq)] |
| pub enum SuggestedAction { |
| RestoreStory(RestoreStoryInfo), |
| AddMod(AddModInfo), |
| } |
| |
| impl SuggestedAction { |
| fn restore_story(story_name: String) -> SuggestedAction { |
| SuggestedAction::RestoreStory(RestoreStoryInfo::new(story_name)) |
| } |
| |
| fn add_mod(action: AddModInfo) -> SuggestedAction { |
| SuggestedAction::AddMod(action) |
| } |
| } |
| |
| #[derive(Debug, Clone, Eq, PartialEq)] |
| // TODO: Suggestions at this point should contain ActionDisplayInfo |
| pub struct Suggestion { |
| id: String, |
| display_info: DisplayInfo, |
| action: SuggestedAction, |
| } |
| |
| #[derive(Debug, Clone, Eq, Hash, PartialEq)] |
| pub struct AddModInfo { |
| pub mod_name: String, |
| story_name: String, |
| pub intent: Intent, |
| } |
| |
| #[derive(Debug, Clone, Eq, Hash, PartialEq, Serialize, Deserialize)] |
| pub struct Intent { |
| pub handler: Option<String>, |
| pub action: Option<String>, |
| pub parameters: BTreeSet<IntentParameter>, |
| } |
| |
| #[derive(Debug, Clone, Eq, Hash, PartialEq, PartialOrd, Ord, Serialize, Deserialize)] |
| pub struct IntentParameter { |
| pub name: String, |
| pub entity_reference: String, |
| } |
| |
| impl Suggestion { |
| pub fn new(action: AddModInfo, display_info: DisplayInfo) -> Self { |
| Suggestion { |
| id: Uuid::new_v4().to_string(), |
| display_info, |
| action: SuggestedAction::add_mod(action), |
| } |
| } |
| |
| pub fn new_story_suggestion(story_name: String, display_info: DisplayInfo) -> Self { |
| Suggestion { |
| id: Uuid::new_v4().to_string(), |
| display_info, |
| action: SuggestedAction::restore_story(story_name), |
| } |
| } |
| |
| pub fn action(&self) -> &SuggestedAction { |
| &self.action |
| } |
| |
| pub fn id(&self) -> &str { |
| &self.id |
| } |
| |
| pub fn display_info(&self) -> &DisplayInfo { |
| &self.display_info |
| } |
| } |
| |
| impl Action { |
| pub async fn load_display_info<'a>( |
| &'a self, |
| parameters: HashMap<String, EntityMatching<'a>>, |
| ) -> Option<DisplayInfo> { |
| match self.action_display { |
| None => None, |
| Some(ref action_display) => action_display.load_display_info(parameters).await, |
| } |
| } |
| } |
| |
| impl ActionDisplayInfo { |
| pub async fn load_display_info<'a>( |
| &'a self, |
| parameters: HashMap<String, EntityMatching<'a>>, |
| ) -> Option<DisplayInfo> { |
| match self.display_info { |
| None => None, |
| Some(ref display_info) => Some(DisplayInfo { |
| title: self.interpolate(&display_info.title, ¶meters).await, |
| subtitle: self.interpolate(&display_info.subtitle, ¶meters).await, |
| icon: self.interpolate(&display_info.icon, ¶meters).await, |
| }), |
| } |
| } |
| |
| async fn interpolate<'a>( |
| &'a self, |
| template: &'a Option<String>, |
| parameters: &'a HashMap<String, EntityMatching<'a>>, |
| ) -> Option<String> { |
| match template { |
| None => None, |
| Some(ref template_str) => { |
| let mut result = template_str.clone(); |
| for parameter_mapping in &self.parameter_mapping { |
| // Matches {name}. {{ is escaped { |
| let template_part = format!("{{{name}}}", name = parameter_mapping.name); |
| if !result.contains(&template_part) |
| || parameter_mapping.parameter_property.is_empty() |
| { |
| continue; |
| } |
| let mut parts = parameter_mapping.parameter_property.split('.'); |
| let intent_param = parts.next().unwrap(); |
| let subfield_path = parts.collect::<Vec<&str>>(); |
| if let Some(matching) = parameters.get(intent_param) { |
| if let Some(data) = matching.get_data(subfield_path).await { |
| result = result.replace(&template_part, &data); |
| } |
| } |
| } |
| Some(result) |
| } |
| } |
| } |
| } |
| |
| impl DisplayInfo { |
| pub fn new() -> Self { |
| DisplayInfo { title: None, icon: None, subtitle: None } |
| } |
| |
| pub fn with_title(mut self, title: &str) -> Self { |
| self.title = Some(title.to_string()); |
| self |
| } |
| |
| #[cfg(test)] |
| pub fn with_subtitle(mut self, subtitle: &str) -> Self { |
| self.subtitle = Some(subtitle.to_string()); |
| self |
| } |
| |
| #[cfg(test)] |
| pub fn with_icon(mut self, icon: &str) -> Self { |
| self.icon = Some(icon.to_string()); |
| self |
| } |
| } |
| |
| #[derive(Debug, Clone)] |
| pub struct EntityMatching<'a> { |
| pub context_entity: &'a ContextEntity, |
| pub matching_type: String, |
| } |
| |
| impl<'a> EntityMatching<'a> { |
| async fn get_data(&'a self, path: Vec<&'a str>) -> Option<String> { |
| self.context_entity.get_string_data(path, &self.matching_type).await |
| } |
| } |
| |
| impl AddModInfo { |
| pub fn new_raw( |
| component_url: &str, |
| story_name: Option<String>, |
| mod_name: Option<String>, |
| ) -> Self { |
| AddModInfo { |
| story_name: story_name.unwrap_or(Uuid::new_v4().to_string()), |
| mod_name: mod_name.unwrap_or(Uuid::new_v4().to_string()), |
| intent: Intent::new().with_handler(component_url), |
| } |
| } |
| |
| pub fn new_intent(intent: Intent) -> Self { |
| AddModInfo { |
| story_name: Uuid::new_v4().to_string(), |
| mod_name: Uuid::new_v4().to_string(), |
| intent: intent, |
| } |
| } |
| |
| pub fn new(intent: Intent, story_name: Option<String>, mod_name: Option<String>) -> Self { |
| AddModInfo { |
| story_name: story_name.unwrap_or(Uuid::new_v4().to_string()), |
| mod_name: mod_name.unwrap_or(Uuid::new_v4().to_string()), |
| intent: intent, |
| } |
| } |
| |
| pub fn story_name(&self) -> &str { |
| &self.story_name |
| } |
| |
| pub fn intent(&self) -> &Intent { |
| &self.intent |
| } |
| |
| pub fn mod_name(&self) -> &str { |
| &self.mod_name |
| } |
| |
| pub fn replace_reference_in_parameters(self, old: &str, new: &str) -> Self { |
| AddModInfo { |
| story_name: self.story_name, |
| mod_name: self.mod_name, |
| intent: self.intent.clone_with_new_reference(old, new), |
| } |
| } |
| } |
| |
| impl Intent { |
| pub fn new() -> Self { |
| Intent { handler: None, action: None, parameters: btreeset!() } |
| } |
| |
| pub fn parameters(&self) -> &BTreeSet<IntentParameter> { |
| &self.parameters |
| } |
| |
| pub fn with_handler(mut self, handler: &str) -> Self { |
| self.handler = Some(handler.to_string()); |
| self |
| } |
| |
| pub fn with_action(mut self, action: &str) -> Self { |
| self.action = Some(action.to_string()); |
| self |
| } |
| |
| pub fn add_parameter(mut self, name: &str, entity_reference: &str) -> Self { |
| self.parameters.insert(IntentParameter { |
| name: name.to_string(), |
| entity_reference: entity_reference.to_string(), |
| }); |
| self |
| } |
| |
| // Create a new intent which replaces old entity |
| // reference with new one. |
| pub fn clone_with_new_reference( |
| &self, |
| old_reference: &str, |
| new_reference: impl Into<String>, |
| ) -> Intent { |
| let reference = new_reference.into(); |
| Intent { |
| handler: self.handler.clone(), |
| action: self.action.clone(), |
| parameters: self |
| .parameters |
| .clone() |
| .into_iter() |
| .map(|p| { |
| if p.entity_reference == old_reference { |
| IntentParameter { name: p.name, entity_reference: reference.clone() } |
| } else { |
| p |
| } |
| }) |
| .collect::<BTreeSet<IntentParameter>>(), |
| } |
| } |
| } |
| |
| #[derive(Clone, Deserialize, Debug, Eq, PartialEq, Hash)] |
| pub struct FuchsiaFulfillment { |
| pub component_url: String, |
| } |
| |
| impl Into<FidlDisplayInfo> for DisplayInfo { |
| fn into(self) -> FidlDisplayInfo { |
| FidlDisplayInfo { title: self.title, subtitle: self.subtitle, icon: self.icon } |
| } |
| } |
| |
| impl Into<FidlSuggestion> for Suggestion { |
| fn into(self) -> FidlSuggestion { |
| FidlSuggestion { id: Some(self.id), display_info: Some(self.display_info.into()) } |
| } |
| } |
| |
| impl Into<Intent> for FidlIntent { |
| fn into(self) -> Intent { |
| Intent { |
| handler: self.handler, |
| action: self.action, |
| parameters: self |
| .parameters |
| .map(|params| { |
| params.into_iter().map(|p| p.into()).collect::<BTreeSet<IntentParameter>>() |
| }) |
| .unwrap_or(BTreeSet::new()), |
| } |
| } |
| } |
| |
| impl Into<FidlIntent> for Intent { |
| fn into(self) -> FidlIntent { |
| FidlIntent { |
| handler: self.handler, |
| action: self.action, |
| parameters: Some( |
| self.parameters.into_iter().map(|p| p.into()).collect::<Vec<FidlIntentParameter>>(), |
| ), |
| } |
| } |
| } |
| |
| impl Into<IntentParameter> for FidlIntentParameter { |
| fn into(self) -> IntentParameter { |
| IntentParameter { |
| name: self.name.unwrap_or("".to_string()), |
| entity_reference: match self.data { |
| FidlIntentParameterData::EntityReference(reference) => reference, |
| _ => "".to_string(), |
| }, |
| } |
| } |
| } |
| |
| impl Into<FidlIntentParameter> for IntentParameter { |
| fn into(self) -> FidlIntentParameter { |
| FidlIntentParameter { |
| name: Some(self.name), |
| data: FidlIntentParameterData::EntityReference(self.entity_reference), |
| } |
| } |
| } |
| |
| impl Into<FidlAddModInfo> for AddModInfo { |
| fn into(self) -> FidlAddModInfo { |
| FidlAddModInfo { |
| mod_name: vec![], |
| mod_name_transitional: Some(self.mod_name), |
| intent: self.intent.into(), |
| surface_parent_mod_name: None, |
| surface_relation: SurfaceRelation { |
| arrangement: SurfaceArrangement::None, |
| dependency: SurfaceDependency::None, |
| emphasis: 1.0, |
| }, |
| } |
| } |
| } |
| |
| #[cfg(test)] |
| mod tests { |
| use { |
| super::*, |
| crate::testing::{FakeEntityData, FakeEntityResolver}, |
| fidl_fuchsia_modular::{EntityMarker, EntityResolverMarker}, |
| fuchsia_async as fasync, |
| futures::future::join_all, |
| maplit::{hashmap, hashset}, |
| std::iter::FromIterator, |
| }; |
| |
| #[test] |
| fn test_from_assets() { |
| let data: Vec<Action> = |
| serde_json::from_str(include_str!("../test_data/test_actions.json")).unwrap(); |
| assert_eq!(data.len(), 5); |
| assert_eq!(data[0].name, "PLAY_MUSIC"); |
| assert_eq!(data[1].name, "SHOW_WEATHER"); |
| assert_eq!(data[2].name, "SHOW_DIRECTIONS"); |
| assert_eq!(data[3].name, "VIEW_COLLECTION"); |
| assert_eq!(data[4].name, "ACTION_MAIN"); |
| |
| let fulfillment = data[3].fuchsia_fulfillment.as_ref().unwrap(); |
| assert_eq!( |
| fulfillment.component_url, |
| "fuchsia-pkg://fuchsia.com/collections#meta/collections.cmx" |
| ); |
| } |
| |
| #[test] |
| fn display_info_into() { |
| let display_info = DisplayInfo { |
| title: Some("title".to_string()), |
| subtitle: Some("subtitle".to_string()), |
| icon: Some("http://example.com/icon.png".to_string()), |
| }; |
| let display_info_fidl: FidlDisplayInfo = display_info.clone().into(); |
| assert_eq!(display_info_fidl.title, display_info.title); |
| assert_eq!(display_info_fidl.subtitle, display_info.subtitle); |
| assert_eq!(display_info_fidl.icon, display_info.icon); |
| } |
| |
| #[test] |
| fn suggestion_into() { |
| let suggestion = Suggestion { |
| id: "123".to_string(), |
| display_info: DisplayInfo { |
| title: Some("suggestion title".to_string()), |
| icon: None, |
| subtitle: Some("suggestion subtitle".to_string()), |
| }, |
| action: SuggestedAction::AddMod(AddModInfo { |
| mod_name: "mod_name".to_string(), |
| intent: Intent { handler: None, action: None, parameters: btreeset!() }, |
| story_name: "story_name".to_string(), |
| }), |
| }; |
| |
| let suggestion_fidl: FidlSuggestion = suggestion.clone().into(); |
| assert_eq!(suggestion_fidl.id, Some(suggestion.id)); |
| let display_info_fidl = suggestion_fidl.display_info.unwrap(); |
| assert_eq!(display_info_fidl.title, suggestion.display_info.title); |
| assert_eq!(display_info_fidl.subtitle, suggestion.display_info.subtitle); |
| assert_eq!(display_info_fidl.icon, suggestion.display_info.icon); |
| } |
| |
| #[test] |
| fn add_mod_into() { |
| let intent = Intent { |
| handler: Some("handler".to_string()), |
| action: Some("action".to_string()), |
| parameters: btreeset!(IntentParameter { |
| name: "param_name".to_string(), |
| entity_reference: "ref".to_string(), |
| }), |
| }; |
| let add_mod = AddModInfo { |
| story_name: "story_name".to_string(), |
| mod_name: "mod_name".to_string(), |
| intent: intent, |
| }; |
| let add_mod_fidl: FidlAddModInfo = add_mod.clone().into(); |
| |
| assert_eq!(add_mod_fidl.mod_name_transitional, Some(add_mod.mod_name)); |
| assert_eq!(add_mod_fidl.intent.handler, add_mod.intent.handler); |
| assert_eq!(add_mod_fidl.intent.action, add_mod.intent.action); |
| assert!(add_mod_fidl |
| .intent |
| .parameters |
| .unwrap() |
| .into_iter() |
| .zip(add_mod.intent.parameters.into_iter()) |
| .all(|(param_fidl, param)| { |
| param_fidl.name.unwrap() == param.name |
| && param_fidl.data |
| == FidlIntentParameterData::EntityReference(param.entity_reference.clone()) |
| })); |
| } |
| |
| #[test] |
| fn replace_reference_in_parameters() { |
| let mut params = vec![ |
| IntentParameter { name: "param_name".to_string(), entity_reference: "ref".to_string() }, |
| IntentParameter { |
| name: "other_param".to_string(), |
| entity_reference: "some-other-ref".to_string(), |
| }, |
| IntentParameter { |
| name: "another_param".to_string(), |
| entity_reference: "ref".to_string(), |
| }, |
| ]; |
| let mut intent = Intent { |
| handler: Some("handler".to_string()), |
| action: Some("action".to_string()), |
| parameters: BTreeSet::from_iter(params.clone().into_iter()), |
| }; |
| let add_mod = AddModInfo { |
| story_name: "story_name".to_string(), |
| mod_name: "mod_name".to_string(), |
| intent: intent.clone(), |
| }; |
| let add_mod = add_mod.replace_reference_in_parameters("ref", "new-ref"); |
| params[0].entity_reference = "new-ref".to_string(); |
| params[2].entity_reference = "new-ref".to_string(); |
| intent.parameters = BTreeSet::from_iter(params.into_iter()); |
| assert_eq!(add_mod.intent, intent); |
| } |
| |
| #[fasync::run_singlethreaded(test)] |
| async fn load_display_info() { |
| let (entity_resolver, request_stream) = |
| fidl::endpoints::create_proxy_and_stream::<EntityResolverMarker>().unwrap(); |
| let mut fake_entity_resolver = FakeEntityResolver::new(); |
| fake_entity_resolver.register_entity( |
| "foo", |
| FakeEntityData::new( |
| vec!["foo-type".into()], |
| include_str!("../test_data/nested-field.json"), |
| ), |
| ); |
| fake_entity_resolver.register_entity( |
| "bar", |
| FakeEntityData::new( |
| vec!["bar-type".into()], |
| include_str!("../test_data/nested-field.json"), |
| ), |
| ); |
| fake_entity_resolver.spawn(request_stream); |
| |
| let futs = vec!["foo", "bar"].into_iter().map(|reference| { |
| let (entity_proxy, server_end) = |
| fidl::endpoints::create_proxy::<EntityMarker>().unwrap(); |
| entity_resolver.resolve_entity(reference, server_end).unwrap(); |
| ContextEntity::from_entity(entity_proxy, hashset!()) |
| }); |
| let entities = |
| join_all(futs).await.into_iter().map(|e| e.unwrap()).collect::<Vec<ContextEntity>>(); |
| let parameters = hashmap!( |
| "param1".to_string() => EntityMatching { |
| context_entity: &entities[0], |
| matching_type: "foo-type".to_string(), |
| }, |
| "param2".to_string() => EntityMatching { |
| context_entity: &entities[1], |
| matching_type: "bar-type".to_string(), |
| } |
| ); |
| |
| // Test successful case |
| let display_info = DisplayInfo::new() |
| .with_title("Hello {foo}") |
| .with_subtitle("Bye {bar}") |
| .with_icon("https://icon.com/{icon}"); |
| let parameter_mapping = vec![ |
| ParameterMapping { |
| name: "foo".to_string(), |
| parameter_property: "param1.a.b.c".to_string(), |
| }, |
| ParameterMapping { |
| name: "bar".to_string(), |
| parameter_property: "param2.x.y".to_string(), |
| }, |
| ParameterMapping { |
| name: "icon".to_string(), |
| parameter_property: "param1.a.b.c".to_string(), |
| }, |
| ]; |
| let action_display = ActionDisplayInfo { |
| display_info: Some(display_info.clone()), |
| parameter_mapping: parameter_mapping.clone(), |
| }; |
| assert_eq!( |
| action_display.load_display_info(parameters.clone()).await, |
| Some( |
| DisplayInfo::new() |
| .with_title("Hello 1") |
| .with_subtitle("Bye 2") |
| .with_icon("https://icon.com/1") |
| ) |
| ); |
| |
| // Without display info, nothing is returned. |
| let action_display = |
| ActionDisplayInfo { display_info: None, parameter_mapping: parameter_mapping.clone() }; |
| assert_eq!(action_display.load_display_info(parameters.clone()).await, None); |
| |
| // Without parameter mapping, the same display info is returned |
| let action_display = ActionDisplayInfo { |
| display_info: Some(display_info.clone()), |
| parameter_mapping: vec![], |
| }; |
| assert_eq!( |
| action_display.load_display_info(parameters.clone()).await, |
| Some(display_info.clone()) |
| ); |
| |
| // If some field is invalid or missing, it will just not be filled. |
| let parameter_mapping = vec![ |
| ParameterMapping { |
| name: "foo".to_string(), |
| parameter_property: "param1.a.b.c".to_string(), |
| }, |
| ParameterMapping { |
| name: "bar".to_string(), |
| parameter_property: "param2.a.x".to_string(), |
| }, |
| ]; |
| let action_display = ActionDisplayInfo { |
| display_info: Some(display_info.clone()), |
| parameter_mapping: parameter_mapping.clone(), |
| }; |
| assert_eq!( |
| action_display.load_display_info(parameters).await, |
| Some( |
| DisplayInfo::new() |
| .with_title("Hello 1") |
| .with_subtitle("Bye {bar}") |
| .with_icon("https://icon.com/{icon}") |
| ) |
| ); |
| } |
| } |