[fuchsia] embedding-flutter test (flutter/engine#37052)

* Add embedding-flutter test

* Lint CC files

* GN formatting

* Remove changes to embedder

* Minor refactor

* Remove unused dependencies

* Lint CC files

* Remove comments

* Rename pubspec
This commit is contained in:
Erik
2022-11-07 17:04:58 -05:00
committed by GitHub
parent 00f424f205
commit fd9e9ccd63
13 changed files with 450 additions and 61 deletions

View File

@@ -46,6 +46,7 @@ executable("touch-input-test-bin") {
"$fuchsia_sdk_root/pkg:scenic_cpp",
"$fuchsia_sdk_root/pkg:sys_component_cpp_testing",
"$fuchsia_sdk_root/pkg:zx",
"embedding-flutter-view:package",
"touch-input-view:package",
"//build/fuchsia/fidl:fuchsia.ui.gfx",
"//flutter/fml",

View File

@@ -1,8 +1,9 @@
# touch-input
`touch-input-test` exercises touch through a child view (in this case, the `touch-input-view` Dart component) and asserting
the precise location of the touch event. We do this by attaching the child view, injecting touch, and validating that the view
reports the touch event back with the correct coordinates.
the precise location of the touch event. We validate a touch event as valid through two ways:
- By attaching the child view, injecting touch, and validating that the view reports the touch event back with the correct coordinates.
- By embedding a child view into a parent view, injecting touch into both views, and validating that each view reports its touch event back with the correct coordinates.
```shell
Injecting the tap event

View File

@@ -0,0 +1,40 @@
# Copyright 2013 The Flutter 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("//build/fuchsia/sdk.gni")
import("//flutter/tools/fuchsia/dart/dart_library.gni")
import("//flutter/tools/fuchsia/flutter/flutter_component.gni")
import("//flutter/tools/fuchsia/gn-sdk/component.gni")
import("//flutter/tools/fuchsia/gn-sdk/package.gni")
dart_library("lib") {
package_name = "embedding-flutter-view"
sources = [ "embedding-flutter-view.dart" ]
deps = [
"//flutter/tools/fuchsia/dart:fuchsia_services",
"//flutter/tools/fuchsia/dart:zircon",
"//flutter/tools/fuchsia/fidl:fuchsia.ui.app",
"//flutter/tools/fuchsia/fidl:fuchsia.ui.scenic",
"//flutter/tools/fuchsia/fidl:fuchsia.ui.test.input",
"//flutter/tools/fuchsia/fidl:fuchsia.ui.views",
]
}
flutter_component("component") {
testonly = true
component_name = "embedding-flutter-view"
manifest = rebase_path("meta/embedding-flutter-view.cml")
main_package = "embedding-flutter-view"
main_dart = "embedding-flutter-view.dart"
deps = [ ":lib" ]
}
fuchsia_package("package") {
testonly = true
package_name = "embedding-flutter-view"
deps = [ ":component" ]
}

View File

@@ -0,0 +1,175 @@
// Copyright 2013 The Flutter 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:convert';
import 'dart:typed_data';
import 'dart:io';
import 'dart:ui';
import 'package:fidl_fuchsia_ui_app/fidl_async.dart';
import 'package:fidl_fuchsia_ui_views/fidl_async.dart';
import 'package:fidl_fuchsia_ui_test_input/fidl_async.dart' as test_touch;
import 'package:fuchsia_services/services.dart';
import 'package:zircon/zircon.dart';
void main(List<String> args) {
print('Launching embedding-flutter-view');
TestApp app = TestApp(ChildView.gfx(_launchGfxChildView()));
app.run();
}
class TestApp {
static const _black = Color.fromARGB(255, 0, 0, 0);
static const _blue = Color.fromARGB(255, 0, 0, 255);
final ChildView childView;
final _responseListener = test_touch.TouchInputListenerProxy();
Color _backgroundColor = _blue;
TestApp(this.childView) {}
void run() {
childView.create((ByteData reply) {
// Set up window callbacks.
window.onPointerDataPacket = (PointerDataPacket packet) {
this.pointerDataPacket(packet);
};
window.onMetricsChanged = () {
window.scheduleFrame();
};
window.onBeginFrame = (Duration duration) {
this.beginFrame(duration);
};
// The child view should be attached to Scenic now.
// Ready to build the scene.
window.scheduleFrame();
});
}
void beginFrame(Duration duration) {
// Convert physical screen size of device to values
final pixelRatio = window.devicePixelRatio;
final size = window.physicalSize / pixelRatio;
final physicalBounds = Offset.zero & window.physicalSize;
final windowBounds = Offset.zero & size;
// Set up a Canvas that uses the screen size
final recorder = PictureRecorder();
final canvas = Canvas(recorder, physicalBounds);
canvas.scale(pixelRatio);
// Draw something
final paint = Paint()..color = this._backgroundColor;
canvas.drawRect(windowBounds, paint);
final picture = recorder.endRecording();
// Build the scene
final sceneBuilder = SceneBuilder()
..pushClipRect(physicalBounds)
..addPicture(Offset.zero, picture);
// Child view should take up half the screen
final childPhysicalSize = window.physicalSize * 0.5;
sceneBuilder
..addPlatformView(childView.viewId,
width: childPhysicalSize.width,
height: size.height)
..pop();
sceneBuilder.pop();
window.render(sceneBuilder.build());
}
void pointerDataPacket(PointerDataPacket packet) async {
int nowNanos = System.clockGetMonotonic();
for (PointerData data in packet.data) {
print('embedding-flutter-view received tap: ${data.toStringFull()}');
if (data.change == PointerChange.down) {
this._backgroundColor = _black;
}
if (data.change == PointerChange.down || data.change == PointerChange.move) {
Incoming.fromSvcPath()
..connectToService(_responseListener)
..close();
_respond(test_touch.TouchInputListenerReportTouchInputRequest(
localX: data.physicalX,
localY: data.physicalY,
timeReceived: nowNanos,
componentName: 'embedding-flutter-view',
));
}
}
window.scheduleFrame();
}
void _respond(test_touch.TouchInputListenerReportTouchInputRequest request) async {
print('embedding-flutter-view reporting touch input to TouchInputListener');
await _responseListener.reportTouchInput(request);
}
}
class ChildView {
final ViewHolderToken viewHolderToken;
final ViewportCreationToken viewportCreationToken;
final int viewId;
ChildView(this.viewportCreationToken) : viewHolderToken = null, viewId = viewportCreationToken.value.handle.handle {
assert(viewId != null);
}
ChildView.gfx(this.viewHolderToken) : viewportCreationToken = null, viewId = viewHolderToken.value.handle.handle {
assert(viewId != null);
}
void create(PlatformMessageResponseCallback callback) {
// Construct the dart:ui platform message to create the view, and when the
// return callback is invoked, build the scene. At that point, it is safe
// to embed the child view in the scene.
final viewOcclusionHint = Rect.zero;
final Map<String, dynamic> args = <String, dynamic>{
'viewId': viewId,
'hitTestable': true,
'focusable': true,
'viewOcclusionHintLTRB': <double>[
viewOcclusionHint.left,
viewOcclusionHint.top,
viewOcclusionHint.right,
viewOcclusionHint.bottom
],
};
final ByteData createViewMessage = utf8.encoder.convert(
json.encode(<String, Object>{
'method': 'View.create',
'args': args,
})
).buffer.asByteData();
final platformViewsChannel = 'flutter/platform_views';
PlatformDispatcher.instance.sendPlatformMessage(
platformViewsChannel,
createViewMessage,
callback);
}
}
ViewHolderToken _launchGfxChildView() {
ViewProviderProxy viewProvider = ViewProviderProxy();
Incoming.fromSvcPath()
..connectToService(viewProvider)
..close();
final viewTokens = EventPairPair();
assert(viewTokens.status == ZX.OK);
final viewHolderToken = ViewHolderToken(value: viewTokens.first);
final viewToken = ViewToken(value: viewTokens.second);
viewProvider.createView(viewToken.value, null, null);
viewProvider.ctrl.close();
return viewHolderToken;
}

View File

@@ -0,0 +1,38 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
{
include: [ "syslog/client.shard.cml" ],
program: {
data: "data/embedding-flutter-view",
// Always use the jit runner for now.
// TODO(fxbug.dev/106577): Implement manifest merging build rules for V2 components.
runner: "flutter_jit_runner",
},
capabilities: [
{
protocol: [ "fuchsia.ui.app.ViewProvider" ],
},
],
expose: [
{
protocol: [ "fuchsia.ui.app.ViewProvider" ],
from: "self",
},
],
use: [
{
protocol: [
"fuchsia.ui.app.ViewProvider",
"fuchsia.ui.scenic.Scenic",
"fuchsia.ui.test.input.TouchInputListener",
]
},
{
directory: "config-data",
rights: [ "r*" ],
path: "/config/data",
},
]
}

View File

@@ -0,0 +1,8 @@
# Copyright 2013 The Flutter Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
name: embedding-flutter-view
environment:
sdk: '>=2.18.0 <3.0.0'

View File

@@ -126,14 +126,24 @@ using RealmBuilder = component_testing::RealmBuilder;
// Max timeout in failure cases.
// Set this as low as you can that still works across all test platforms.
constexpr zx::duration kTimeout = zx::min(5);
constexpr zx::duration kTimeout = zx::min(1);
constexpr auto kTestUIStackUrl =
"fuchsia-pkg://fuchsia.com/gfx-root-presenter-test-ui-stack#meta/"
"test-ui-stack.cm";
constexpr auto kMockTouchInputListener = "touch_input_listener";
constexpr auto kMockTouchInputListenerRef = ChildRef{kMockTouchInputListener};
constexpr auto kTouchInputView = "touch-input-view";
constexpr auto kTouchInputViewRef = ChildRef{kTouchInputView};
constexpr auto kTouchInputViewUrl =
"fuchsia-pkg://fuchsia.com/touch-input-view#meta/touch-input-view.cm";
constexpr auto kEmbeddingFlutterView = "embedding-flutter-view";
constexpr auto kEmbeddingFlutterViewRef = ChildRef{kEmbeddingFlutterView};
constexpr auto kEmbeddingFlutterViewUrl =
"fuchsia-pkg://fuchsia.com/embedding-flutter-view#meta/"
"embedding-flutter-view.cm";
bool CompareDouble(double f0, double f1, double epsilon) {
return std::abs(f0 - f1) <= epsilon;
@@ -195,11 +205,11 @@ class TouchInputListenerServer
events_received_;
};
class FlutterTapTest : public PortableUITest,
public ::testing::Test,
public ::testing::WithParamInterface<std::string> {
class FlutterTapTestBase : public PortableUITest,
public ::testing::Test,
public ::testing::WithParamInterface<std::string> {
protected:
~FlutterTapTest() override {
~FlutterTapTestBase() override {
FML_CHECK(touch_injection_request_count() > 0)
<< "Injection expected but didn't happen.";
}
@@ -251,24 +261,41 @@ class FlutterTapTest : public PortableUITest,
auto actual_x = pixel_scale * last_event.local_x();
auto actual_y = pixel_scale * last_event.local_y();
auto actual_component = last_event.component_name();
FML_LOG(INFO) << "Expecting event for component " << component_name
<< " at (" << expected_x << ", " << expected_y << ")";
FML_LOG(INFO) << "Received event for component " << component_name
<< " at (" << actual_x << ", " << actual_y
<< "), accounting for pixel scale of " << pixel_scale;
bool last_event_matches =
CompareDouble(actual_x, expected_x, pixel_scale) &&
CompareDouble(actual_y, expected_y, pixel_scale) &&
last_event.component_name() == component_name;
return CompareDouble(actual_x, expected_x, pixel_scale) &&
CompareDouble(actual_y, expected_y, pixel_scale) &&
last_event.component_name() == component_name;
if (last_event_matches) {
FML_LOG(INFO) << "Received event for component " << component_name
<< " at (" << expected_x << ", " << expected_y << ")";
} else {
FML_LOG(WARNING) << "Expecting event for component " << component_name
<< " at (" << expected_x << ", " << expected_y << "). "
<< "Instead received event for component "
<< actual_component << " at (" << actual_x << ", "
<< actual_y << "), accounting for pixel scale of "
<< pixel_scale;
}
return last_event_matches;
}
// Guaranteed to be initialized after SetUp().
uint32_t display_width() const { return display_width_; }
uint32_t display_height() const { return display_height_; }
ParamType GetTestUIStackUrl() override { return GetParam(); };
std::unique_ptr<TouchInputListenerServer> touch_input_listener_server_;
};
class FlutterTapTest : public FlutterTapTestBase {
private:
void ExtendRealm() override {
FML_LOG(INFO) << "Extending realm";
// Key part of service setup: have this test component vend the
// |TouchInputListener| service in the constructed realm.
touch_input_listener_server_ =
@@ -276,6 +303,7 @@ class FlutterTapTest : public PortableUITest,
realm_builder()->AddLocalChild(kMockTouchInputListener,
touch_input_listener_server_.get());
// Add touch-input-view to the Realm
realm_builder()->AddChild(kTouchInputView, kTouchInputViewUrl,
component_testing::ChildOptions{
.environment = kFlutterRunnerEnvironment,
@@ -293,26 +321,57 @@ class FlutterTapTest : public PortableUITest,
.source = kTouchInputViewRef,
.targets = {ParentRef()}});
}
};
ParamType GetTestUIStackUrl() override { return GetParam(); };
class FlutterEmbedTapTest : public FlutterTapTestBase {
private:
void ExtendRealm() override {
FML_LOG(INFO) << "Extending realm";
// Key part of service setup: have this test component vend the
// |TouchInputListener| service in the constructed realm.
touch_input_listener_server_ =
std::make_unique<TouchInputListenerServer>(dispatcher());
realm_builder()->AddLocalChild(kMockTouchInputListener,
touch_input_listener_server_.get());
std::unique_ptr<TouchInputListenerServer> touch_input_listener_server_;
// Add touch-input-view to the Realm
realm_builder()->AddChild(kTouchInputView, kTouchInputViewUrl,
component_testing::ChildOptions{
.environment = kFlutterRunnerEnvironment,
});
// Add embedding-flutter-view to the Realm
// This component will embed touch-input-view as a child view
realm_builder()->AddChild(kEmbeddingFlutterView, kEmbeddingFlutterViewUrl,
component_testing::ChildOptions{
.environment = kFlutterRunnerEnvironment,
});
fuchsia::ui::scenic::ScenicPtr scenic_;
uint32_t display_width_ = 0;
uint32_t display_height_ = 0;
// Route the TouchInput protocol capability to the Dart component
realm_builder()->AddRoute(
Route{.capabilities = {Protocol{
fuchsia::ui::test::input::TouchInputListener::Name_}},
.source = kMockTouchInputListenerRef,
.targets = {kFlutterJitRunnerRef, kTouchInputViewRef,
kEmbeddingFlutterViewRef}});
realm_builder()->AddRoute(
Route{.capabilities = {Protocol{fuchsia::ui::app::ViewProvider::Name_}},
.source = kEmbeddingFlutterViewRef,
.targets = {ParentRef()}});
realm_builder()->AddRoute(
Route{.capabilities = {Protocol{fuchsia::ui::app::ViewProvider::Name_}},
.source = kTouchInputViewRef,
.targets = {kEmbeddingFlutterViewRef}});
}
};
// Makes use of gtest's parameterized testing, allowing us
// to test different combinations of test-ui-stack + runners. Currently, there
// is just one combination. Documentation:
// http://go/gunitadvanced#value-parameterized-tests
INSTANTIATE_TEST_SUITE_P(
FlutterTapTestParameterized,
FlutterTapTest,
::testing::Values(
"fuchsia-pkg://fuchsia.com/gfx-root-presenter-test-ui-stack#meta/"
"test-ui-stack.cm"));
INSTANTIATE_TEST_SUITE_P(FlutterTapTestParameterized,
FlutterTapTest,
::testing::Values(kTestUIStackUrl));
TEST_P(FlutterTapTest, FlutterTap) {
// Launch client view, and wait until it's rendering to proceed with the test.
@@ -335,5 +394,43 @@ TEST_P(FlutterTapTest, FlutterTap) {
});
}
INSTANTIATE_TEST_SUITE_P(FlutterEmbedTapTestParameterized,
FlutterEmbedTapTest,
::testing::Values(kTestUIStackUrl));
TEST_P(FlutterEmbedTapTest, FlutterEmbedTap) {
// Launch view
FML_LOG(INFO) << "Initializing scene";
LaunchClientWithEmbeddedView();
FML_LOG(INFO) << "Client launched";
{
// Embedded child view takes up the left side of the screen
// Expect a response from the child view if we inject a tap there
InjectTap(-500, -500);
RunLoopUntil([this] {
return LastEventReceivedMatches(
/*expected_x=*/static_cast<float>(display_width() / 4.0f),
/*expected_y=*/static_cast<float>(display_height() / 4.0f),
/*component_name=*/"touch-input-view");
});
}
{
// Parent view takes up the right side of the screen
// Validate that parent can still receive taps
InjectTap(500, 500);
RunLoopUntil([this] {
return LastEventReceivedMatches(
/*expected_x=*/static_cast<float>(display_width() / (4.0f / 3.0f)),
/*expected_y=*/static_cast<float>(display_height() / (4.0f / 3.0f)),
/*component_name=*/"embedding-flutter-view");
});
}
// There should be 2 injected taps
ASSERT_EQ(touch_injection_request_count(), 2);
}
} // namespace
} // namespace touch_input_test::testing

View File

@@ -11,30 +11,17 @@ import 'package:fuchsia_services/services.dart';
import 'package:zircon/zircon.dart';
void main() {
print('Launching two-flutter view');
MyApp app = MyApp();
print('Launching touch-input-view');
TestApp app = TestApp();
app.run();
}
class MyApp {
static const _red = Color.fromARGB(255, 244, 67, 54);
static const _orange = Color.fromARGB(255, 255, 152, 0);
static const _yellow = Color.fromARGB(255, 255, 235, 59);
static const _green = Color.fromARGB(255, 76, 175, 80);
static const _blue = Color.fromARGB(255, 33, 150, 143);
static const _purple = Color.fromARGB(255, 156, 39, 176);
class TestApp {
static const _yellow = Color.fromARGB(255, 255, 255, 0);
static const _pink = Color.fromARGB(255, 255, 0, 255);
final List<Color> _colors = <Color>[
_red,
_orange,
_yellow,
_green,
_blue,
_purple,
];
Color _backgroundColor = _pink;
// Each tap will increment the counter, we then determine what color to choose
int _touchCounter = 0;
final _responseListener = test_touch.TouchInputListenerProxy();
void run() {
@@ -59,15 +46,14 @@ class MyApp {
final pixelRatio = window.devicePixelRatio;
final size = window.physicalSize / pixelRatio;
final physicalBounds = Offset.zero & size * pixelRatio;
// Set up Canvas that uses the screen size
final windowBounds = Offset.zero & size;
// Set up a Canvas that uses the screen size
final recorder = PictureRecorder();
final canvas = Canvas(recorder, physicalBounds);
canvas.scale(pixelRatio, pixelRatio);
// Draw something
// Color of the screen is set initially to the first value in _colors
// Incrementing _touchCounter will change screen color
final paint = Paint()..color = _colors[_touchCounter % _colors.length];
canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), paint);
final paint = Paint()..color = this._backgroundColor;
canvas.drawRect(windowBounds, paint);
// Build the scene
final picture = recorder.endRecording();
final sceneBuilder = SceneBuilder()
@@ -83,10 +69,6 @@ class MyApp {
for (PointerData data in packet.data) {
print('touch-input-view received tap: ${data.toStringFull()}');
if (data.change == PointerChange.down) {
_touchCounter++;
}
if (data.change == PointerChange.down || data.change == PointerChange.move) {
Incoming.fromSvcPath()
..connectToService(_responseListener)

View File

@@ -24,11 +24,7 @@
use: [
{
protocol: [
"fuchsia.sysmem.Allocator",
"fuchsia.tracing.provider.Registry",
"fuchsia.ui.scenic.Scenic",
"fuchsia.ui.test.input.TouchInputListener",
"fuchsia.vulkan.loader.Loader",
]
}
]

View File

@@ -31,9 +31,7 @@ using fuchsia_test_utils::CheckViewExistsInSnapshot;
void PortableUITest::SetUp() {
SetUpRealmBase();
ExtendRealm();
realm_ = std::make_unique<RealmRoot>(realm_builder_.Build());
}
@@ -142,6 +140,7 @@ void PortableUITest::LaunchClient() {
FML_LOG(ERROR) << "Error from test scene provider: "
<< &zx_status_get_string;
});
fuchsia::ui::test::scene::ControllerAttachClientViewRequest request;
request.set_view_provider(realm_->Connect<fuchsia::ui::app::ViewProvider>());
scene_provider_->RegisterViewTreeWatcher(view_tree_watcher_.NewRequest(),
@@ -157,11 +156,55 @@ void PortableUITest::LaunchClient() {
WatchViewGeometry();
FML_LOG(INFO) << "Waiting for client view to connect";
// Wait for the client view to get attached to the view tree.
RunLoopUntil(
[this] { return HasViewConnected(*client_root_view_ref_koid_); });
FML_LOG(INFO) << "Client view has rendered";
}
void PortableUITest::LaunchClientWithEmbeddedView() {
LaunchClient();
// At this point, the parent view must have rendered, so we just need to wait
// for the embedded view.
RunLoopUntil([this] {
if (!last_view_tree_snapshot_.has_value() ||
!last_view_tree_snapshot_->has_views()) {
return false;
}
if (!client_root_view_ref_koid_.has_value()) {
return false;
}
for (const auto& view : last_view_tree_snapshot_->views()) {
if (!view.has_view_ref_koid() ||
view.view_ref_koid() != *client_root_view_ref_koid_) {
continue;
}
if (view.children().empty()) {
return false;
}
// NOTE: We can't rely on the presence of the child view in
// `view.children()` to guarantee that it has rendered. The child view
// also needs to be present in `last_view_tree_snapshot_->views`.
return std::count_if(
last_view_tree_snapshot_->views().begin(),
last_view_tree_snapshot_->views().end(),
[view_to_find =
view.children().back()](const auto& view_to_check) {
return view_to_check.has_view_ref_koid() &&
view_to_check.view_ref_koid() == view_to_find;
}) > 0;
}
return false;
});
FML_LOG(INFO) << "Embedded view has rendered";
}
void PortableUITest::RegisterTouchScreen() {
FML_LOG(INFO) << "Registering fake touch screen";
input_registry_ = realm_->Connect<fuchsia::ui::test::input::Registry>();

View File

@@ -48,6 +48,9 @@ class PortableUITest : public ::loop_fixture::RealLoop {
// Attaches a client view to the scene, and waits for it to render.
void LaunchClient();
// Attaches a view with an embedded child view to the scene, and waits for it
// to render.
void LaunchClientWithEmbeddedView();
// Returns true when the specified view is fully connected to the scene AND
// has presented at least one frame of content.
@@ -86,6 +89,10 @@ class PortableUITest : public ::loop_fixture::RealLoop {
component_testing::RealmBuilder* realm_builder() { return &realm_builder_; }
component_testing::RealmRoot* realm_root() { return realm_.get(); }
fuchsia::ui::scenic::ScenicPtr scenic_;
uint32_t display_width_ = 0;
uint32_t display_height_ = 0;
int touch_injection_request_count() const {
return touch_injection_request_count_;
}

View File

@@ -53,6 +53,7 @@
- touch-input-test-0.far
- oot_flutter_jit_runner-0.far
- gen/flutter/shell/platform/fuchsia/flutter/tests/integration/touch-input/touch-input-view/touch-input-view/touch-input-view.far
- gen/flutter/shell/platform/fuchsia/flutter/tests/integration/touch-input/embedding-flutter-view/embedding-flutter-view/embedding-flutter-view.far
- test_command: run-test-suite fuchsia-pkg://fuchsia.com/mouse-input-test#meta/mouse-input-test.cm
packages:
- mouse-input-test-0.far

View File

@@ -60,7 +60,7 @@ case $test_name in
test_packages=("text-input-test-0.far" "text-input-view.far")
;;
touch-input)
test_packages=("touch-input-test-0.far" "touch-input-view.far")
test_packages=("touch-input-test-0.far" "touch-input-view.far" "embedding-flutter-view.far")
;;
*)
engine-error "Unknown test name $test_name. You may need to add it to $0"