| // Copyright 2022 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "ash/system/video_conference/video_conference_tray.h" |
| |
| #include <memory> |
| #include <string> |
| |
| #include "ash/public/cpp/shelf_types.h" |
| #include "ash/resources/vector_icons/vector_icons.h" |
| #include "ash/session/session_controller_impl.h" |
| #include "ash/shelf/shelf.h" |
| #include "ash/shell.h" |
| #include "ash/strings/grit/ash_strings.h" |
| #include "ash/style/ash_color_id.h" |
| #include "ash/style/icon_button.h" |
| #include "ash/system/privacy/screen_security_controller.h" |
| #include "ash/system/system_notification_controller.h" |
| #include "ash/system/tray/tray_background_view.h" |
| #include "ash/system/tray/tray_bubble_view.h" |
| #include "ash/system/tray/tray_bubble_wrapper.h" |
| #include "ash/system/tray/tray_constants.h" |
| #include "ash/system/tray/tray_container.h" |
| #include "ash/system/tray/tray_utils.h" |
| #include "ash/system/video_conference/bubble/bubble_view.h" |
| #include "ash/system/video_conference/bubble/linux_apps_bubble_view.h" |
| #include "ash/system/video_conference/video_conference_tray_controller.h" |
| #include "base/functional/bind.h" |
| #include "base/functional/callback_helpers.h" |
| #include "base/memory/raw_ptr.h" |
| #include "base/metrics/histogram_functions.h" |
| #include "chromeos/crosapi/mojom/video_conference.mojom.h" |
| #include "components/session_manager/session_manager_types.h" |
| #include "ui/base/l10n/l10n_util.h" |
| #include "ui/base/metadata/metadata_header_macros.h" |
| #include "ui/base/metadata/metadata_impl_macros.h" |
| #include "ui/chromeos/styles/cros_tokens_color_mappings.h" |
| #include "ui/color/color_id.h" |
| #include "ui/events/event.h" |
| #include "ui/gfx/canvas.h" |
| #include "ui/gfx/image/canvas_image_source.h" |
| #include "ui/gfx/image/image_skia.h" |
| #include "ui/gfx/image/image_skia_operations.h" |
| #include "ui/gfx/scoped_canvas.h" |
| #include "ui/views/controls/button/button_controller.h" |
| #include "ui/views/controls/highlight_path_generator.h" |
| #include "ui/views/view_utils.h" |
| |
| namespace ash { |
| |
| namespace { |
| |
| constexpr float kTrayButtonsSpacing = 4; |
| constexpr float kPrivacyIndicatorRadius = 3; |
| |
| // The offset value from the bottom right corner of the icon to the place where |
| // we actually want to draw the privacy indicator. |
| constexpr float kPrivacyIndicatorOffset = 2; |
| |
| // Histogram names |
| constexpr char kToggleButtonHistogramName[] = |
| "Ash.VideoConferenceTray.ToggleBubbleButton.Click"; |
| constexpr char kCameraMuteHistogramName[] = |
| "Ash.VideoConferenceTray.CameraMuteButton.Click"; |
| constexpr char kMicrophoneMuteHistogramName[] = |
| "Ash.VideoConferenceTray.MicrophoneMuteButton.Click"; |
| constexpr char kStopScreenShareHistogramName[] = |
| "Ash.VideoConferenceTray.StopScreenShareButton.Click"; |
| |
| // Check if there's a non-linux app(s) from the given `apps`. |
| bool HasNonLinuxMediaApps(const MediaApps& apps) { |
| for (auto& app : apps) { |
| if (app->app_type != crosapi::mojom::VideoConferenceAppType::kCrostiniVm && |
| app->app_type != crosapi::mojom::VideoConferenceAppType::kPluginVm && |
| app->app_type != crosapi::mojom::VideoConferenceAppType::kBorealis) { |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| // A customized toggle button for the VC tray's toggle bubble button. |
| class ToggleBubbleButton : public IconButton { |
| METADATA_HEADER(ToggleBubbleButton, IconButton) |
| |
| public: |
| ToggleBubbleButton(VideoConferenceTray* tray, PressedCallback callback) |
| : IconButton(std::move(callback), |
| IconButton::Type::kMediumFloating, |
| &kVideoConferenceUpChevronIcon, |
| IDS_ASH_VIDEO_CONFERENCE_TOGGLE_BUBBLE_BUTTON_TOOLTIP, |
| /*is_togglable=*/true, |
| /*has_border=*/true), |
| tray_(tray) { |
| SetButtonController(std::make_unique<views::ButtonController>( |
| /*views::Button*=*/this, |
| std::make_unique<TrayBackgroundView::TrayButtonControllerDelegate>( |
| /*views::Button*=*/this, |
| TrayBackgroundViewCatalogName::kVideoConferenceTray))); |
| // Reduce the focus ring padding which is installed by default by |
| // `IconButton`. The default padding results in the focus ring being painted |
| // outside of the available bounds. |
| auto* focus_ring = views::FocusRing::Get(this); |
| focus_ring->SetPathGenerator( |
| std::make_unique<views::CircleHighlightPathGenerator>( |
| -gfx::Insets(focus_ring->GetHaloThickness() / 2))); |
| } |
| ToggleBubbleButton(const ToggleBubbleButton&) = delete; |
| ToggleBubbleButton& operator=(const ToggleBubbleButton&) = delete; |
| ~ToggleBubbleButton() override = default; |
| |
| // IconButton: |
| void PaintButtonContents(gfx::Canvas* canvas) override { |
| // Rotate the canvas to rotate the expand indicator according to toggle |
| // state and shelf alignment. Note that when shelf alignment changes, |
| // TrayBackgroundView::UpdateLayout() will be triggered and this button will |
| // be automatically repainted, so we don't need to manually handle it. |
| gfx::ScopedCanvas scoped(canvas); |
| canvas->Translate(gfx::Vector2d(size().width() / 2, size().height() / 2)); |
| canvas->sk_canvas()->rotate(tray_->GetRotationValueForToggleBubbleButton()); |
| gfx::ImageSkia image = GetImageToPaint(); |
| canvas->DrawImageInt(image, -image.width() / 2, -image.height() / 2); |
| } |
| |
| private: |
| // Parent view of this button. Owned by the views hierarchy. |
| const raw_ptr<VideoConferenceTray> tray_; |
| }; |
| |
| BEGIN_METADATA(ToggleBubbleButton) |
| END_METADATA |
| |
| } // namespace |
| |
| VideoConferenceTrayButton::VideoConferenceTrayButton( |
| PressedCallback callback, |
| const gfx::VectorIcon* icon, |
| const gfx::VectorIcon* toggled_icon, |
| const gfx::VectorIcon* capturing_icon, |
| const int accessible_name_id) |
| : IconButton(std::move(callback), |
| IconButton::Type::kMedium, |
| icon, |
| accessible_name_id, |
| /*is_togglable=*/true, |
| /*has_border=*/true), |
| accessible_name_id_(accessible_name_id), |
| icon_(icon), |
| capturing_icon_(capturing_icon) { |
| SetButtonController(std::make_unique<views::ButtonController>( |
| /*views::Button*=*/this, |
| std::make_unique<TrayBackgroundView::TrayButtonControllerDelegate>( |
| /*views::Button*=*/this, |
| TrayBackgroundViewCatalogName::kVideoConferenceTray))); |
| |
| SetBackgroundToggledColor(cros_tokens::kCrosSysSystemNegativeContainer); |
| SetIconToggledColor(cros_tokens::kCrosSysSystemOnNegativeContainer); |
| |
| SetBackgroundColor(cros_tokens::kCrosSysSystemOnBase1); |
| |
| SetToggledVectorIcon(*toggled_icon); |
| |
| SetAccessibleRole(ax::mojom::Role::kToggleButton); |
| |
| // Reduce the focus ring padding which is installed by default by |
| // `IconButton`. The default padding results in the focus ring being painted |
| // outside of the available bounds. |
| auto* focus_ring = views::FocusRing::Get(this); |
| focus_ring->SetPathGenerator( |
| std::make_unique<views::CircleHighlightPathGenerator>( |
| -gfx::Insets(focus_ring->GetHaloThickness() / 2))); |
| |
| UpdateTooltip(); |
| } |
| |
| VideoConferenceTrayButton::~VideoConferenceTrayButton() = default; |
| |
| void VideoConferenceTrayButton::SetIsCapturing(bool is_capturing) { |
| if (is_capturing_ == is_capturing) { |
| return; |
| } |
| |
| is_capturing_ = is_capturing; |
| |
| SetVectorIcon(is_capturing_ ? *capturing_icon_ : *icon_); |
| UpdateCapturingState(); |
| } |
| |
| void VideoConferenceTrayButton::UpdateCapturingState() { |
| // We should only show the privacy indicator when the button is not |
| // muted/untoggled. |
| const bool show_privacy_indicator = is_capturing_ && !toggled(); |
| |
| // Always call `UpdateTooltip()` because even if `show_privacy_indicator_` |
| // doesn't change, `is_capturing_` may have. |
| base::ScopedClosureRunner scoped_closure(base::BindOnce( |
| &VideoConferenceTrayButton::UpdateTooltip, base::Unretained(this))); |
| |
| if (show_privacy_indicator_ == show_privacy_indicator) { |
| return; |
| } |
| |
| show_privacy_indicator_ = show_privacy_indicator; |
| SchedulePaint(); |
| } |
| |
| void VideoConferenceTrayButton::PaintButtonContents(gfx::Canvas* canvas) { |
| IconButton::PaintButtonContents(canvas); |
| |
| if (!show_privacy_indicator_) { |
| return; |
| } |
| |
| const gfx::RectF bounds(GetContentsBounds()); |
| auto image = GetImageToPaint(); |
| auto indicator_origin_x = (bounds.width() - image.width()) / 2 + |
| image.width() - kPrivacyIndicatorRadius; |
| auto indicator_origin_y = (bounds.height() - image.height()) / 2 + |
| image.height() - kPrivacyIndicatorRadius; |
| |
| // Draw the green dot privacy indicator. |
| cc::PaintFlags flags; |
| flags.setStyle(cc::PaintFlags::kFill_Style); |
| flags.setAntiAlias(true); |
| flags.setColor( |
| GetColorProvider()->GetColor(ui::kColorAshPrivacyIndicatorsBackground)); |
| canvas->DrawCircle(gfx::PointF(indicator_origin_x - kPrivacyIndicatorOffset, |
| indicator_origin_y), |
| kPrivacyIndicatorRadius, flags); |
| } |
| |
| void VideoConferenceTrayButton::UpdateTooltip() { |
| int capture_state_id = VIDEO_CONFERENCE_TOGGLE_BUTTON_STATE_OFF; |
| if (show_privacy_indicator_) { |
| capture_state_id = VIDEO_CONFERENCE_TOGGLE_BUTTON_STATE_ON_AND_IN_USE; |
| } else if (!toggled()) { |
| capture_state_id = VIDEO_CONFERENCE_TOGGLE_BUTTON_STATE_ON; |
| } |
| |
| int base_string_id = VIDEO_CONFERENCE_TOGGLE_BUTTON_TOOLTIP; |
| if (toggle_is_one_way_) { |
| base_string_id = VIDEO_CONFERENCE_ONE_WAY_TOGGLE_BUTTON_TOOLTIP; |
| } |
| |
| SetTooltipText(l10n_util::GetStringFUTF16( |
| base_string_id, l10n_util::GetStringUTF16(accessible_name_id_), |
| l10n_util::GetStringUTF16(capture_state_id))); |
| } |
| |
| BEGIN_METADATA(VideoConferenceTrayButton) |
| END_METADATA |
| |
| VideoConferenceTray::VideoConferenceTray(Shelf* shelf) |
| : TrayBackgroundView(shelf, |
| TrayBackgroundViewCatalogName::kVideoConferenceTray) { |
| SetCallback(base::BindRepeating(&VideoConferenceTray::ToggleBubble, |
| weak_ptr_factory_.GetWeakPtr())); |
| |
| tray_container()->SetSpacingBetweenChildren(kTrayButtonsSpacing); |
| |
| audio_icon_ = tray_container()->AddChildView(std::make_unique< |
| VideoConferenceTrayButton>( |
| base::BindRepeating(&VideoConferenceTray::OnAudioButtonClicked, |
| weak_ptr_factory_.GetWeakPtr()), |
| /*icon=*/&kPrivacyIndicatorsMicrophoneIcon, |
| /*toggled_icon=*/&kVideoConferenceMicrophoneMutedIcon, |
| /*capturing_icon=*/&kVideoConferenceMicrophoneCapturingIcon, |
| /*accessible_name_id=*/VIDEO_CONFERENCE_TOGGLE_BUTTON_TYPE_MICROPHONE)); |
| audio_icon_->SetVisible(false); |
| |
| camera_icon_ = tray_container()->AddChildView( |
| std::make_unique<VideoConferenceTrayButton>( |
| base::BindRepeating(&VideoConferenceTray::OnCameraButtonClicked, |
| weak_ptr_factory_.GetWeakPtr()), |
| &kPrivacyIndicatorsCameraIcon, &kVideoConferenceCameraMutedIcon, |
| &kVideoConferenceCameraCapturingIcon, |
| VIDEO_CONFERENCE_TOGGLE_BUTTON_TYPE_CAMERA)); |
| camera_icon_->SetVisible(false); |
| |
| const bool allow_stop_screen_share = |
| base::FeatureList::IsEnabled(features::kVcStopAllScreenShare); |
| |
| if (allow_stop_screen_share) { |
| screen_share_icon_ = tray_container()->AddChildView( |
| std::make_unique<VideoConferenceTrayButton>( |
| base::BindRepeating( |
| &VideoConferenceTray::OnScreenShareButtonClicked, |
| weak_ptr_factory_.GetWeakPtr()), |
| &kVideoConferenceScreenShareIcon, &kVideoConferenceScreenShareIcon, |
| &kVideoConferenceScreenShareIcon, |
| VIDEO_CONFERENCE_TOGGLE_BUTTON_TYPE_SCREEN_SHARE)); |
| // Toggling screen share stops screen share, and removes the item. |
| screen_share_icon_->set_toggle_is_one_way(); |
| screen_share_icon_->SetVisible(false); |
| } |
| |
| toggle_bubble_button_ = |
| tray_container()->AddChildView(std::make_unique<ToggleBubbleButton>( |
| this, base::BindRepeating(&VideoConferenceTray::ToggleBubble, |
| weak_ptr_factory_.GetWeakPtr()))); |
| |
| VideoConferenceTrayController::Get()->AddObserver(this); |
| VideoConferenceTrayController::Get()->GetEffectsManager().AddObserver(this); |
| Shell::Get()->session_controller()->AddObserver(this); |
| |
| // Update visibility of the tray and all child icons and indicators. If this |
| // lives on a secondary display, it's possible a media session already exists |
| // so force update all state. |
| UpdateTrayAndIconsState(); |
| |
| DCHECK_EQ(allow_stop_screen_share ? 4u : 3u, |
| tray_container()->children().size()) |
| << "Icons must be updated here in case a media session begins prior to " |
| "connecting a secondary display."; |
| } |
| |
| VideoConferenceTray::~VideoConferenceTray() { |
| Shell::Get()->session_controller()->RemoveObserver(this); |
| VideoConferenceTrayController::Get()->GetEffectsManager().RemoveObserver( |
| this); |
| VideoConferenceTrayController::Get()->RemoveObserver(this); |
| } |
| |
| void VideoConferenceTray::CloseBubble() { |
| bubble_open_ = false; |
| toggle_bubble_button_->SetToggled(false); |
| bubble_.reset(); |
| shelf()->UpdateAutoHideState(); |
| } |
| |
| TrayBubbleView* VideoConferenceTray::GetBubbleView() { |
| return bubble_ ? bubble_->bubble_view() : nullptr; |
| } |
| |
| views::Widget* VideoConferenceTray::GetBubbleWidget() const { |
| return bubble_ ? bubble_->bubble_widget() : nullptr; |
| } |
| |
| std::u16string VideoConferenceTray::GetAccessibleNameForTray() { |
| return l10n_util::GetStringUTF16(IDS_ASH_VIDEO_CONFERENCE_ACCESSIBLE_NAME); |
| } |
| |
| std::u16string VideoConferenceTray::GetAccessibleNameForBubble() { |
| return GetAccessibleNameForTray(); |
| } |
| |
| void VideoConferenceTray::HideBubbleWithView( |
| const TrayBubbleView* bubble_view) { |
| if (bubble_ && bubble_->bubble_view() == bubble_view) { |
| CloseBubble(); |
| } |
| } |
| |
| void VideoConferenceTray::HideBubble(const TrayBubbleView* bubble_view) { |
| if (bubble_ && bubble_->bubble_view() == bubble_view) { |
| CloseBubble(); |
| } |
| } |
| |
| void VideoConferenceTray::ClickedOutsideBubble() { |
| CloseBubble(); |
| } |
| |
| void VideoConferenceTray::HandleLocaleChange() { |
| // TODO(b/253646076): Finish this function. |
| } |
| |
| void VideoConferenceTray::AnchorUpdated() { |
| if (bubble_) { |
| bubble_->bubble_view()->UpdateBubble(); |
| } |
| } |
| |
| void VideoConferenceTray::OnAnimationEnded() { |
| TrayBackgroundView::OnAnimationEnded(); |
| |
| if (!visible_preferred()) { |
| return; |
| } |
| |
| auto* controller = VideoConferenceTrayController::Get(); |
| controller->MaybeRunNudgeRequest(); |
| controller->MaybeShowSpeakOnMuteOptInNudge(); |
| } |
| |
| bool VideoConferenceTray::ShouldEnterPushedState(const ui::Event& event) { |
| // Never enter pushed state to avoid displaying unnecessary ink drop in |
| // `ButtonController::OnMousePressed()`. |
| return false; |
| } |
| |
| void VideoConferenceTray::OnHasMediaAppStateChange() { |
| SetVisiblePreferred(VideoConferenceTrayController::Get()->ShouldShowTray()); |
| } |
| |
| void VideoConferenceTray::OnCameraPermissionStateChange() { |
| camera_icon_->SetVisible( |
| VideoConferenceTrayController::Get()->GetHasCameraPermissions()); |
| } |
| |
| void VideoConferenceTray::OnMicrophonePermissionStateChange() { |
| audio_icon_->SetVisible( |
| VideoConferenceTrayController::Get()->GetHasMicrophonePermissions()); |
| } |
| |
| void VideoConferenceTray::OnScreenSharingStateChange(bool is_capturing_screen) { |
| if (screen_share_icon_) { |
| screen_share_icon_->SetVisible(is_capturing_screen); |
| screen_share_icon_->SetIsCapturing( |
| /*is_capturing=*/is_capturing_screen); |
| } |
| } |
| |
| void VideoConferenceTray::OnCameraCapturingStateChange(bool is_capturing) { |
| camera_icon_->SetIsCapturing(is_capturing); |
| } |
| |
| void VideoConferenceTray::OnMicrophoneCapturingStateChange(bool is_capturing) { |
| audio_icon_->SetIsCapturing(is_capturing); |
| } |
| |
| void VideoConferenceTray::OnEffectSupportStateChanged(VcEffectId effect_id, |
| bool is_supported) { |
| // If the bubble is open, we close it so that when it is re-opened, the |
| // bubble is updated with the correct effect support state. |
| if (GetBubbleWidget()) { |
| CloseBubble(); |
| } |
| } |
| |
| SkScalar VideoConferenceTray::GetRotationValueForToggleBubbleButton() { |
| // If `bubble_` is not null, it means that the bubble is opened. |
| switch (shelf()->alignment()) { |
| case ShelfAlignment::kBottom: |
| case ShelfAlignment::kBottomLocked: |
| return bubble_ ? 180 : 0; |
| case ShelfAlignment::kLeft: |
| return bubble_ ? 270 : 90; |
| case ShelfAlignment::kRight: |
| return bubble_ ? 90 : 270; |
| } |
| } |
| |
| void VideoConferenceTray::UpdateTrayAndIconsState() { |
| auto* controller = VideoConferenceTrayController::Get(); |
| |
| SetVisiblePreferred(controller->ShouldShowTray()); |
| |
| camera_icon_->SetVisible(controller->GetHasCameraPermissions()); |
| camera_icon_->SetIsCapturing(controller->IsCapturingCamera()); |
| camera_icon_->SetToggled(/*toggled=*/controller->GetCameraMuted()); |
| |
| audio_icon_->SetVisible(controller->GetHasMicrophonePermissions()); |
| audio_icon_->SetIsCapturing(controller->IsCapturingMicrophone()); |
| audio_icon_->SetToggled(/*toggled=*/controller->GetMicrophoneMuted()); |
| |
| if (screen_share_icon_) { |
| bool is_capturing_screen = controller->IsCapturingScreen(); |
| screen_share_icon_->SetVisible(is_capturing_screen); |
| screen_share_icon_->SetIsCapturing(is_capturing_screen); |
| } |
| } |
| |
| IconButton* VideoConferenceTray::GetToggleBubbleButtonForTest() { |
| return toggle_bubble_button_; |
| } |
| |
| void VideoConferenceTray::OnSessionStateChanged( |
| session_manager::SessionState state) { |
| SetVisiblePreferred(VideoConferenceTrayController::Get()->ShouldShowTray()); |
| } |
| |
| void VideoConferenceTray::ToggleBubble(const ui::Event& event) { |
| bubble_open_ = !bubble_open_; |
| base::UmaHistogramBoolean(kToggleButtonHistogramName, bubble_open_); |
| |
| if (!bubble_open_) { |
| CloseBubble(); |
| return; |
| } |
| |
| VideoConferenceTrayController::Get()->CloseAllVcNudges(); |
| |
| // If we are already in the process of getting the media apps, we don't need |
| // to get it again. |
| if (!getting_media_apps_) { |
| getting_media_apps_ = true; |
| // Get all the currently running media apps from the controller and use it |
| // to construct the bubble. |
| VideoConferenceTrayController::Get()->GetMediaApps( |
| base::BindOnce(&VideoConferenceTray::ConstructBubbleWithMediaApps, |
| weak_ptr_factory_.GetWeakPtr())); |
| } |
| } |
| |
| void VideoConferenceTray::OnCameraButtonClicked(const ui::Event& event) { |
| const bool muted = !camera_icon_->toggled(); |
| VideoConferenceTrayController::Get()->SetCameraMuted(muted); |
| |
| base::UmaHistogramBoolean(kCameraMuteHistogramName, !muted); |
| } |
| |
| void VideoConferenceTray::OnAudioButtonClicked(const ui::Event& event) { |
| const bool muted = !audio_icon_->toggled(); |
| VideoConferenceTrayController::Get()->SetMicrophoneMuted(muted); |
| |
| base::UmaHistogramBoolean(kMicrophoneMuteHistogramName, !muted); |
| } |
| |
| void VideoConferenceTray::OnScreenShareButtonClicked(const ui::Event& event) { |
| if (features::IsStopAllScreenShareEnabled()) { |
| VideoConferenceTrayController::Get()->StopAllScreenShare(); |
| base::UmaHistogramBoolean(kStopScreenShareHistogramName, true); |
| } |
| } |
| |
| void VideoConferenceTray::ConstructBubbleWithMediaApps(MediaApps apps) { |
| getting_media_apps_ = false; |
| |
| // Should not show anything if bubble is intended to be close. |
| if (!bubble_open_) { |
| return; |
| } |
| |
| std::unique_ptr<TrayBubbleView> bubble_view; |
| auto init_params = CreateInitParamsForTrayBubble(/*tray=*/this); |
| |
| // If all of the apps are Linux apps, we will just use `LinuxAppsBubbleView` |
| // specifically for this situation. |
| if (!HasNonLinuxMediaApps(apps)) { |
| init_params.corner_radius = 18; |
| bubble_view = std::make_unique<video_conference::LinuxAppsBubbleView>( |
| init_params, apps); |
| bubble_view->SetPreferredWidth(bubble_view->GetPreferredSize().width()); |
| } else { |
| bubble_view = std::make_unique<video_conference::BubbleView>( |
| /*init_params=*/init_params, /*media_apps=*/apps, |
| /*controller=*/VideoConferenceTrayController::Get()); |
| } |
| |
| bubble_ = std::make_unique<TrayBubbleWrapper>(this); |
| bubble_->ShowBubble(std::move(bubble_view)); |
| |
| toggle_bubble_button_->SetToggled(true); |
| } |
| |
| void VideoConferenceTray::SetBackgroundReplaceUiVisible(bool visible) { |
| auto* bubble_view = GetBubbleView(); |
| if (bubble_view) { |
| views::AsViewClass<video_conference::BubbleView>(bubble_view) |
| ->SetBackgroundReplaceUiVisible(visible); |
| } |
| } |
| |
| BEGIN_METADATA(VideoConferenceTray) |
| END_METADATA |
| |
| } // namespace ash |