| // 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. |
| |
| use crate::id::Id; |
| use crate::services::discovery::filter::*; |
| use crate::{Result, SessionId}; |
| use anyhow::format_err; |
| use fidl::client::QueryResponseFut; |
| use fidl::endpoints::{ClientEnd, ServerEnd}; |
| use fidl_fuchsia_media::*; |
| use fidl_fuchsia_media_audio::*; |
| use fidl_fuchsia_media_sessions2::*; |
| use fidl_table_validation::*; |
| use fuchsia_inspect as inspect; |
| use futures::channel::oneshot; |
| use futures::future::BoxFuture; |
| use futures::stream::{FusedStream, FuturesUnordered}; |
| use futures::{Future, FutureExt, Stream, StreamExt}; |
| use std::convert::*; |
| use std::pin::Pin; |
| use std::sync::Arc; |
| use std::task::{Context, Poll, Waker}; |
| use tracing::info; |
| |
| const DEFAULT_PLAYER_USAGE: AudioRenderUsage = AudioRenderUsage::Media; |
| |
| #[derive(Debug, Clone, ValidFidlTable, PartialEq)] |
| #[fidl_table_src(PlayerRegistration)] |
| pub struct ValidPlayerRegistration { |
| pub domain: String, |
| #[fidl_field_with_default(DEFAULT_PLAYER_USAGE)] |
| pub usage: AudioRenderUsage, |
| } |
| |
| #[derive(Debug, Clone, ValidFidlTable, PartialEq)] |
| #[fidl_table_src(PlayerStatus)] |
| pub struct ValidPlayerStatus { |
| #[fidl_field_type(optional)] |
| pub duration: Option<i64>, |
| #[fidl_field_type(default = false)] |
| pub is_live: bool, |
| pub player_state: PlayerState, |
| #[fidl_field_type(optional)] |
| pub timeline_function: Option<TimelineFunction>, |
| pub repeat_mode: RepeatMode, |
| pub shuffle_on: bool, |
| pub content_type: ContentType, |
| #[fidl_field_type(optional)] |
| pub error: Option<Error>, |
| } |
| |
| #[derive(Debug, Clone, ValidFidlTable, PartialEq)] |
| #[fidl_table_src(PlayerCapabilities)] |
| pub struct ValidPlayerCapabilities { |
| pub flags: PlayerCapabilityFlags, |
| } |
| |
| #[derive(Debug, Clone, ValidFidlTable, PartialEq)] |
| #[fidl_table_src(MediaImage)] |
| #[fidl_table_validator(MediaImageValidator)] |
| pub struct ValidMediaImage { |
| pub image_type: MediaImageType, |
| pub sizes: Vec<ImageSizeVariant>, |
| } |
| |
| #[derive(Debug)] |
| pub enum MediaImageError { |
| NoSizesEnumerated, |
| } |
| |
| pub struct MediaImageValidator; |
| |
| impl Validate<ValidMediaImage> for MediaImageValidator { |
| type Error = MediaImageError; |
| fn validate(candidate: &ValidMediaImage) -> std::result::Result<(), Self::Error> { |
| if candidate.sizes.len() == 0 { |
| return Err(MediaImageError::NoSizesEnumerated); |
| } |
| |
| Ok(()) |
| } |
| } |
| |
| #[derive(Default, Debug, Clone, ValidFidlTable, PartialEq)] |
| #[fidl_table_src(PlayerInfoDelta)] |
| pub struct ValidPlayerInfoDelta { |
| #[fidl_field_type(optional)] |
| pub local: Option<bool>, |
| #[fidl_field_type(optional)] |
| pub player_status: Option<ValidPlayerStatus>, |
| #[fidl_field_type(optional)] |
| pub metadata: Option<Metadata>, |
| #[fidl_field_type(optional)] |
| pub media_images: Option<Vec<ValidMediaImage>>, |
| #[fidl_field_type(optional)] |
| pub player_capabilities: Option<ValidPlayerCapabilities>, |
| #[fidl_field_type(optional)] |
| pub interruption_behavior: Option<InterruptionBehavior>, |
| } |
| |
| impl ValidPlayerInfoDelta { |
| /// Update this delta with a newer delta, overwriting now-out-of-date information. |
| pub fn apply(self, delta: ValidPlayerInfoDelta) -> Self { |
| Self { |
| local: delta.local.or(self.local), |
| player_status: delta.player_status.or(self.player_status), |
| metadata: delta.metadata.or(self.metadata), |
| media_images: delta.media_images.or(self.media_images), |
| player_capabilities: delta.player_capabilities.or(self.player_capabilities), |
| interruption_behavior: delta.interruption_behavior.or(self.interruption_behavior), |
| } |
| } |
| |
| /// Returns whether the state represented by this delta is active. |
| pub fn is_active(&self) -> Option<bool> { |
| self.player_status |
| .as_ref() |
| .map(|status| status.player_state) |
| .map(|state| state == PlayerState::Playing) |
| } |
| } |
| |
| /// A control request which can be forwarded to the proxied client. |
| pub enum ForwardControlRequest { |
| Play, |
| Pause, |
| Stop, |
| Seek(i64), |
| SkipForward, |
| SkipReverse, |
| NextItem, |
| PrevItem, |
| SetPlaybackRate(f32), |
| SetRepeatMode(RepeatMode), |
| SetShuffleMode { shuffle_on: bool }, |
| BindVolumeControl(ServerEnd<VolumeControlMarker>), |
| } |
| |
| impl TryFrom<SessionControlRequest> for ForwardControlRequest { |
| type Error = SessionControlWatchStatusResponder; |
| fn try_from(src: SessionControlRequest) -> std::result::Result<Self, Self::Error> { |
| match src { |
| SessionControlRequest::Play { .. } => Ok(ForwardControlRequest::Play), |
| SessionControlRequest::Pause { .. } => Ok(ForwardControlRequest::Pause), |
| SessionControlRequest::Stop { .. } => Ok(ForwardControlRequest::Stop), |
| SessionControlRequest::Seek { position, .. } => { |
| Ok(ForwardControlRequest::Seek(position)) |
| } |
| SessionControlRequest::SkipForward { .. } => Ok(ForwardControlRequest::SkipForward), |
| SessionControlRequest::SkipReverse { .. } => Ok(ForwardControlRequest::SkipReverse), |
| SessionControlRequest::NextItem { .. } => Ok(ForwardControlRequest::NextItem), |
| SessionControlRequest::PrevItem { .. } => Ok(ForwardControlRequest::PrevItem), |
| SessionControlRequest::SetPlaybackRate { playback_rate, .. } => { |
| Ok(ForwardControlRequest::SetPlaybackRate(playback_rate)) |
| } |
| SessionControlRequest::SetRepeatMode { repeat_mode, .. } => { |
| Ok(ForwardControlRequest::SetRepeatMode(repeat_mode)) |
| } |
| SessionControlRequest::SetShuffleMode { shuffle_on, .. } => { |
| Ok(ForwardControlRequest::SetShuffleMode { shuffle_on }) |
| } |
| SessionControlRequest::BindVolumeControl { volume_control_request, .. } => { |
| Ok(ForwardControlRequest::BindVolumeControl(volume_control_request)) |
| } |
| SessionControlRequest::WatchStatus { responder } => Err(responder), |
| } |
| } |
| } |
| |
| #[derive(Clone)] |
| pub enum PlayerProxyEvent { |
| Updated(Arc<dyn Fn() -> SessionInfoDelta + Send + Sync>), |
| Removed, |
| } |
| |
| impl std::fmt::Debug for PlayerProxyEvent { |
| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> { |
| match self { |
| PlayerProxyEvent::Updated(delta) => { |
| write!(f, "PlayerProxyEvent::Updated({:?})", delta()) |
| } |
| PlayerProxyEvent::Removed => write!(f, "PlayerProxyEvent::Removed"), |
| } |
| } |
| } |
| |
| /// A proxy for published `fuchsia.media.sessions2.Player` protocols. |
| #[derive(Debug)] |
| pub struct Player { |
| id: Id, |
| inner: PlayerProxy, |
| state: ValidPlayerInfoDelta, |
| registration: ValidPlayerRegistration, |
| hanging_get: Option<QueryResponseFut<PlayerInfoDelta>>, |
| terminated: bool, |
| // TODO(https://fxbug.dev/42117181): Use structured data when a proc macro to derive |
| // Inspect support is available. |
| inspect_handle: inspect::Node, |
| inspect_state: inspect::Node, |
| proxy_tasks: FuturesUnordered<BoxFuture<'static, ()>>, |
| waker: Option<Waker>, |
| published_sender: Option<oneshot::Sender<()>>, |
| } |
| |
| impl Player { |
| pub fn new( |
| id: Id, |
| client_end: ClientEnd<PlayerMarker>, |
| registration: PlayerRegistration, |
| inspect_handle: inspect::Node, |
| published_sender: oneshot::Sender<()>, |
| ) -> Result<Self> { |
| let inspect_state = inspect_handle.create_child("state"); |
| Ok(Player { |
| id, |
| inner: client_end.into_proxy()?, |
| state: ValidPlayerInfoDelta::default(), |
| registration: ValidPlayerRegistration::try_from(registration)?, |
| hanging_get: None, |
| terminated: false, |
| inspect_handle, |
| inspect_state, |
| proxy_tasks: FuturesUnordered::new(), |
| waker: None, |
| published_sender: Some(published_sender), |
| }) |
| } |
| |
| pub fn id(&self) -> SessionId { |
| self.id.get() |
| } |
| |
| /// Notify any listeners on the other side of the `published_sender` oneshot channel that it's |
| /// safe to let external clients know about this player's id. |
| pub fn notify_published(&mut self) -> Result<()> { |
| if let Some(sender) = self.published_sender.take() { |
| sender.send(()).map_err(|_| format_err!("Failed to notify")) |
| } else { |
| Err(format_err!("Already notified")) |
| } |
| } |
| |
| /// Returns whether the player is in an active state. |
| pub fn is_active(&self) -> bool { |
| self.state.is_active().unwrap_or(false) |
| } |
| |
| /// Returns the usage which, when interrupted, should cause the player |
| /// to pause until the interruption is over. |
| pub fn usage_to_pause_on_interruption(&self) -> Option<AudioRenderUsage> { |
| self.state |
| .interruption_behavior |
| .as_ref() |
| .copied() |
| .filter(|behavior| *behavior == InterruptionBehavior::Pause) |
| .map(|_| self.registration.usage) |
| } |
| |
| /// Sends a pause command to the backing player. |
| pub fn pause(&self) -> Result<()> { |
| Ok(self.inner.pause()?) |
| } |
| |
| /// Sends a play command to the backing player. |
| pub fn play(&self) -> Result<()> { |
| Ok(self.inner.play()?) |
| } |
| |
| /// Updates state with the latest delta published by the player. |
| pub fn update(&mut self, delta: ValidPlayerInfoDelta) { |
| self.state = ValidPlayerInfoDelta::apply(self.state.clone(), delta); |
| self.update_inspect(); |
| } |
| |
| /// Updates inspect tree from current state of player |
| fn update_inspect(&mut self) { |
| let inspect_state = self.inspect_handle.create_child("state"); |
| if let Some(local) = self.state.local { |
| inspect_state.record_bool("local", local); |
| } |
| |
| if let Some(player_status) = &self.state.player_status { |
| if let Some(duration) = player_status.duration { |
| inspect_state.record_int("duration", duration); |
| } |
| inspect_state.record_bool("is_live", player_status.is_live); |
| inspect_state |
| .record_string("player_state", format!("{:?}", player_status.player_state)); |
| if let Some(timeline_function) = &player_status.timeline_function { |
| inspect_state |
| .record_string("timeline_function", format!("{:?}", timeline_function)); |
| } |
| inspect_state.record_string("repeat_mode", format!("{:?}", player_status.repeat_mode)); |
| inspect_state.record_bool("shuffle_on", player_status.shuffle_on); |
| inspect_state |
| .record_string("content_type", format!("{:?}", player_status.content_type)); |
| if let Some(error) = player_status.error { |
| inspect_state.record_string("error", format!("{:?}", error)); |
| } |
| } |
| |
| if let Some(metadata) = &self.state.metadata { |
| inspect_state.record_string("metadata", format!("{:#?}", metadata)); |
| } |
| |
| if let Some(media_images) = &self.state.media_images { |
| inspect_state.record_string("media_images", format!("{:#?}", media_images)); |
| } |
| |
| if let Some(player_capabilities) = &self.state.player_capabilities { |
| inspect_state |
| .record_string("player_capabilities", format!("{:?}", player_capabilities)); |
| } |
| |
| if let Some(interruption_behavior) = &self.state.interruption_behavior { |
| inspect_state |
| .record_string("interruption_behavior", format!("{:?}", interruption_behavior)); |
| } |
| |
| self.inspect_state = inspect_state; |
| } |
| |
| /// Adds a task to the player proxy which depends on a connection to the |
| /// backing player. The player stream will drive the task until the backing |
| /// player disconnects, at which point the task will be dropped. |
| pub fn add_proxy_task(&mut self, proxy_task: BoxFuture<'static, ()>) { |
| self.proxy_tasks.push(proxy_task); |
| self.waker.iter().for_each(Waker::wake_by_ref); |
| } |
| |
| /// Spawns a task to serve requests from `fuchsia.media.sessions2.SessionControl` with the |
| /// backing `fuchsia.media.sessions2.Player` protocol. |
| /// |
| /// Drops the `SessionControl` channel if the backing player is disconnected at the time of a |
| /// request, or if `disconnect_proxied_clients` is called. |
| pub fn serve_controls( |
| &mut self, |
| mut requests: impl Stream<Item = ForwardControlRequest> + Unpin + Send + 'static, |
| ) { |
| let proxy = self.inner.clone(); |
| |
| let control_task = async move { |
| while let Some(request) = requests.next().await { |
| let r: fidl::Result<()> = match request { |
| ForwardControlRequest::Play => proxy.play(), |
| ForwardControlRequest::Pause => proxy.pause(), |
| ForwardControlRequest::Stop => proxy.stop(), |
| ForwardControlRequest::Seek(position) => proxy.seek(position), |
| ForwardControlRequest::SkipForward => proxy.skip_forward(), |
| ForwardControlRequest::SkipReverse => proxy.skip_reverse(), |
| ForwardControlRequest::NextItem => proxy.next_item(), |
| ForwardControlRequest::PrevItem => proxy.prev_item(), |
| ForwardControlRequest::SetPlaybackRate(playback_rate) => { |
| proxy.set_playback_rate(playback_rate) |
| } |
| ForwardControlRequest::SetRepeatMode(repeat_mode) => { |
| proxy.set_repeat_mode(repeat_mode) |
| } |
| ForwardControlRequest::SetShuffleMode { shuffle_on } => { |
| proxy.set_shuffle_mode(shuffle_on) |
| } |
| ForwardControlRequest::BindVolumeControl(volume_control_request) => { |
| proxy.bind_volume_control(volume_control_request) |
| } |
| }; |
| |
| match r { |
| Err(_) => return, |
| Ok(_) => {} |
| } |
| } |
| }; |
| |
| self.proxy_tasks.push(control_task.map(drop).boxed()); |
| self.waker.iter().for_each(Waker::wake_by_ref); |
| } |
| |
| fn session_info_delta(&self) -> impl Fn() -> SessionInfoDelta { |
| let state = self.state.clone(); |
| let registration = self.registration.clone(); |
| let local = state.local.unwrap_or(true); |
| move || SessionInfoDelta { |
| is_locally_active: state.is_active().map(|active| active && local), |
| domain: Some(registration.domain.clone()), |
| is_local: Some(local), |
| player_status: state.player_status.clone().map(Into::into), |
| metadata: state.metadata.clone(), |
| player_capabilities: state.player_capabilities.clone().map(Into::into), |
| media_images: state.media_images.clone().map(|media_images| { |
| media_images |
| .into_iter() |
| .map(|media_image| MediaImage { |
| image_type: Some(media_image.image_type), |
| sizes: Some(media_image.sizes), |
| ..Default::default() |
| }) |
| .collect() |
| }), |
| ..Default::default() |
| } |
| } |
| |
| fn options_satisfied(&self) -> WatchOptions { |
| WatchOptions { |
| only_active: Some(self.state.is_active().unwrap_or(false)), |
| allowed_sessions: Some(vec![self.id.get()]), |
| ..Default::default() |
| } |
| } |
| } |
| |
| /// The Stream implementation for Player is a stream of full player states. A new state is emitted |
| /// when the backing player implementation sends us an update. |
| impl Stream for Player { |
| type Item = FilterApplicant<(SessionId, PlayerProxyEvent)>; |
| |
| fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> { |
| if self.terminated { |
| return Poll::Ready(None); |
| } |
| |
| // Send any queued messages to the player. |
| self.waker = Some(cx.waker().clone()); |
| let _ = Pin::new(&mut self.proxy_tasks).poll_next(cx); |
| |
| let proxy = self.inner.clone(); |
| let hanging_get = self.hanging_get.get_or_insert_with(move || proxy.watch_info_change()); |
| |
| match Pin::new(hanging_get).poll(cx) { |
| Poll::Pending => Poll::Pending, |
| Poll::Ready(r) => { |
| let event = match r { |
| Err(e) => match e { |
| fidl::Error::ClientRead(_) | fidl::Error::ClientWrite(_) => { |
| PlayerProxyEvent::Removed |
| } |
| e => { |
| info!(tag = "player_proxy", "Player update failed: {:#?}", e); |
| PlayerProxyEvent::Removed |
| } |
| }, |
| Ok(delta) => match ValidPlayerInfoDelta::try_from(delta) { |
| Ok(delta) => { |
| self.update(delta); |
| self.hanging_get = None; |
| PlayerProxyEvent::Updated(Arc::new(self.session_info_delta())) |
| } |
| Err(e) => { |
| info!(tag = "player_proxy", "Player sent invalid update: {:#?}", e); |
| PlayerProxyEvent::Removed |
| } |
| }, |
| }; |
| if let PlayerProxyEvent::Removed = event { |
| self.terminated = true; |
| } |
| Poll::Ready(Some(FilterApplicant::new( |
| self.options_satisfied(), |
| (self.id.get(), event), |
| ))) |
| } |
| } |
| } |
| } |
| |
| impl FusedStream for Player { |
| fn is_terminated(&self) -> bool { |
| self.terminated |
| } |
| } |
| |
| #[cfg(test)] |
| mod test { |
| use super::*; |
| use assert_matches::assert_matches; |
| use diagnostics_assertions::assert_data_tree; |
| use fidl::endpoints::{create_endpoints, create_proxy_and_stream}; |
| use fidl::prelude::*; |
| use futures::future; |
| use futures::stream::TryStreamExt; |
| use futures_test::task::noop_waker; |
| use inspect::Inspector; |
| |
| static TEST_DOMAIN: &str = "test_domain"; |
| |
| fn test_player() -> (Inspector, Player, ServerEnd<PlayerMarker>) { |
| let (player_client, player_server) = create_endpoints::<PlayerMarker>(); |
| let inspector = Inspector::default(); |
| let (player_published_sink, _player_published_receiver) = oneshot::channel(); |
| let player = Player::new( |
| Id::new().expect("Creating id for test player"), |
| player_client, |
| PlayerRegistration { domain: Some(TEST_DOMAIN.to_string()), ..Default::default() }, |
| inspector.root().create_child("test_player"), |
| player_published_sink, |
| ) |
| .expect("Creating player from valid prereqs"); |
| (inspector, player, player_server) |
| } |
| |
| #[fuchsia::test] |
| async fn client_channel_closes_when_backing_player_disconnects() -> Result<()> { |
| let (_inspector, mut player, _player_server) = test_player(); |
| |
| let (session_control_fidl_proxy, session_control_request_stream) = |
| create_proxy_and_stream::<SessionControlMarker>()?; |
| let control_request_stream = session_control_request_stream |
| .filter_map(|r| future::ready(r.ok())) |
| .map(ForwardControlRequest::try_from) |
| .filter_map(|r| future::ready(r.ok())); |
| |
| player.serve_controls(control_request_stream); |
| |
| assert!(session_control_fidl_proxy.play().is_ok()); |
| |
| drop(player); |
| |
| assert!(session_control_fidl_proxy.is_closed()); |
| |
| Ok(()) |
| } |
| |
| #[fuchsia::test] |
| async fn update_stream_relays_player_state() -> Result<()> { |
| let (_inspector, mut player, player_server) = test_player(); |
| let mut requests = player_server.into_stream()?; |
| |
| let waker = noop_waker(); |
| let mut ctx = Context::from_waker(&waker); |
| |
| // Poll the stream so that it sends a watch request to the backing player. |
| let poll_result = Pin::new(&mut player).poll_next(&mut ctx); |
| assert_matches!(poll_result, Poll::Pending); |
| |
| let info_change_responder = requests |
| .try_next() |
| .await? |
| .expect("Receiving a request") |
| .into_watch_info_change() |
| .expect("Receiving info change responder"); |
| info_change_responder.send(&PlayerInfoDelta::default())?; |
| |
| let mut player_stream = Pin::new(&mut player); |
| let (_, event) = player_stream.next().await.expect("Polling player event").applicant; |
| let delta = match event { |
| PlayerProxyEvent::Updated(f) => f(), |
| _ => panic!("Expected update from player proxy"), |
| }; |
| |
| assert_eq!( |
| delta, |
| SessionInfoDelta { |
| is_local: Some(true), |
| domain: Some(TEST_DOMAIN.to_string()), |
| ..Default::default() |
| } |
| ); |
| assert!(!player_stream.is_terminated()); |
| |
| Ok(()) |
| } |
| |
| #[fuchsia::test] |
| async fn update_stream_terminates_when_backing_player_disconnects() { |
| let (_inspector, mut player, _) = test_player(); |
| let mut player_stream = Pin::new(&mut player); |
| let (_, event) = player_stream.next().await.expect("Polling player event").applicant; |
| assert_matches!(event, PlayerProxyEvent::Removed); |
| assert!(player_stream.is_terminated()); |
| } |
| |
| #[fuchsia::test] |
| async fn update_stream_terminates_on_invalid_delta() -> Result<()> { |
| let (_inspector, mut player, player_server) = test_player(); |
| let mut requests = player_server.into_stream()?; |
| |
| let waker = noop_waker(); |
| let mut ctx = Context::from_waker(&waker); |
| |
| // Poll the stream so that it sends a watch request to the backing player. |
| let poll_result = Pin::new(&mut player).poll_next(&mut ctx); |
| assert_matches!(poll_result, Poll::Pending); |
| |
| let info_change_responder = requests |
| .try_next() |
| .await? |
| .expect("Receiving a request") |
| .into_watch_info_change() |
| .expect("Receiving info change responder"); |
| info_change_responder.send(&PlayerInfoDelta { |
| player_capabilities: Some(PlayerCapabilities::default()), |
| ..Default::default() |
| })?; |
| |
| let mut player_stream = Pin::new(&mut player); |
| let (_, event) = player_stream.next().await.expect("Polling player event").applicant; |
| assert_matches!(event, PlayerProxyEvent::Removed); |
| assert!(player_stream.is_terminated()); |
| |
| Ok(()) |
| } |
| |
| #[fuchsia::test] |
| async fn inspect_node() -> Result<()> { |
| let (inspector, mut player, player_server) = test_player(); |
| let mut requests = player_server.into_stream()?; |
| |
| assert_data_tree!(inspector, root: { |
| test_player: {state: {}}, |
| }); |
| |
| let waker = noop_waker(); |
| let mut ctx = Context::from_waker(&waker); |
| |
| let delta = PlayerInfoDelta { |
| player_capabilities: Some(PlayerCapabilities { |
| flags: Some(PlayerCapabilityFlags::PLAY | PlayerCapabilityFlags::PAUSE), |
| ..Default::default() |
| }), |
| ..Default::default() |
| }; |
| |
| // Poll the stream so that it sends a watch request to the backing player. |
| let poll_result = Pin::new(&mut player).poll_next(&mut ctx); |
| assert_matches!(poll_result, Poll::Pending); |
| |
| let info_change_responder = requests |
| .try_next() |
| .await? |
| .expect("Receiving a request") |
| .into_watch_info_change() |
| .expect("Receiving info change responder"); |
| info_change_responder.send(&delta)?; |
| |
| let _ = player.next().await.expect("Polling player event"); |
| |
| assert_data_tree!(inspector, root: { |
| test_player: {state: { player_capabilities: "ValidPlayerCapabilities { flags: PlayerCapabilityFlags(PLAY | PAUSE) }"}} |
| }); |
| |
| Ok(()) |
| } |
| } |