mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
* Re-land "Support multiple shells in a single process. (#4932)" This reverts commit 723c7d01439da4261bc836075fb55651ce9e7f03.
552 lines
20 KiB
C++
552 lines
20 KiB
C++
// Copyright 2018 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.
|
|
|
|
#define RAPIDJSON_HAS_STDSTRING 1
|
|
|
|
#include "platform_view.h"
|
|
|
|
#include <sstream>
|
|
|
|
#include "flutter/lib/ui/window/pointer_data.h"
|
|
#include "lib/app/cpp/connect.h"
|
|
#include "third_party/rapidjson/rapidjson/document.h"
|
|
#include "third_party/rapidjson/rapidjson/stringbuffer.h"
|
|
#include "third_party/rapidjson/rapidjson/writer.h"
|
|
|
|
namespace flutter {
|
|
|
|
constexpr char kFlutterPlatformChannel[] = "flutter/platform";
|
|
constexpr char kTextInputChannel[] = "flutter/textinput";
|
|
constexpr char kKeyEventChannel[] = "flutter/keyevent";
|
|
|
|
PlatformView::PlatformView(
|
|
PlatformView::Delegate& delegate,
|
|
std::string debug_label,
|
|
blink::TaskRunners task_runners,
|
|
component::ServiceProviderPtr parent_environment_service_provider,
|
|
mozart::ViewManagerPtr& view_manager,
|
|
f1dl::InterfaceRequest<mozart::ViewOwner> view_owner,
|
|
ui::ScenicPtr scenic,
|
|
zx::eventpair export_token,
|
|
zx::eventpair import_token,
|
|
maxwell::ContextWriterPtr accessibility_context_writer,
|
|
OnMetricsUpdate on_session_metrics_did_change,
|
|
fxl::Closure session_error_callback)
|
|
: shell::PlatformView(delegate, std::move(task_runners)),
|
|
debug_label_(std::move(debug_label)),
|
|
view_listener_(this),
|
|
input_listener_(this),
|
|
ime_client_(this),
|
|
scenic_(std::move(scenic)),
|
|
accessibility_bridge_(std::move(accessibility_context_writer)),
|
|
surface_(
|
|
std::make_unique<Surface>(scenic_,
|
|
debug_label_,
|
|
std::move(import_token),
|
|
std::move(on_session_metrics_did_change),
|
|
std::move(session_error_callback))) {
|
|
// Create the view.
|
|
view_manager->CreateView(view_.NewRequest(), // view
|
|
std::move(view_owner), // view owner
|
|
view_listener_.NewBinding(), // view listener
|
|
std::move(export_token), // export token
|
|
debug_label_ // diagnostic label
|
|
);
|
|
|
|
// Get the services from the created view.
|
|
component::ServiceProviderPtr service_provider;
|
|
view_->GetServiceProvider(service_provider.NewRequest());
|
|
|
|
// Get the input connection from the services of the view.
|
|
component::ConnectToService(service_provider.get(),
|
|
input_connection_.NewRequest());
|
|
|
|
// Set the input listener on the input connection.
|
|
input_connection_->SetEventListener(input_listener_.NewBinding());
|
|
|
|
// Access the clipboard.
|
|
component::ConnectToService(parent_environment_service_provider.get(),
|
|
clipboard_.NewRequest());
|
|
|
|
// Finally! Register the native platform message handlers.
|
|
RegisterPlatformMessageHandlers();
|
|
}
|
|
|
|
PlatformView::~PlatformView() = default;
|
|
|
|
void PlatformView::RegisterPlatformMessageHandlers() {
|
|
platform_message_handlers_[kFlutterPlatformChannel] =
|
|
std::bind(&PlatformView::HandleFlutterPlatformChannelPlatformMessage, //
|
|
this, //
|
|
std::placeholders::_1);
|
|
platform_message_handlers_[kTextInputChannel] =
|
|
std::bind(&PlatformView::HandleFlutterTextInputChannelPlatformMessage, //
|
|
this, //
|
|
std::placeholders::_1);
|
|
}
|
|
|
|
mozart::ViewPtr& PlatformView::GetMozartView() {
|
|
return view_;
|
|
}
|
|
|
|
// |mozart::ViewListener|
|
|
void PlatformView::OnPropertiesChanged(
|
|
mozart::ViewPropertiesPtr properties,
|
|
const OnPropertiesChangedCallback& callback) {
|
|
UpdateViewportMetrics(properties->view_layout);
|
|
callback();
|
|
}
|
|
|
|
void PlatformView::UpdateViewportMetrics(const mozart::ViewLayoutPtr& layout) {
|
|
if (!layout) {
|
|
return;
|
|
}
|
|
|
|
metrics_.size.width = layout->size->width;
|
|
metrics_.size.height = layout->size->height;
|
|
|
|
metrics_.padding.left = layout->inset->left;
|
|
metrics_.padding.top = layout->inset->top;
|
|
metrics_.padding.right = layout->inset->right;
|
|
metrics_.padding.bottom = layout->inset->bottom;
|
|
|
|
FlushViewportMetrics();
|
|
}
|
|
|
|
void PlatformView::UpdateViewportMetrics(double pixel_ratio) {
|
|
metrics_.scale = pixel_ratio;
|
|
|
|
FlushViewportMetrics();
|
|
}
|
|
|
|
void PlatformView::FlushViewportMetrics() {
|
|
const auto scale = metrics_.scale;
|
|
blink::ViewportMetrics metrics = {
|
|
.device_pixel_ratio = static_cast<float>(scale),
|
|
|
|
.physical_width = static_cast<int32_t>(metrics_.size.width * scale),
|
|
.physical_height = static_cast<int32_t>(metrics_.size.height * scale),
|
|
|
|
.physical_padding_top =
|
|
static_cast<int32_t>(metrics_.padding.top * scale),
|
|
.physical_padding_right =
|
|
static_cast<int32_t>(metrics_.padding.right * scale),
|
|
.physical_padding_bottom =
|
|
static_cast<int32_t>(metrics_.padding.bottom * scale),
|
|
.physical_padding_left =
|
|
static_cast<int32_t>(metrics_.padding.left * scale),
|
|
|
|
.physical_view_inset_top =
|
|
static_cast<int32_t>(metrics_.view_inset.top * scale),
|
|
.physical_view_inset_right =
|
|
static_cast<int32_t>(metrics_.view_inset.right * scale),
|
|
.physical_view_inset_bottom =
|
|
static_cast<int32_t>(metrics_.view_inset.bottom * scale),
|
|
.physical_view_inset_left =
|
|
static_cast<int32_t>(metrics_.view_inset.left * scale),
|
|
};
|
|
|
|
SetViewportMetrics(metrics);
|
|
}
|
|
|
|
// |mozart::InputMethodEditorClient|
|
|
void PlatformView::DidUpdateState(mozart::TextInputStatePtr state,
|
|
mozart::InputEventPtr event) {
|
|
rapidjson::Document document;
|
|
auto& allocator = document.GetAllocator();
|
|
rapidjson::Value encoded_state(rapidjson::kObjectType);
|
|
encoded_state.AddMember("text", state->text.get(), allocator);
|
|
encoded_state.AddMember("selectionBase", state->selection->base, allocator);
|
|
encoded_state.AddMember("selectionExtent", state->selection->extent,
|
|
allocator);
|
|
switch (state->selection->affinity) {
|
|
case mozart::TextAffinity::UPSTREAM:
|
|
encoded_state.AddMember("selectionAffinity",
|
|
rapidjson::Value("TextAffinity.upstream"),
|
|
allocator);
|
|
break;
|
|
case mozart::TextAffinity::DOWNSTREAM:
|
|
encoded_state.AddMember("selectionAffinity",
|
|
rapidjson::Value("TextAffinity.downstream"),
|
|
allocator);
|
|
break;
|
|
}
|
|
encoded_state.AddMember("selectionIsDirectional", true, allocator);
|
|
encoded_state.AddMember("composingBase", state->composing->start, allocator);
|
|
encoded_state.AddMember("composingExtent", state->composing->end, allocator);
|
|
|
|
rapidjson::Value args(rapidjson::kArrayType);
|
|
args.PushBack(current_text_input_client_, allocator);
|
|
args.PushBack(encoded_state, allocator);
|
|
|
|
document.SetObject();
|
|
document.AddMember("method",
|
|
rapidjson::Value("TextInputClient.updateEditingState"),
|
|
allocator);
|
|
document.AddMember("args", args, allocator);
|
|
|
|
rapidjson::StringBuffer buffer;
|
|
rapidjson::Writer<rapidjson::StringBuffer> writer(buffer);
|
|
document.Accept(writer);
|
|
|
|
const uint8_t* data = reinterpret_cast<const uint8_t*>(buffer.GetString());
|
|
DispatchPlatformMessage(fxl::MakeRefCounted<blink::PlatformMessage>(
|
|
kTextInputChannel, // channel
|
|
std::vector<uint8_t>(data, data + buffer.GetSize()), // message
|
|
nullptr) // response
|
|
);
|
|
}
|
|
|
|
// |mozart::InputMethodEditorClient|
|
|
void PlatformView::OnAction(mozart::InputMethodAction action) {
|
|
rapidjson::Document document;
|
|
auto& allocator = document.GetAllocator();
|
|
|
|
rapidjson::Value args(rapidjson::kArrayType);
|
|
args.PushBack(current_text_input_client_, allocator);
|
|
|
|
// Done is currently the only text input action defined by Flutter.
|
|
args.PushBack("TextInputAction.done", allocator);
|
|
|
|
document.SetObject();
|
|
document.AddMember(
|
|
"method", rapidjson::Value("TextInputClient.performAction"), allocator);
|
|
document.AddMember("args", args, allocator);
|
|
|
|
rapidjson::StringBuffer buffer;
|
|
rapidjson::Writer<rapidjson::StringBuffer> writer(buffer);
|
|
document.Accept(writer);
|
|
|
|
const uint8_t* data = reinterpret_cast<const uint8_t*>(buffer.GetString());
|
|
DispatchPlatformMessage(fxl::MakeRefCounted<blink::PlatformMessage>(
|
|
kTextInputChannel, // channel
|
|
std::vector<uint8_t>(data, data + buffer.GetSize()), // message
|
|
nullptr) // response
|
|
);
|
|
}
|
|
|
|
// |mozart::InputListener|
|
|
void PlatformView::OnEvent(mozart::InputEventPtr event,
|
|
const OnEventCallback& callback) {
|
|
using Type = mozart::InputEvent::Tag;
|
|
switch (event->which()) {
|
|
case Type::POINTER:
|
|
callback(OnHandlePointerEvent(event->get_pointer()));
|
|
return;
|
|
case Type::KEYBOARD:
|
|
callback(OnHandleKeyboardEvent(event->get_keyboard()));
|
|
return;
|
|
case Type::FOCUS:
|
|
callback(OnHandleFocusEvent(event->get_focus()));
|
|
return;
|
|
case Type::__UNKNOWN__:
|
|
break;
|
|
}
|
|
|
|
callback(false);
|
|
}
|
|
|
|
static blink::PointerData::Change GetChangeFromPointerEventPhase(
|
|
mozart::PointerEvent::Phase phase) {
|
|
switch (phase) {
|
|
case mozart::PointerEvent::Phase::ADD:
|
|
return blink::PointerData::Change::kAdd;
|
|
case mozart::PointerEvent::Phase::HOVER:
|
|
return blink::PointerData::Change::kHover;
|
|
case mozart::PointerEvent::Phase::DOWN:
|
|
return blink::PointerData::Change::kDown;
|
|
case mozart::PointerEvent::Phase::MOVE:
|
|
return blink::PointerData::Change::kMove;
|
|
case mozart::PointerEvent::Phase::UP:
|
|
return blink::PointerData::Change::kUp;
|
|
case mozart::PointerEvent::Phase::REMOVE:
|
|
return blink::PointerData::Change::kRemove;
|
|
case mozart::PointerEvent::Phase::CANCEL:
|
|
return blink::PointerData::Change::kCancel;
|
|
default:
|
|
return blink::PointerData::Change::kCancel;
|
|
}
|
|
}
|
|
|
|
static blink::PointerData::DeviceKind GetKindFromPointerType(
|
|
mozart::PointerEvent::Type type) {
|
|
switch (type) {
|
|
case mozart::PointerEvent::Type::TOUCH:
|
|
return blink::PointerData::DeviceKind::kTouch;
|
|
case mozart::PointerEvent::Type::MOUSE:
|
|
return blink::PointerData::DeviceKind::kMouse;
|
|
default:
|
|
return blink::PointerData::DeviceKind::kTouch;
|
|
}
|
|
}
|
|
|
|
bool PlatformView::OnHandlePointerEvent(
|
|
const mozart::PointerEventPtr& pointer) {
|
|
blink::PointerData pointer_data;
|
|
pointer_data.time_stamp = pointer->event_time / 1000;
|
|
pointer_data.change = GetChangeFromPointerEventPhase(pointer->phase);
|
|
pointer_data.kind = GetKindFromPointerType(pointer->type);
|
|
pointer_data.device = pointer->pointer_id;
|
|
pointer_data.physical_x = pointer->x * metrics_.scale;
|
|
pointer_data.physical_y = pointer->y * metrics_.scale;
|
|
|
|
switch (pointer_data.change) {
|
|
case blink::PointerData::Change::kDown:
|
|
down_pointers_.insert(pointer_data.device);
|
|
break;
|
|
case blink::PointerData::Change::kCancel:
|
|
case blink::PointerData::Change::kUp:
|
|
down_pointers_.erase(pointer_data.device);
|
|
break;
|
|
case blink::PointerData::Change::kMove:
|
|
if (down_pointers_.count(pointer_data.device) == 0) {
|
|
pointer_data.change = blink::PointerData::Change::kHover;
|
|
}
|
|
break;
|
|
case blink::PointerData::Change::kAdd:
|
|
if (down_pointers_.count(pointer_data.device) != 0) {
|
|
FXL_DLOG(ERROR) << "Received add event for down pointer.";
|
|
}
|
|
break;
|
|
case blink::PointerData::Change::kRemove:
|
|
if (down_pointers_.count(pointer_data.device) != 0) {
|
|
FXL_DLOG(ERROR) << "Received remove event for down pointer.";
|
|
}
|
|
break;
|
|
case blink::PointerData::Change::kHover:
|
|
if (down_pointers_.count(pointer_data.device) != 0) {
|
|
FXL_DLOG(ERROR) << "Received hover event for down pointer.";
|
|
}
|
|
break;
|
|
}
|
|
|
|
auto packet = std::make_unique<blink::PointerDataPacket>(1);
|
|
packet->SetPointerData(0, pointer_data);
|
|
DispatchPointerDataPacket(std::move(packet));
|
|
return true;
|
|
}
|
|
|
|
bool PlatformView::OnHandleKeyboardEvent(
|
|
const mozart::KeyboardEventPtr& keyboard) {
|
|
const char* type = nullptr;
|
|
if (keyboard->phase == mozart::KeyboardEvent::Phase::PRESSED) {
|
|
type = "keydown";
|
|
} else if (keyboard->phase == mozart::KeyboardEvent::Phase::REPEAT) {
|
|
type = "keydown"; // TODO change this to keyrepeat
|
|
} else if (keyboard->phase == mozart::KeyboardEvent::Phase::RELEASED) {
|
|
type = "keyup";
|
|
}
|
|
|
|
if (type == nullptr) {
|
|
FXL_DLOG(ERROR) << "Unknown key event phase.";
|
|
return false;
|
|
}
|
|
|
|
rapidjson::Document document;
|
|
auto& allocator = document.GetAllocator();
|
|
document.SetObject();
|
|
document.AddMember("type", rapidjson::Value(type, strlen(type)), allocator);
|
|
document.AddMember("keymap", rapidjson::Value("fuchsia"), allocator);
|
|
document.AddMember("hidUsage", keyboard->hid_usage, allocator);
|
|
document.AddMember("codePoint", keyboard->code_point, allocator);
|
|
document.AddMember("modifiers", keyboard->modifiers, allocator);
|
|
rapidjson::StringBuffer buffer;
|
|
rapidjson::Writer<rapidjson::StringBuffer> writer(buffer);
|
|
document.Accept(writer);
|
|
|
|
const uint8_t* data = reinterpret_cast<const uint8_t*>(buffer.GetString());
|
|
DispatchPlatformMessage(fxl::MakeRefCounted<blink::PlatformMessage>(
|
|
kKeyEventChannel, // channel
|
|
std::vector<uint8_t>(data, data + buffer.GetSize()), // data
|
|
nullptr) // response
|
|
);
|
|
|
|
return true;
|
|
}
|
|
|
|
bool PlatformView::OnHandleFocusEvent(const mozart::FocusEventPtr& focus) {
|
|
if (!focus->focused && current_text_input_client_ != 0) {
|
|
current_text_input_client_ = 0;
|
|
if (ime_) {
|
|
ime_->Hide();
|
|
ime_ = nullptr;
|
|
}
|
|
if (ime_client_.is_bound()) {
|
|
ime_client_.Unbind();
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// |shell::PlatformView|
|
|
std::unique_ptr<shell::Surface> PlatformView::CreateRenderingSurface() {
|
|
// This platform does not repeatly lose and gain a surface connection. So the
|
|
// surface is setup once during platform view setup and and returned to the
|
|
// shell on the initial (and only) |NotifyCreated| call.
|
|
return std::move(surface_);
|
|
}
|
|
|
|
// |shell::PlatformView|
|
|
void PlatformView::HandlePlatformMessage(
|
|
fxl::RefPtr<blink::PlatformMessage> message) {
|
|
if (!message) {
|
|
return;
|
|
}
|
|
auto found = platform_message_handlers_.find(message->channel());
|
|
if (found == platform_message_handlers_.end()) {
|
|
FXL_DLOG(ERROR)
|
|
<< "Platform view received message on channel '" << message->channel()
|
|
<< "' with no registed handler. And empty response will be generated. "
|
|
"Please implement the native message handler.";
|
|
PlatformView::HandlePlatformMessage(std::move(message));
|
|
return;
|
|
}
|
|
found->second(std::move(message));
|
|
}
|
|
|
|
// |shell::PlatformView|
|
|
void PlatformView::UpdateSemantics(blink::SemanticsNodeUpdates update) {
|
|
accessibility_bridge_.UpdateSemantics(update);
|
|
}
|
|
|
|
// Channel handler for kFlutterPlatformChannel
|
|
void PlatformView::HandleFlutterPlatformChannelPlatformMessage(
|
|
fxl::RefPtr<blink::PlatformMessage> message) {
|
|
FXL_DCHECK(message->channel() == kFlutterPlatformChannel);
|
|
const auto& data = message->data();
|
|
rapidjson::Document document;
|
|
document.Parse(reinterpret_cast<const char*>(data.data()), data.size());
|
|
if (document.HasParseError() || !document.IsObject()) {
|
|
return;
|
|
}
|
|
|
|
auto root = document.GetObject();
|
|
auto method = root.FindMember("method");
|
|
if (method == root.MemberEnd() || !method->value.IsString()) {
|
|
return;
|
|
}
|
|
|
|
fxl::RefPtr<blink::PlatformMessageResponse> response = message->response();
|
|
if (method->value == "Clipboard.setData") {
|
|
auto text = root["args"]["text"].GetString();
|
|
clipboard_->Push(text);
|
|
response->CompleteEmpty();
|
|
} else if (method->value == "Clipboard.getData") {
|
|
clipboard_->Peek([response](const f1dl::String& text) {
|
|
rapidjson::StringBuffer json_buffer;
|
|
rapidjson::Writer<rapidjson::StringBuffer> writer(json_buffer);
|
|
writer.StartArray();
|
|
writer.StartObject();
|
|
writer.Key("text");
|
|
writer.String(text);
|
|
writer.EndObject();
|
|
writer.EndArray();
|
|
std::string result = json_buffer.GetString();
|
|
response->Complete(std::vector<uint8_t>{result.begin(), result.end()});
|
|
});
|
|
} else {
|
|
response->CompleteEmpty();
|
|
}
|
|
}
|
|
|
|
// Channel handler for kTextInputChannel
|
|
void PlatformView::HandleFlutterTextInputChannelPlatformMessage(
|
|
fxl::RefPtr<blink::PlatformMessage> message) {
|
|
FXL_DCHECK(message->channel() == kTextInputChannel);
|
|
const auto& data = message->data();
|
|
rapidjson::Document document;
|
|
document.Parse(reinterpret_cast<const char*>(data.data()), data.size());
|
|
if (document.HasParseError() || !document.IsObject()) {
|
|
return;
|
|
}
|
|
auto root = document.GetObject();
|
|
auto method = root.FindMember("method");
|
|
if (method == root.MemberEnd() || !method->value.IsString()) {
|
|
return;
|
|
}
|
|
|
|
if (method->value == "TextInput.show") {
|
|
if (ime_) {
|
|
ime_->Show();
|
|
}
|
|
} else if (method->value == "TextInput.hide") {
|
|
if (ime_) {
|
|
ime_->Hide();
|
|
}
|
|
} else if (method->value == "TextInput.setClient") {
|
|
current_text_input_client_ = 0;
|
|
if (ime_client_.is_bound())
|
|
ime_client_.Unbind();
|
|
ime_ = nullptr;
|
|
|
|
auto args = root.FindMember("args");
|
|
if (args == root.MemberEnd() || !args->value.IsArray() ||
|
|
args->value.Size() != 2)
|
|
return;
|
|
const auto& configuration = args->value[1];
|
|
if (!configuration.IsObject()) {
|
|
return;
|
|
}
|
|
// TODO(abarth): Read the keyboard type from the configuration.
|
|
current_text_input_client_ = args->value[0].GetInt();
|
|
mozart::TextInputStatePtr state = mozart::TextInputState::New();
|
|
state->text = std::string();
|
|
state->selection = mozart::TextSelection::New();
|
|
state->composing = mozart::TextRange::New();
|
|
input_connection_->GetInputMethodEditor(
|
|
mozart::KeyboardType::TEXT, mozart::InputMethodAction::DONE,
|
|
std::move(state), ime_client_.NewBinding(), ime_.NewRequest());
|
|
} else if (method->value == "TextInput.setEditingState") {
|
|
if (ime_) {
|
|
auto args_it = root.FindMember("args");
|
|
if (args_it == root.MemberEnd() || !args_it->value.IsObject()) {
|
|
return;
|
|
}
|
|
const auto& args = args_it->value;
|
|
mozart::TextInputStatePtr state = mozart::TextInputState::New();
|
|
state->selection = mozart::TextSelection::New();
|
|
state->composing = mozart::TextRange::New();
|
|
// TODO(abarth): Deserialize state.
|
|
auto text = args.FindMember("text");
|
|
if (text != args.MemberEnd() && text->value.IsString())
|
|
state->text = text->value.GetString();
|
|
auto selection_base = args.FindMember("selectionBase");
|
|
if (selection_base != args.MemberEnd() && selection_base->value.IsInt())
|
|
state->selection->base = selection_base->value.GetInt();
|
|
auto selection_extent = args.FindMember("selectionExtent");
|
|
if (selection_extent != args.MemberEnd() &&
|
|
selection_extent->value.IsInt())
|
|
state->selection->extent = selection_extent->value.GetInt();
|
|
auto selection_affinity = args.FindMember("selectionAffinity");
|
|
if (selection_affinity != args.MemberEnd() &&
|
|
selection_affinity->value.IsString() &&
|
|
selection_affinity->value == "TextAffinity.upstream")
|
|
state->selection->affinity = mozart::TextAffinity::UPSTREAM;
|
|
else
|
|
state->selection->affinity = mozart::TextAffinity::DOWNSTREAM;
|
|
// We ignore selectionIsDirectional because that concept doesn't exist on
|
|
// Fuchsia.
|
|
auto composing_base = args.FindMember("composingBase");
|
|
if (composing_base != args.MemberEnd() && composing_base->value.IsInt())
|
|
state->composing->start = composing_base->value.GetInt();
|
|
auto composing_extent = args.FindMember("composingExtent");
|
|
if (composing_extent != args.MemberEnd() &&
|
|
composing_extent->value.IsInt())
|
|
state->composing->end = composing_extent->value.GetInt();
|
|
ime_->SetState(std::move(state));
|
|
}
|
|
} else if (method->value == "TextInput.clearClient") {
|
|
current_text_input_client_ = 0;
|
|
if (ime_client_.is_bound())
|
|
ime_client_.Unbind();
|
|
ime_ = nullptr;
|
|
} else {
|
|
FXL_DLOG(ERROR) << "Unknown " << message->channel() << " method "
|
|
<< method->value.GetString();
|
|
}
|
|
}
|
|
|
|
} // namespace flutter
|