diff --git a/DEPS b/DEPS index 92ebc03cfcff8..5cc1fadc6b9eb 100644 --- a/DEPS +++ b/DEPS @@ -14,7 +14,7 @@ vars = { 'flutter_git': 'https://flutter.googlesource.com', 'skia_git': 'https://skia.googlesource.com', 'llvm_git': 'https://llvm.googlesource.com', - 'skia_revision': '1349ddc074ad616c6660eb29f75dafe18f3714c0', + 'skia_revision': '6062afaa505bf7e6c727a20cafe4c7bee0f02df8', # WARNING: DO NOT EDIT canvaskit_cipd_instance MANUALLY # See `lib/web_ui/README.md` for how to roll CanvasKit to a new version. @@ -56,7 +56,7 @@ vars = { # Dart is: https://github.com/dart-lang/sdk/blob/main/DEPS # You can use //tools/dart/create_updated_flutter_deps.py to produce # updated revision list of existing dependencies. - 'dart_revision': 'd916a5f69a486de98316900f19ef0ff46834b03d', + 'dart_revision': '1bf43bfd314768e235f6e5618842469bab6494bd', # WARNING: DO NOT EDIT MANUALLY # The lines between blank lines above and below are generated by a script. See create_updated_flutter_deps.py diff --git a/ci/builders/mac_unopt.json b/ci/builders/mac_unopt.json index 3a95e217f5670..1f9a942362030 100644 --- a/ci/builders/mac_unopt.json +++ b/ci/builders/mac_unopt.json @@ -5,8 +5,7 @@ "drone_dimensions": [ "device_type=none", "os=Mac-13|Mac-14", - "cpu=x86", - "mac_model=Macmini8,1" + "cpu=arm64" ], "gclient_variables": { "download_android_deps": false, @@ -15,9 +14,11 @@ }, "gn": [ "--target-dir", - "ci/host_debug_tests", + "ci/host_debug_arm64_tests", "--runtime-mode", "debug", + "--mac-cpu", + "arm64", "--no-lto", "--prebuilt-dart-sdk", "--build-embedder-examples", @@ -25,12 +26,11 @@ "--rbe", "--no-goma", "--xcode-symlinks" - ], - "name": "ci/host_debug_tests", - "description": "Produces debug mode x64 macOS host-side tooling and builds host-side unit tests for x64 macOS.", + "name": "ci/host_debug_arm64_tests", + "description": "Produces debug mode arm64 macOS host-side tooling and builds host-side unit tests for arm64 macOS.", "ninja": { - "config": "ci/host_debug_tests", + "config": "ci/host_debug_arm64_tests", "targets": [] }, "properties": { @@ -44,8 +44,9 @@ "name": "Host Tests for host_debug", "script": "flutter/testing/run_tests.py", "parameters": [ + "--quiet", "--variant", - "ci/host_debug_tests", + "ci/host_debug_arm64_tests", "--type", "dart,dart-host,engine", "--engine-capture-core-dump" @@ -58,8 +59,7 @@ "drone_dimensions": [ "device_type=none", "os=Mac-13|Mac-14", - "cpu=x86", - "mac_model=Macmini8,1" + "cpu=arm64" ], "gclient_variables": { "download_android_deps": false, @@ -67,9 +67,11 @@ }, "gn": [ "--target-dir", - "ci/host_profile_tests", + "ci/host_profile_arm64_tests", "--runtime-mode", "profile", + "--mac-cpu", + "arm64", "--no-lto", "--prebuilt-dart-sdk", "--build-embedder-examples", @@ -77,10 +79,10 @@ "--no-goma", "--xcode-symlinks" ], - "name": "ci/host_profile_tests", - "description": "Produces profile mode x64 macOS host-side tooling and builds host-side unit tests for x64 macOS.", + "name": "ci/host_profile_arm64_tests", + "description": "Produces profile mode arm64 macOS host-side tooling and builds host-side unit tests for arm64 macOS.", "ninja": { - "config": "ci/host_profile_tests", + "config": "ci/host_profile_arm64_tests", "targets": [] }, "properties": { @@ -94,8 +96,9 @@ "name": "Host Tests for host_profile", "script": "flutter/testing/run_tests.py", "parameters": [ + "--quiet", "--variant", - "ci/host_profile_tests", + "ci/host_profile_arm64_tests", "--type", "dart,dart-host,engine", "--engine-capture-core-dump" @@ -108,14 +111,7 @@ "drone_dimensions": [ "device_type=none", "os=Mac-13|Mac-14", - "cpu=x86", - "mac_model=Macmini8,1" - ], - "dependencies": [ - { - "dependency": "goldctl", - "version": "git_revision:720a542f6fe4f92922c3b8f0fdcc4d2ac6bb83cd" - } + "cpu=arm64" ], "gclient_variables": { "download_android_deps": false, @@ -123,9 +119,11 @@ }, "gn": [ "--target-dir", - "ci/host_release_tests", + "ci/host_release_arm64_tests", "--runtime-mode", "release", + "--mac-cpu", + "arm64", "--no-lto", "--prebuilt-dart-sdk", "--build-embedder-examples", @@ -134,10 +132,10 @@ "--no-goma", "--xcode-symlinks" ], - "name": "ci/host_release_tests", - "description": "Produces release mode x64 macOS host-side tooling and builds host-side unit tests for x64 macOS.", + "name": "ci/host_release_arm64_tests", + "description": "Produces release mode arm64 macOS host-side tooling and builds host-side unit tests for arm64 macOS.", "ninja": { - "config": "ci/host_release_tests", + "config": "ci/host_release_arm64_tests", "targets": [] }, "properties": { @@ -148,11 +146,12 @@ "tests": [ { "language": "python3", - "name": "Impeller-golden, dart and engine tests for host_release", + "name": "Dart and engine tests for host_release", "script": "flutter/testing/run_tests.py", "parameters": [ + "--quiet", "--variant", - "ci/host_release_tests", + "ci/host_release_arm64_tests", "--type", "dart,dart-host,engine" ] @@ -208,6 +207,7 @@ "name": "Impeller-golden for host_release", "script": "flutter/testing/run_tests.py", "parameters": [ + "--quiet", "--variant", "ci/mac_release_arm64_tests", "--type", @@ -216,62 +216,6 @@ } ] }, - { - "cas_archive": false, - "drone_dimensions": [ - "device_type=none", - "os=Mac-13|Mac-14", - "cpu=x86", - "mac_model=Macmini8,1" - ], - "gclient_variables": { - "download_android_deps": false, - "use_rbe": true - }, - "gn": [ - "--target-dir", - "ci/host_debug_unopt", - "--runtime-mode", - "debug", - "--unoptimized", - "--no-lto", - "--prebuilt-dart-sdk", - "--enable-impeller-3d", - "--rbe", - "--no-goma", - "--xcode-symlinks" - ], - "name": "ci/host_debug_unopt", - "description": "Builds a debug mode unopt x64 macOS engine and runs host-side tests.", - "ninja": { - "config": "ci/host_debug_unopt", - "targets": [] - }, - "properties": { - "$flutter/osx_sdk": { - "sdk_version": "15a240d" - } - }, - "tests": [ - { - "language": "python3", - "name": "Host Tests for host_debug_unopt", - "script": "flutter/testing/run_tests.py", - "parameters": [ - "--variant", - "ci/host_debug_unopt", - "--type", - "dart,dart-host,engine", - "--engine-capture-core-dump" - ] - }, - { - "name": "Tests of tools/gn", - "language": "python3", - "script": "flutter/tools/gn_test.py" - } - ] - }, { "cas_archive": false, "properties": { @@ -318,6 +262,7 @@ "name": "Tests for ios_debug_unopt_sim", "script": "flutter/testing/run_tests.py", "parameters": [ + "--quiet", "--variant", "ci/ios_debug_unopt_sim", "--type", @@ -358,6 +303,7 @@ "--prebuilt-dart-sdk", "--mac-cpu", "arm64", + "--enable-impeller-3d", "--rbe", "--no-goma", "--xcode-symlinks", @@ -381,6 +327,7 @@ "name": "Host Tests for host_debug_unopt_arm64", "script": "flutter/testing/run_tests.py", "parameters": [ + "--quiet", "--variant", "ci/host_debug_unopt_arm64", "--type", @@ -388,6 +335,11 @@ "--engine-capture-core-dump", "--no-skia-gold" ] + }, + { + "name": "Tests of tools/gn", + "language": "python3", + "script": "flutter/tools/gn_test.py" } ] }, @@ -439,6 +391,7 @@ "name": "Tests for ios_debug_unopt_sim_arm64", "script": "flutter/testing/run_tests.py", "parameters": [ + "--quiet", "--variant", "ci/ios_debug_unopt_sim_arm64", "--type", @@ -507,6 +460,7 @@ "name": "Tests for ios_debug_unopt_sim_arm64_extension_safe", "script": "flutter/testing/run_tests.py", "parameters": [ + "--quiet", "--variant", "ci/ios_debug_unopt_sim_arm64_extension_safe", "--type", diff --git a/ci/licenses_golden/licenses_skia b/ci/licenses_golden/licenses_skia index f80744d86965d..75834b7b17053 100644 --- a/ci/licenses_golden/licenses_skia +++ b/ci/licenses_golden/licenses_skia @@ -1,4 +1,4 @@ -Signature: e41e121402f1b734a9825aaa785d49a8 +Signature: 086870b9a7d2aa20c37a6b0ade0856e2 ==================================================================================================== LIBRARY: etc1 diff --git a/impeller/compiler/shader_lib/impeller/dithering.glsl b/impeller/compiler/shader_lib/impeller/dithering.glsl index e8e4e8439ce67..5494c4ecee45c 100644 --- a/impeller/compiler/shader_lib/impeller/dithering.glsl +++ b/impeller/compiler/shader_lib/impeller/dithering.glsl @@ -45,9 +45,6 @@ vec4 IPOrderedDither8x8(vec4 color, vec2 dest) { // Apply the dither to the color. color.rgb += dither * kDitherRate; - // Clamp the color values to [0,1]. - color.rgb = clamp(color.rgb, 0.0, 1.0); - return color; } diff --git a/impeller/display_list/aiks_dl_unittests.cc b/impeller/display_list/aiks_dl_unittests.cc index 133bb89169eab..e5dc1441f1707 100644 --- a/impeller/display_list/aiks_dl_unittests.cc +++ b/impeller/display_list/aiks_dl_unittests.cc @@ -3,12 +3,14 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +#include #include "display_list/dl_sampling_options.h" #include "display_list/dl_tile_mode.h" #include "display_list/effects/dl_color_filter.h" #include "display_list/effects/dl_color_source.h" #include "display_list/effects/dl_image_filter.h" #include "display_list/geometry/dl_geometry_types.h" +#include "display_list/geometry/dl_path.h" #include "display_list/image/dl_image.h" #include "flutter/impeller/display_list/aiks_unittests.h" @@ -21,7 +23,9 @@ #include "impeller/display_list/dl_dispatcher.h" #include "impeller/display_list/dl_image_impeller.h" #include "impeller/geometry/scalar.h" +#include "include/core/SkCanvas.h" #include "include/core/SkMatrix.h" +#include "include/core/SkPath.h" #include "include/core/SkRSXform.h" #include "include/core/SkRefCnt.h" @@ -974,5 +978,61 @@ TEST_P(AiksTest, CanEmptyPictureConvertToImage) { ASSERT_TRUE(OpenPlaygroundHere(recorder_builder.Build())); } +TEST_P(AiksTest, DepthValuesForLineMode) { + // Ensures that the additional draws created by line/polygon mode all + // have the same depth values. + DisplayListBuilder builder; + + SkPath path = SkPath::Circle(100, 100, 100); + + builder.DrawPath(path, DlPaint() + .setColor(DlColor::kRed()) + .setDrawStyle(DlDrawStyle::kStroke) + .setStrokeWidth(5)); + builder.Save(); + builder.ClipPath(path); + + std::vector points = { + SkPoint::Make(0, -200), SkPoint::Make(400, 200), SkPoint::Make(0, -100), + SkPoint::Make(400, 300), SkPoint::Make(0, 0), SkPoint::Make(400, 400), + SkPoint::Make(0, 100), SkPoint::Make(400, 500), SkPoint::Make(0, 150), + SkPoint::Make(400, 600)}; + + builder.DrawPoints(DisplayListBuilder::PointMode::kLines, points.size(), + points.data(), + DlPaint().setColor(DlColor::kBlue()).setStrokeWidth(10)); + builder.Restore(); + + ASSERT_TRUE(OpenPlaygroundHere(builder.Build())); +} + +TEST_P(AiksTest, DepthValuesForPolygonMode) { + // Ensures that the additional draws created by line/polygon mode all + // have the same depth values. + DisplayListBuilder builder; + + SkPath path = SkPath::Circle(100, 100, 100); + + builder.DrawPath(path, DlPaint() + .setColor(DlColor::kRed()) + .setDrawStyle(DlDrawStyle::kStroke) + .setStrokeWidth(5)); + builder.Save(); + builder.ClipPath(path); + + std::vector points = { + SkPoint::Make(0, -200), SkPoint::Make(400, 200), SkPoint::Make(0, -100), + SkPoint::Make(400, 300), SkPoint::Make(0, 0), SkPoint::Make(400, 400), + SkPoint::Make(0, 100), SkPoint::Make(400, 500), SkPoint::Make(0, 150), + SkPoint::Make(400, 600)}; + + builder.DrawPoints(DisplayListBuilder::PointMode::kPolygon, points.size(), + points.data(), + DlPaint().setColor(DlColor::kBlue()).setStrokeWidth(10)); + builder.Restore(); + + ASSERT_TRUE(OpenPlaygroundHere(builder.Build())); +} + } // namespace testing } // namespace impeller diff --git a/impeller/display_list/canvas.cc b/impeller/display_list/canvas.cc index d14db69dc0d69..2634760368a06 100644 --- a/impeller/display_list/canvas.cc +++ b/impeller/display_list/canvas.cc @@ -543,13 +543,17 @@ bool Canvas::AttemptDrawBlurredRRect(const Rect& rect, return true; } -void Canvas::DrawLine(const Point& p0, const Point& p1, const Paint& paint) { +void Canvas::DrawLine(const Point& p0, + const Point& p1, + const Paint& paint, + bool reuse_depth) { Entity entity; entity.SetTransform(GetCurrentTransform()); entity.SetBlendMode(paint.blend_mode); LineGeometry geom(p0, p1, paint.stroke_width, paint.stroke_cap); - AddRenderEntityWithFiltersToCurrentPass(entity, &geom, paint); + AddRenderEntityWithFiltersToCurrentPass(entity, &geom, paint, + /*reuse_depth=*/reuse_depth); } void Canvas::DrawRect(const Rect& rect, const Paint& paint) { diff --git a/impeller/display_list/canvas.h b/impeller/display_list/canvas.h index 387cb5c81dc33..fd5e54169b18d 100644 --- a/impeller/display_list/canvas.h +++ b/impeller/display_list/canvas.h @@ -167,7 +167,10 @@ class Canvas { void DrawPaint(const Paint& paint); - void DrawLine(const Point& p0, const Point& p1, const Paint& paint); + void DrawLine(const Point& p0, + const Point& p1, + const Paint& paint, + bool reuse_depth = false); void DrawRect(const Rect& rect, const Paint& paint); diff --git a/impeller/display_list/dl_dispatcher.cc b/impeller/display_list/dl_dispatcher.cc index ffdd0c6eca9f8..c803f549752bf 100644 --- a/impeller/display_list/dl_dispatcher.cc +++ b/impeller/display_list/dl_dispatcher.cc @@ -675,7 +675,7 @@ void DlDispatcherBase::drawPoints(PointMode mode, for (uint32_t i = 1; i < count; i += 2) { Point p0 = points[i - 1]; Point p1 = points[i]; - GetCanvas().DrawLine(p0, p1, paint); + GetCanvas().DrawLine(p0, p1, paint, /*reuse_depth=*/i > 1); } break; case flutter::DlCanvas::PointMode::kPolygon: @@ -683,7 +683,7 @@ void DlDispatcherBase::drawPoints(PointMode mode, Point p0 = points[0]; for (uint32_t i = 1; i < count; i++) { Point p1 = points[i]; - GetCanvas().DrawLine(p0, p1, paint); + GetCanvas().DrawLine(p0, p1, paint, /*reuse_depth=*/i > 1); p0 = p1; } } diff --git a/impeller/entity/contents/filters/gaussian_blur_filter_contents.cc b/impeller/entity/contents/filters/gaussian_blur_filter_contents.cc index d2e39ead1817b..b70c717394894 100644 --- a/impeller/entity/contents/filters/gaussian_blur_filter_contents.cc +++ b/impeller/entity/contents/filters/gaussian_blur_filter_contents.cc @@ -239,7 +239,7 @@ DownsamplePassArgs CalculateDownsamplePassArgs( // .Contains(coverage_hint.value())) std::optional snapshot_coverage = input_snapshot.GetCoverage(); - if (input_snapshot.transform.IsIdentity() && + if (input_snapshot.transform.Equals(snapshot_entity.GetTransform()) && source_expanded_coverage_hint.has_value() && snapshot_coverage.has_value() && snapshot_coverage->Contains(source_expanded_coverage_hint.value())) { diff --git a/impeller/geometry/matrix.h b/impeller/geometry/matrix.h index 1726c30151fdf..166fc11f9609f 100644 --- a/impeller/geometry/matrix.h +++ b/impeller/geometry/matrix.h @@ -407,6 +407,27 @@ struct Matrix { std::optional Decompose() const; + bool Equals(const Matrix& matrix, Scalar epsilon = 1e-5f) const { + const Scalar* a = m; + const Scalar* b = matrix.m; + return ScalarNearlyEqual(a[0], b[0], epsilon) && + ScalarNearlyEqual(a[1], b[1], epsilon) && + ScalarNearlyEqual(a[2], b[2], epsilon) && + ScalarNearlyEqual(a[3], b[3], epsilon) && + ScalarNearlyEqual(a[4], b[4], epsilon) && + ScalarNearlyEqual(a[5], b[5], epsilon) && + ScalarNearlyEqual(a[6], b[6], epsilon) && + ScalarNearlyEqual(a[7], b[7], epsilon) && + ScalarNearlyEqual(a[8], b[8], epsilon) && + ScalarNearlyEqual(a[9], b[9], epsilon) && + ScalarNearlyEqual(a[10], b[10], epsilon) && + ScalarNearlyEqual(a[11], b[11], epsilon) && + ScalarNearlyEqual(a[12], b[12], epsilon) && + ScalarNearlyEqual(a[13], b[13], epsilon) && + ScalarNearlyEqual(a[14], b[14], epsilon) && + ScalarNearlyEqual(a[15], b[15], epsilon); + } + constexpr bool operator==(const Matrix& m) const { // clang-format off return vec[0] == m.vec[0] diff --git a/impeller/geometry/matrix_unittests.cc b/impeller/geometry/matrix_unittests.cc index ac160086994e3..91a2a50f2a7d4 100644 --- a/impeller/geometry/matrix_unittests.cc +++ b/impeller/geometry/matrix_unittests.cc @@ -25,6 +25,18 @@ TEST(MatrixTest, Multiply) { 11.0, 21.0, 0.0, 1.0))); } +TEST(MatrixTest, Equals) { + Matrix x; + Matrix y = x; + EXPECT_TRUE(x.Equals(y)); +} + +TEST(MatrixTest, NotEquals) { + Matrix x; + Matrix y = x.Translate({1, 0, 0}); + EXPECT_FALSE(x.Equals(y)); +} + TEST(MatrixTest, HasPerspective2D) { EXPECT_FALSE(Matrix().HasPerspective2D()); diff --git a/impeller/geometry/scalar.h b/impeller/geometry/scalar.h index 2600a49c42dc5..dadc52850cb5f 100644 --- a/impeller/geometry/scalar.h +++ b/impeller/geometry/scalar.h @@ -22,6 +22,11 @@ constexpr T Absolute(const T& val) { return val >= T{} ? val : -val; } +template <> +constexpr Scalar Absolute(const float& val) { + return fabsf(val); +} + constexpr inline bool ScalarNearlyZero(Scalar x, Scalar tolerance = kEhCloseEnough) { return Absolute(x) <= tolerance; diff --git a/impeller/renderer/backend/gles/render_pass_gles.cc b/impeller/renderer/backend/gles/render_pass_gles.cc index 1625dc03ac4a3..a9449a8643188 100644 --- a/impeller/renderer/backend/gles/render_pass_gles.cc +++ b/impeller/renderer/backend/gles/render_pass_gles.cc @@ -338,8 +338,6 @@ struct RenderPassData { scissor.GetWidth(), // width scissor.GetHeight() // height ); - } else { - gl.Disable(GL_SCISSOR_TEST); } //-------------------------------------------------------------------------- diff --git a/impeller/renderer/backend/vulkan/driver_info_vk.cc b/impeller/renderer/backend/vulkan/driver_info_vk.cc index e0fdb5728d44e..fc96c828b613b 100644 --- a/impeller/renderer/backend/vulkan/driver_info_vk.cc +++ b/impeller/renderer/backend/vulkan/driver_info_vk.cc @@ -330,20 +330,49 @@ bool DriverInfoVK::IsEmulator() const { bool DriverInfoVK::IsKnownBadDriver() const { if (adreno_gpu_.has_value()) { - auto adreno = adreno_gpu_.value(); + AdrenoGPU adreno = adreno_gpu_.value(); + // See: + // https://github.com/flutter/flutter/issues/154103 + // + // Reports "VK_INCOMPLETE" when compiling certain entity shader with + // vkCreateGraphicsPipelines, which is not a valid return status. + // See https://github.com/flutter/flutter/issues/155185 . + // + // https://github.com/flutter/flutter/issues/155185 + // Unknown crashes but device is not easily acquirable. switch (adreno) { - // see: - // https://github.com/flutter/flutter/issues/154103 - // - // Reports "VK_INCOMPLETE" when compiling certain entity shader with - // vkCreateGraphicsPipelines, which is not a valid return status. - // See https://github.com/flutter/flutter/issues/155185 . + case AdrenoGPU::kAdreno640: case AdrenoGPU::kAdreno630: + case AdrenoGPU::kAdreno620: + case AdrenoGPU::kAdreno619: + case AdrenoGPU::kAdreno619L: + case AdrenoGPU::kAdreno618: + case AdrenoGPU::kAdreno616: + case AdrenoGPU::kAdreno615: + case AdrenoGPU::kAdreno613: + case AdrenoGPU::kAdreno612: + case AdrenoGPU::kAdreno610: + case AdrenoGPU::kAdreno608: + case AdrenoGPU::kAdreno605: + case AdrenoGPU::kAdreno540: + case AdrenoGPU::kAdreno530: + case AdrenoGPU::kAdreno512: + case AdrenoGPU::kAdreno510: + case AdrenoGPU::kAdreno509: + case AdrenoGPU::kAdreno508: + case AdrenoGPU::kAdreno506: + case AdrenoGPU::kAdreno505: + case AdrenoGPU::kAdreno504: return true; default: return false; } } + // Disable Maleoon series GPUs, see: + // https://github.com/flutter/flutter/issues/156623 + if (vendor_ == VendorVK::kHuawei) { + return true; + } return false; } diff --git a/impeller/renderer/backend/vulkan/driver_info_vk_unittests.cc b/impeller/renderer/backend/vulkan/driver_info_vk_unittests.cc index 59f0733b8b4e4..67f07815320af 100644 --- a/impeller/renderer/backend/vulkan/driver_info_vk_unittests.cc +++ b/impeller/renderer/backend/vulkan/driver_info_vk_unittests.cc @@ -36,6 +36,19 @@ TEST_P(DriverInfoVKTest, CanDumpToLog) { EXPECT_TRUE(log.str().find("Driver Information") != std::string::npos); } +TEST(DriverInfoVKTest, CanIdentifyBadMaleoonDriver) { + auto const context = + MockVulkanContextBuilder() + .SetPhysicalPropertiesCallback( + [](VkPhysicalDevice device, VkPhysicalDeviceProperties* prop) { + prop->vendorID = 0x19E5; // Huawei + prop->deviceType = VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU; + }) + .Build(); + + EXPECT_TRUE(context->GetDriverInfo()->IsKnownBadDriver()); +} + bool IsBadVersionTest(std::string_view driver_name, bool qc = true) { auto const context = MockVulkanContextBuilder() @@ -67,6 +80,18 @@ TEST(DriverInfoVKTest, DriverParsingArm) { TEST(DriverInfoVKTest, DisabledDevices) { EXPECT_TRUE(IsBadVersionTest("Adreno (TM) 630")); + EXPECT_TRUE(IsBadVersionTest("Adreno (TM) 620")); + EXPECT_TRUE(IsBadVersionTest("Adreno (TM) 610")); + EXPECT_TRUE(IsBadVersionTest("Adreno (TM) 530")); + EXPECT_TRUE(IsBadVersionTest("Adreno (TM) 512")); + EXPECT_TRUE(IsBadVersionTest("Adreno (TM) 509")); + EXPECT_TRUE(IsBadVersionTest("Adreno (TM) 508")); + EXPECT_TRUE(IsBadVersionTest("Adreno (TM) 506")); + EXPECT_TRUE(IsBadVersionTest("Adreno (TM) 505")); + EXPECT_TRUE(IsBadVersionTest("Adreno (TM) 504")); + EXPECT_TRUE(IsBadVersionTest("Adreno (TM) 640")); + + EXPECT_FALSE(IsBadVersionTest("Adreno (TM) 650")); } TEST(DriverInfoVKTest, EnabledDevicesMali) { @@ -83,13 +108,6 @@ TEST(DriverInfoVKTest, EnabledDevicesAdreno) { EXPECT_FALSE(IsBadVersionTest("Adreno (TM) 720")); EXPECT_FALSE(IsBadVersionTest("Adreno (TM) 710")); EXPECT_FALSE(IsBadVersionTest("Adreno (TM) 702")); - EXPECT_FALSE(IsBadVersionTest("Adreno (TM) 530")); - EXPECT_FALSE(IsBadVersionTest("Adreno (TM) 512")); - EXPECT_FALSE(IsBadVersionTest("Adreno (TM) 509")); - EXPECT_FALSE(IsBadVersionTest("Adreno (TM) 508")); - EXPECT_FALSE(IsBadVersionTest("Adreno (TM) 506")); - EXPECT_FALSE(IsBadVersionTest("Adreno (TM) 505")); - EXPECT_FALSE(IsBadVersionTest("Adreno (TM) 504")); } } // namespace impeller::testing diff --git a/impeller/renderer/backend/vulkan/swapchain/swapchain_vk.cc b/impeller/renderer/backend/vulkan/swapchain/swapchain_vk.cc index 1b872778dbc33..195b06f663e43 100644 --- a/impeller/renderer/backend/vulkan/swapchain/swapchain_vk.cc +++ b/impeller/renderer/backend/vulkan/swapchain/swapchain_vk.cc @@ -56,32 +56,6 @@ std::shared_ptr SwapchainVK::Create( return nullptr; } - // TODO(147533): AHB swapchains on emulators are not functional. - auto& context_vk = ContextVK::Cast(*context); - const auto emulator = context_vk.GetDriverInfo()->IsEmulator(); - const auto should_disable_sc = - context_vk.GetShouldDisableSurfaceControlSwapchain(); - - // Try AHB swapchains first. - if (!emulator && AHBSwapchainVK::IsAvailableOnPlatform() && - !android::ShadowRealm::ShouldDisableAHB() && !should_disable_sc) { - auto ahb_swapchain = std::shared_ptr(new AHBSwapchainVK( - context, // - window.GetHandle(), // - surface, // - window.GetSize(), // - enable_msaa // - )); - - if (ahb_swapchain->IsValid()) { - return ahb_swapchain; - } else { - VALIDATION_LOG - << "Could not create AHB swapchain. Falling back to KHR variant."; - } - } - - // Fallback to KHR swapchains if AHB swapchains aren't available. return Create(context, std::move(surface), window.GetSize(), enable_msaa); } #endif // FML_OS_ANDROID diff --git a/impeller/toolkit/android/shadow_realm.cc b/impeller/toolkit/android/shadow_realm.cc index 6d19e6fe459f4..312f45c116a74 100644 --- a/impeller/toolkit/android/shadow_realm.cc +++ b/impeller/toolkit/android/shadow_realm.cc @@ -15,13 +15,23 @@ bool ShadowRealm::ShouldDisableAHB() { __system_property_get("ro.com.google.clientidbase", clientidbase); auto api_level = android_get_device_api_level(); + char first_api_level[PROP_VALUE_MAX]; + __system_property_get("ro.product.first_api_level", first_api_level); - return ShouldDisableAHBInternal(clientidbase, api_level); + return ShouldDisableAHBInternal(clientidbase, first_api_level, api_level); } // static bool ShadowRealm::ShouldDisableAHBInternal(std::string_view clientidbase, + std::string_view first_api_level, uint32_t api_level) { + // Most devices that have updated to API 29 don't seem to correctly + // support AHBs: https://github.com/flutter/flutter/issues/157113 + if (first_api_level == "28" || first_api_level == "27" || + first_api_level == "26" || first_api_level == "25" || + first_api_level == "24") { + return true; + } // From local testing, neither the swapchain nor AHB import works, see also: // https://github.com/flutter/flutter/issues/154068 if (clientidbase == kAndroidHuawei && api_level <= 29) { diff --git a/impeller/toolkit/android/shadow_realm.h b/impeller/toolkit/android/shadow_realm.h index 90f913b5af3bc..90f265ec612b6 100644 --- a/impeller/toolkit/android/shadow_realm.h +++ b/impeller/toolkit/android/shadow_realm.h @@ -18,6 +18,7 @@ class ShadowRealm { // For testing. static bool ShouldDisableAHBInternal(std::string_view clientidbase, + std::string_view first_api_level, uint32_t api_level); }; diff --git a/impeller/toolkit/android/toolkit_android_unittests.cc b/impeller/toolkit/android/toolkit_android_unittests.cc index c96e04c2dfe9c..ca8837d732ee2 100644 --- a/impeller/toolkit/android/toolkit_android_unittests.cc +++ b/impeller/toolkit/android/toolkit_android_unittests.cc @@ -136,11 +136,17 @@ TEST(ToolkitAndroidTest, CanPostAndWaitForFrameCallbacks) { } TEST(ToolkitAndroidTest, ShouldDisableAHB) { - EXPECT_FALSE(ShadowRealm::ShouldDisableAHB()); - - EXPECT_TRUE(ShadowRealm::ShouldDisableAHBInternal("android-huawei", 29)); - EXPECT_FALSE(ShadowRealm::ShouldDisableAHBInternal("android-huawei", 30)); - EXPECT_FALSE(ShadowRealm::ShouldDisableAHBInternal("something made up", 29)); + EXPECT_FALSE( + ShadowRealm::ShouldDisableAHBInternal("android-huawei", "30", 30)); + EXPECT_FALSE( + ShadowRealm::ShouldDisableAHBInternal("something made up", "29", 29)); + + EXPECT_TRUE( + ShadowRealm::ShouldDisableAHBInternal("android-huawei", "29", 29)); + EXPECT_TRUE( + ShadowRealm::ShouldDisableAHBInternal("something made up", "27", 29)); + EXPECT_TRUE( + ShadowRealm::ShouldDisableAHBInternal("android-huawei", "garbage", 29)); } } // namespace impeller::android::testing diff --git a/impeller/tools/malioc.json b/impeller/tools/malioc.json index aafabbc2af84b..787254a4fa0e5 100644 --- a/impeller/tools/malioc.json +++ b/impeller/tools/malioc.json @@ -586,7 +586,7 @@ "shortest_path_cycles": [ 0.5, 0.109375, - 0.296875, + 0.28125, 0.5, 0.0, 0.25, @@ -596,9 +596,9 @@ "load_store" ], "total_cycles": [ - 1.4249999523162842, + 1.40625, 0.862500011920929, - 1.4249999523162842, + 1.40625, 0.875, 4.0, 0.25, @@ -636,7 +636,7 @@ "longest_path_cycles": [ 0.5, 0.109375, - 0.125, + 0.109375, 0.5, 0.0, 0.25, @@ -658,7 +658,7 @@ "shortest_path_cycles": [ 0.5, 0.109375, - 0.125, + 0.109375, 0.5, 0.0, 0.25, @@ -671,7 +671,7 @@ "total_cycles": [ 0.5, 0.109375, - 0.125, + 0.109375, 0.5, 0.0, 0.25, @@ -6298,7 +6298,7 @@ "shortest_path_cycles": [ 0.5625, 0.203125, - 0.265625, + 0.25, 0.5625, 0.0, 0.25, @@ -6308,9 +6308,9 @@ "load_store" ], "total_cycles": [ - 0.71875, + 0.699999988079071, 0.40625, - 0.71875, + 0.699999988079071, 0.5625, 4.0, 0.25, @@ -6768,7 +6768,7 @@ "shortest_path_cycles": [ 0.5625, 0.21875, - 0.28125, + 0.265625, 0.5625, 0.0, 0.25, @@ -6778,9 +6778,9 @@ "load_store" ], "total_cycles": [ - 0.71875, + 0.699999988079071, 0.421875, - 0.71875, + 0.699999988079071, 0.625, 4.0, 0.25, diff --git a/lib/ui/fixtures/out_of_bounds.apng b/lib/ui/fixtures/out_of_bounds.apng new file mode 100644 index 0000000000000..33993c7960e96 Binary files /dev/null and b/lib/ui/fixtures/out_of_bounds.apng differ diff --git a/lib/ui/fixtures/out_of_bounds_wrapping.apng b/lib/ui/fixtures/out_of_bounds_wrapping.apng new file mode 100644 index 0000000000000..e4cc5d50cfe5f Binary files /dev/null and b/lib/ui/fixtures/out_of_bounds_wrapping.apng differ diff --git a/lib/ui/painting/image_decoder_impeller.cc b/lib/ui/painting/image_decoder_impeller.cc index adb06b435fcb7..6a1118954a9be 100644 --- a/lib/ui/painting/image_decoder_impeller.cc +++ b/lib/ui/painting/image_decoder_impeller.cc @@ -483,6 +483,9 @@ ImageDecoderImpeller::UploadTextureToStorage( } texture->SetLabel(impeller::SPrintF("ui.Image(%p)", texture.get()).c_str()); + + context->DisposeThreadLocalCachedResources(); + return std::make_pair(impeller::DlImageImpeller::Make(std::move(texture)), std::string()); } diff --git a/lib/ui/painting/image_decoder_no_gl_unittests.cc b/lib/ui/painting/image_decoder_no_gl_unittests.cc index d935fa2e3e9f9..e9b8c208d0b09 100644 --- a/lib/ui/painting/image_decoder_no_gl_unittests.cc +++ b/lib/ui/painting/image_decoder_no_gl_unittests.cc @@ -187,7 +187,7 @@ TEST(ImageDecoderNoGLTest, ImpellerWideGamutIndexedPng) { #endif // IMPELLER_SUPPORTS_RENDERING } -TEST(ImageDecoderNoGLTest, ImepllerUnmultipliedAlphaPng) { +TEST(ImageDecoderNoGLTest, ImpellerUnmultipliedAlphaPng) { #if defined(OS_FUCHSIA) GTEST_SKIP() << "Fuchsia can't load the test fixtures."; #endif diff --git a/lib/ui/painting/image_decoder_unittests.cc b/lib/ui/painting/image_decoder_unittests.cc index d8f76671ae09b..69096eeca3866 100644 --- a/lib/ui/painting/image_decoder_unittests.cc +++ b/lib/ui/painting/image_decoder_unittests.cc @@ -92,8 +92,12 @@ class TestImpellerContext : public impeller::Context { tasks_.clear(); } + void DisposeThreadLocalCachedResources() override { did_dispose_ = true; } + void Shutdown() override {} + bool DidDisposeResources() const { return did_dispose_; } + mutable size_t command_buffer_count_ = 0; private: @@ -103,6 +107,7 @@ class TestImpellerContext : public impeller::Context { }; std::vector tasks_; std::shared_ptr capabilities_; + bool did_dispose_ = false; }; } // namespace impeller @@ -367,12 +372,14 @@ TEST_F(ImageDecoderFixtureTest, ImpellerUploadToSharedNoGpu) { EXPECT_EQ(no_gpu_access_context->command_buffer_count_, 0ul); EXPECT_FALSE(invoked); + EXPECT_EQ(no_gpu_access_context->DidDisposeResources(), false); auto result = ImageDecoderImpeller::UploadTextureToStorage( no_gpu_access_context, bitmap); ASSERT_EQ(no_gpu_access_context->command_buffer_count_, 0ul); ASSERT_EQ(result.second, ""); + EXPECT_EQ(no_gpu_access_context->DidDisposeResources(), true); no_gpu_access_context->FlushTasks(/*fail=*/true); } diff --git a/lib/ui/painting/image_generator_apng.cc b/lib/ui/painting/image_generator_apng.cc index 159d87638aeb2..08182c9ff2bd4 100644 --- a/lib/ui/painting/image_generator_apng.cc +++ b/lib/ui/painting/image_generator_apng.cc @@ -110,6 +110,28 @@ bool APNGImageGenerator::GetPixels(const SkImageInfo& info, << ") of APNG due to the frame missing data (frame_info)."; return false; } + if ( + // Check for unsigned integer wrapping for + // frame.{x|y}_offset + frame_info.{width|height}(). + frame.x_offset > + std::numeric_limits::max() - frame_info.width() || + frame.y_offset > + std::numeric_limits::max() - frame_info.height() || + + frame.x_offset + frame_info.width() > + static_cast(info.width()) || + frame.y_offset + frame_info.height() > + static_cast(info.height())) { + FML_DLOG(ERROR) + << "Decoded image at index " << image_index + << " (frame index: " << frame_index + << ") rejected because the destination region (x: " << frame.x_offset + << ", y: " << frame.y_offset << ", width: " << frame_info.width() + << ", height: " << frame_info.height() + << ") is not entirely within the destination surface (width: " + << info.width() << ", height: " << info.height() << ")."; + return false; + } //---------------------------------------------------------------------------- /// 3. Composite the frame onto the canvas. @@ -630,7 +652,19 @@ uint32_t APNGImageGenerator::ChunkHeader::ComputeChunkCrc32() { bool APNGImageGenerator::RenderDefaultImage(const SkImageInfo& info, void* pixels, size_t row_bytes) { - SkCodec::Result result = images_[0].codec->getPixels(info, pixels, row_bytes); + APNGImage& frame = images_[0]; + SkImageInfo frame_info = frame.codec->getInfo(); + if (frame_info.width() > info.width() || + frame_info.height() > info.height()) { + FML_DLOG(ERROR) + << "Default image rejected because the destination region (width: " + << frame_info.width() << ", height: " << frame_info.height() + << ") is not entirely within the destination surface (width: " + << info.width() << ", height: " << info.height() << ")."; + return false; + } + + SkCodec::Result result = frame.codec->getPixels(info, pixels, row_bytes); if (result != SkCodec::kSuccess) { FML_DLOG(ERROR) << "Failed to decode the APNG's default/fallback image. " "SkCodec::Result: " diff --git a/lib/web_ui/lib/src/engine/canvaskit/image.dart b/lib/web_ui/lib/src/engine/canvaskit/image.dart index 812c98feb2099..306ff752c2563 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/image.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/image.dart @@ -7,6 +7,7 @@ import 'dart:js_interop'; import 'dart:math' as math; import 'dart:typed_data'; +import 'package:meta/meta.dart'; import 'package:ui/src/engine.dart'; import 'package:ui/ui.dart' as ui; import 'package:ui/ui_web/src/ui_web.dart' as ui_web; @@ -160,7 +161,7 @@ ui.Image createCkImageFromImageElement( } class CkImageElementCodec extends HtmlImageElementCodec { - CkImageElementCodec(super.src); + CkImageElementCodec(super.src, {super.chunkCallback}); @override ui.Image createImageFromHTMLImageElement( @@ -169,7 +170,7 @@ class CkImageElementCodec extends HtmlImageElementCodec { } class CkImageBlobCodec extends HtmlBlobCodec { - CkImageBlobCodec(super.blob); + CkImageBlobCodec(super.blob, {super.chunkCallback}); @override ui.Image createImageFromHTMLImageElement( @@ -325,7 +326,7 @@ const String _kNetworkImageMessage = 'Failed to load network image.'; /// requesting from URI. Future skiaInstantiateWebImageCodec( String url, ui_web.ImageCodecChunkCallback? chunkCallback) async { - final CkImageElementCodec imageElementCodec = CkImageElementCodec(url); + final CkImageElementCodec imageElementCodec = CkImageElementCodec(url, chunkCallback: chunkCallback); try { await imageElementCodec.decode(); return imageElementCodec; @@ -338,7 +339,7 @@ Future skiaInstantiateWebImageCodec( data: list, contentType: imageType.mimeType, debugSource: url); } else { final DomBlob blob = createDomBlob([list.buffer]); - final CkImageBlobCodec codec = CkImageBlobCodec(blob); + final CkImageBlobCodec codec = CkImageBlobCodec(blob, chunkCallback: chunkCallback); try { await codec.decode(); @@ -403,11 +404,13 @@ class CkImage implements ui.Image, StackTraceDebugger { CkImage(SkImage skImage, {this.imageSource}) { box = CountedRef(skImage, this, 'SkImage'); _init(); + imageSource?.refCount++; } CkImage.cloneOf(this.box, {this.imageSource}) { _init(); box.ref(this); + imageSource?.refCount++; } void _init() { @@ -454,6 +457,8 @@ class CkImage implements ui.Image, StackTraceDebugger { ui.Image.onDispose?.call(this); _disposed = true; box.unref(this); + + imageSource?.refCount--; imageSource?.close(); } @@ -645,7 +650,26 @@ sealed class ImageSource { DomCanvasImageSource get canvasImageSource; int get width; int get height; - void close(); + + /// The number of references to this image source. + /// + /// Calling [close] is a no-op if [refCount] is greater than 0. + /// + /// Only when [refCount] is 0 will the [close] method actually close the + /// image source. + int refCount = 0; + + @visibleForTesting + bool debugIsClosed = false; + + void close() { + if (refCount == 0) { + _doClose(); + debugIsClosed = true; + } + } + + void _doClose(); } class VideoFrameImageSource extends ImageSource { @@ -654,7 +678,7 @@ class VideoFrameImageSource extends ImageSource { final VideoFrame videoFrame; @override - void close() { + void _doClose() { // Do nothing. Skia will close the VideoFrame when the SkImage is disposed. } @@ -674,7 +698,7 @@ class ImageElementImageSource extends ImageSource { final DomHTMLImageElement imageElement; @override - void close() { + void _doClose() { // There's no way to immediately close the element. Just let the // browser garbage collect it. } @@ -695,7 +719,7 @@ class ImageBitmapImageSource extends ImageSource { final DomImageBitmap imageBitmap; @override - void close() { + void _doClose() { imageBitmap.close(); } diff --git a/lib/web_ui/lib/src/engine/dom.dart b/lib/web_ui/lib/src/engine/dom.dart index 5fb335f11b728..336ddadbab602 100644 --- a/lib/web_ui/lib/src/engine/dom.dart +++ b/lib/web_ui/lib/src/engine/dom.dart @@ -169,6 +169,12 @@ extension DomWindowExtension on DomWindow { /// The Trusted Types API (when available). /// See: https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API external DomTrustedTypePolicyFactory? get trustedTypes; + + @JS('createImageBitmap') + external JSPromise _createImageBitmap(DomImageData source); + Future createImageBitmap(DomImageData source) { + return js_util.promiseToFuture(_createImageBitmap(source)); + } } typedef DomRequestAnimationFrameCallback = void Function(JSNumber highResTime); @@ -984,6 +990,22 @@ extension DomHTMLImageElementExtension on DomHTMLImageElement { external set _height(JSNumber? value); set height(double? value) => _height = value?.toJS; + @JS('crossOrigin') + external JSString? get _crossOrigin; + String? get crossOrigin => _crossOrigin?.toDart; + + @JS('crossOrigin') + external set _crossOrigin(JSString? value); + set crossOrigin(String? value) => _crossOrigin = value?.toJS; + + @JS('decoding') + external JSString? get _decoding; + String? get decoding => _decoding?.toDart; + + @JS('decoding') + external set _decoding(JSString? value); + set decoding(String? value) => _decoding = value?.toJS; + @JS('decode') external JSPromise _decode(); Future decode() => js_util.promiseToFuture(_decode()); diff --git a/lib/web_ui/lib/src/engine/html_image_element_codec.dart b/lib/web_ui/lib/src/engine/html_image_element_codec.dart index 2bd6ccabc54f0..9784345e999f3 100644 --- a/lib/web_ui/lib/src/engine/html_image_element_codec.dart +++ b/lib/web_ui/lib/src/engine/html_image_element_codec.dart @@ -43,8 +43,13 @@ abstract class HtmlImageElementCodec implements ui.Codec { // builders to create UI. chunkCallback?.call(0, 100); imgElement = createDomHTMLImageElement(); - imgElement!.src = src; - setJsProperty(imgElement!, 'decoding', 'async'); + if (renderer is! HtmlRenderer) { + imgElement!.crossOrigin = 'anonymous'; + } + imgElement! + ..decoding = 'async' + ..src = src; + // Ignoring the returned future on purpose because we're communicating // through the `completer`. @@ -91,7 +96,7 @@ abstract class HtmlImageElementCodec implements ui.Codec { } abstract class HtmlBlobCodec extends HtmlImageElementCodec { - HtmlBlobCodec(this.blob) + HtmlBlobCodec(this.blob, {super.chunkCallback}) : super( domWindow.URL.createObjectURL(blob), debugSource: 'encoded image bytes', diff --git a/lib/web_ui/lib/src/engine/pointer_binding.dart b/lib/web_ui/lib/src/engine/pointer_binding.dart index 11502593eb97e..228b30a7f5a19 100644 --- a/lib/web_ui/lib/src/engine/pointer_binding.dart +++ b/lib/web_ui/lib/src/engine/pointer_binding.dart @@ -1010,20 +1010,32 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin { }); // Why `domWindow` you ask? See this fiddle: https://jsfiddle.net/ditman/7towxaqp - _addPointerEventListener(_globalTarget, 'pointermove', (DomPointerEvent event) { - final int device = _getPointerId(event); + _addPointerEventListener(_globalTarget, 'pointermove', (DomPointerEvent moveEvent) { + final int device = _getPointerId(moveEvent); final _ButtonSanitizer sanitizer = _ensureSanitizer(device); final List pointerData = []; - final List expandedEvents = _expandEvents(event); + final List expandedEvents = _expandEvents(moveEvent); for (final DomPointerEvent event in expandedEvents) { final _SanitizedDetails? up = sanitizer.sanitizeMissingRightClickUp(buttons: event.buttons!.toInt()); if (up != null) { - _convertEventsToPointerData(data: pointerData, event: event, details: up); + _convertEventsToPointerData( + data: pointerData, + event: event, + details: up, + pointerId: device, + eventTarget: moveEvent.target, + ); } final _SanitizedDetails move = sanitizer.sanitizeMoveEvent(buttons: event.buttons!.toInt()); - _convertEventsToPointerData(data: pointerData, event: event, details: move); + _convertEventsToPointerData( + data: pointerData, + event: event, + details: move, + pointerId: device, + eventTarget: moveEvent.target, + ); } - _callback(event, pointerData); + _callback(moveEvent, pointerData); }); _addPointerEventListener(_viewTarget, 'pointerleave', (DomPointerEvent event) { @@ -1077,12 +1089,17 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin { required List data, required DomPointerEvent event, required _SanitizedDetails details, + // `pointerId` and `eventTarget` are optional but useful when it's not + // desired to get those values from the event object. For example, when the + // event is a coalesced event. + int? pointerId, + DomEventTarget? eventTarget, }) { final ui.PointerDeviceKind kind = _pointerTypeToDeviceKind(event.pointerType!); final double tilt = _computeHighestTilt(event); final Duration timeStamp = _BaseAdapter._eventTimeStampToDuration(event.timeStamp!); final num? pressure = event.pressure; - final ui.Offset offset = computeEventOffsetToTarget(event, _view); + final ui.Offset offset = computeEventOffsetToTarget(event, _view, eventTarget: eventTarget); _pointerDataConverter.convert( data, viewId: _view.viewId, @@ -1090,7 +1107,7 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin { timeStamp: timeStamp, kind: kind, signalKind: ui.PointerSignalKind.none, - device: _getPointerId(event), + device: pointerId ?? _getPointerId(event), physicalX: offset.dx * _view.devicePixelRatio, physicalY: offset.dy * _view.devicePixelRatio, buttons: details.buttons, diff --git a/lib/web_ui/lib/src/engine/pointer_binding/event_position_helper.dart b/lib/web_ui/lib/src/engine/pointer_binding/event_position_helper.dart index 319a3620cd741..82954325ab85e 100644 --- a/lib/web_ui/lib/src/engine/pointer_binding/event_position_helper.dart +++ b/lib/web_ui/lib/src/engine/pointer_binding/event_position_helper.dart @@ -12,18 +12,26 @@ import '../text_editing/text_editing.dart'; import '../vector_math.dart'; import '../window.dart'; -/// Returns an [ui.Offset] of the position of [event], relative to the position of [actualTarget]. +/// Returns an [ui.Offset] of the position of [event], relative to the position +/// of the Flutter [view]. /// /// The offset is *not* multiplied by DPR or anything else, it's the closest /// to what the DOM would return if we had currentTarget readily available. /// -/// This needs an `actualTarget`, because the `event.currentTarget` (which is what -/// this would really need to use) gets lost when the `event` comes from a "coalesced" -/// event. +/// This takes an optional `eventTarget`, because the `event.target` may have +/// the wrong value for "coalesced" events. See: +/// +/// - https://github.com/flutter/flutter/issues/155987 +/// - https://github.com/flutter/flutter/issues/159804 +/// - https://g-issues.chromium.org/issues/382473107 /// /// It also takes into account semantics being enabled to fix the case where /// offsetX, offsetY == 0 (TalkBack events). -ui.Offset computeEventOffsetToTarget(DomMouseEvent event, EngineFlutterView view) { +ui.Offset computeEventOffsetToTarget( + DomMouseEvent event, + EngineFlutterView view, { + DomEventTarget? eventTarget, +}) { final DomElement actualTarget = view.dom.rootElement; // On a TalkBack event if (EngineSemantics.instance.semanticsEnabled && event.offsetX == 0 && event.offsetY == 0) { @@ -31,16 +39,17 @@ ui.Offset computeEventOffsetToTarget(DomMouseEvent event, EngineFlutterView view } // On one of our text-editing nodes - final bool isInput = view.dom.textEditingHost.contains(event.target! as DomNode); + eventTarget ??= event.target!; + final bool isInput = view.dom.textEditingHost.contains(eventTarget as DomNode); if (isInput) { final EditableTextGeometry? inputGeometry = textEditing.strategy.geometry; if (inputGeometry != null) { - return _computeOffsetForInputs(event, inputGeometry); + return _computeOffsetForInputs(event, eventTarget, inputGeometry); } } // On another DOM Element (normally a platform view) - final bool isTargetOutsideOfShadowDOM = event.target != actualTarget; + final bool isTargetOutsideOfShadowDOM = eventTarget != actualTarget; if (isTargetOutsideOfShadowDOM) { final DomRect origin = actualTarget.getBoundingClientRect(); // event.clientX/Y and origin.x/y are relative **to the viewport**. @@ -64,8 +73,14 @@ ui.Offset computeEventOffsetToTarget(DomMouseEvent event, EngineFlutterView view /// sent from the framework, which includes information on how to transform the /// underlying input element. We transform the `event.offset` points we receive /// using the values from the input's transform matrix. -ui.Offset _computeOffsetForInputs(DomMouseEvent event, EditableTextGeometry inputGeometry) { - final DomElement targetElement = event.target! as DomHTMLElement; +/// +/// See [computeEventOffsetToTarget] for more information about `eventTarget`. +ui.Offset _computeOffsetForInputs( + DomMouseEvent event, + DomEventTarget eventTarget, + EditableTextGeometry inputGeometry, +) { + final DomElement targetElement = eventTarget as DomElement; final DomHTMLElement domElement = textEditing.strategy.activeDomElement; assert(targetElement == domElement, 'The targeted input element must be the active input element'); final Float32List transformValues = inputGeometry.globalTransform; diff --git a/lib/web_ui/test/canvaskit/image_golden_test.dart b/lib/web_ui/test/canvaskit/image_golden_test.dart index b5c5c6085b113..e0681ce6e1f02 100644 --- a/lib/web_ui/test/canvaskit/image_golden_test.dart +++ b/lib/web_ui/test/canvaskit/image_golden_test.dart @@ -253,6 +253,19 @@ Future testMain() async { } }); + test('crossOrigin requests cause an error', () async { + final String otherOrigin = + domWindow.location.origin.replaceAll('localhost', '127.0.0.1'); + bool gotError = false; + try { + final ui.Codec _ = await renderer.instantiateImageCodecFromUrl( + Uri.parse('$otherOrigin/test_images/1x1.png')); + } catch (e) { + gotError = true; + } + expect(gotError, isTrue, reason: 'Should have got CORS error'); + }); + _testCkAnimatedImage(); test('isAvif', () { diff --git a/lib/web_ui/test/canvaskit/image_test.dart b/lib/web_ui/test/canvaskit/image_test.dart index c23ae19fd196a..9a30849365011 100644 --- a/lib/web_ui/test/canvaskit/image_test.dart +++ b/lib/web_ui/test/canvaskit/image_test.dart @@ -7,12 +7,11 @@ import 'dart:typed_data'; import 'package:test/bootstrap/browser.dart'; import 'package:test/test.dart'; -import 'package:ui/src/engine/canvaskit/image.dart'; -import 'package:ui/src/engine/image_decoder.dart'; -import 'package:ui/src/engine/util.dart'; +import 'package:ui/src/engine.dart'; import 'package:ui/ui.dart' as ui; import 'common.dart'; +import 'test_data.dart'; void main() { internalBootstrapBrowserTest(() => testMain); @@ -131,6 +130,31 @@ void testMain() { expect(activeImages.length, 0); }); + + test('CkImage does not close image source too early', () async { + final ImageSource imageSource = ImageBitmapImageSource( + await domWindow.createImageBitmap(createBlankDomImageData(4, 4)), + ); + + final SkImage skImage1 = canvasKit.MakeAnimatedImageFromEncoded(k4x4PngImage)!.makeImageAtCurrentFrame(); + final CkImage image1 = CkImage(skImage1, imageSource: imageSource); + + final SkImage skImage2 = canvasKit.MakeAnimatedImageFromEncoded(k4x4PngImage)!.makeImageAtCurrentFrame(); + final CkImage image2 = CkImage(skImage2, imageSource: imageSource); + + final CkImage image3 = image1.clone(); + + expect(imageSource.debugIsClosed, isFalse); + + image1.dispose(); + expect(imageSource.debugIsClosed, isFalse); + + image2.dispose(); + expect(imageSource.debugIsClosed, isFalse); + + image3.dispose(); + expect(imageSource.debugIsClosed, isTrue); + }); } Future _createImage() => _createPicture().toImage(10, 10); diff --git a/lib/web_ui/test/engine/image/html_image_element_codec_test.dart b/lib/web_ui/test/engine/image/html_image_element_codec_test.dart index bbbf9ed2a58aa..772aeeb31cabb 100644 --- a/lib/web_ui/test/engine/image/html_image_element_codec_test.dart +++ b/lib/web_ui/test/engine/image/html_image_element_codec_test.dart @@ -7,12 +7,15 @@ import 'dart:typed_data'; import 'package:test/bootstrap/browser.dart'; import 'package:test/test.dart'; +import 'package:ui/src/engine/canvaskit/image.dart'; +import 'package:ui/src/engine/dom.dart'; import 'package:ui/src/engine/html/image.dart'; import 'package:ui/src/engine/html_image_element_codec.dart'; import 'package:ui/ui.dart' as ui; import 'package:ui/ui_web/src/ui_web.dart' as ui_web; import '../../common/test_initialization.dart'; +import '../../ui/utils.dart'; void main() { internalBootstrapBrowserTest(() => testMain); @@ -60,16 +63,20 @@ Future testMain() async { expect(image.height, height); }); test('loads sample image', () async { - final HtmlImageElementCodec codec = - HtmlRendererImageCodec('sample_image1.png'); + final HtmlImageElementCodec codec = createImageElementCodec('sample_image1.png'); final ui.FrameInfo frameInfo = await codec.getNextFrame(); + + expect(codec.imgElement, isNotNull); + expect(codec.imgElement!.src, contains('sample_image1.png')); + expect(codec.imgElement!.crossOrigin, isHtml ? isNull : 'anonymous'); + expect(codec.imgElement!.decoding, 'async'); + expect(frameInfo.image, isNotNull); expect(frameInfo.image.width, 100); expect(frameInfo.image.toString(), '[100×100]'); }); test('dispose image image', () async { - final HtmlImageElementCodec codec = - HtmlRendererImageCodec('sample_image1.png'); + final HtmlImageElementCodec codec = createImageElementCodec('sample_image1.png'); final ui.FrameInfo frameInfo = await codec.getNextFrame(); expect(frameInfo.image, isNotNull); expect(frameInfo.image.debugDisposed, isFalse); @@ -78,7 +85,7 @@ Future testMain() async { }); test('provides image loading progress', () async { final StringBuffer buffer = StringBuffer(); - final HtmlImageElementCodec codec = HtmlRendererImageCodec( + final HtmlImageElementCodec codec = createImageElementCodec( 'sample_image1.png', chunkCallback: (int loaded, int total) { buffer.write('$loaded/$total,'); }); @@ -89,7 +96,7 @@ Future testMain() async { /// Regression test for Firefox /// https://github.com/flutter/flutter/issues/66412 test('Returns nonzero natural width/height', () async { - final HtmlImageElementCodec codec = HtmlRendererImageCodec( + final HtmlImageElementCodec codec = createImageElementCodec( 'data:image/svg+xml;base64,PHN2ZyByb2xlPSJpbWciIHZpZXdCb3g9I' 'jAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48dG' 'l0bGU+QWJzdHJhY3QgaWNvbjwvdGl0bGU+PHBhdGggZD0iTTEyIDBjOS42MDEgMCAx' @@ -103,7 +110,7 @@ Future testMain() async { final ui.FrameInfo frameInfo = await codec.getNextFrame(); expect(frameInfo.image.width, isNot(0)); }); - }); + }, skip: isSkwasm); group('ImageCodecUrl', () { test('loads sample image from web', () async { @@ -111,6 +118,12 @@ Future testMain() async { final HtmlImageElementCodec codec = await ui_web.createImageCodecFromUrl(uri) as HtmlImageElementCodec; final ui.FrameInfo frameInfo = await codec.getNextFrame(); + + expect(codec.imgElement, isNotNull); + expect(codec.imgElement!.src, contains('sample_image1.png')); + expect(codec.imgElement!.crossOrigin, isHtml ? isNull : 'anonymous'); + expect(codec.imgElement!.decoding, 'async'); + expect(frameInfo.image, isNotNull); expect(frameInfo.image.width, 100); }); @@ -124,5 +137,14 @@ Future testMain() async { await codec.getNextFrame(); expect(buffer.toString(), '0/100,100/100,'); }); - }); + }, skip: isSkwasm); +} + +HtmlImageElementCodec createImageElementCodec( + String src, { + ui_web.ImageCodecChunkCallback? chunkCallback, +}) { + return isHtml + ? HtmlRendererImageCodec(src, chunkCallback: chunkCallback) + : CkImageElementCodec(src, chunkCallback: chunkCallback); } diff --git a/lib/web_ui/test/engine/pointer_binding/event_position_helper_test.dart b/lib/web_ui/test/engine/pointer_binding/event_position_helper_test.dart index eca204cd6e613..14f41dccbbf9a 100644 --- a/lib/web_ui/test/engine/pointer_binding/event_position_helper_test.dart +++ b/lib/web_ui/test/engine/pointer_binding/event_position_helper_test.dart @@ -32,6 +32,7 @@ void doTests() { group('computeEventOffsetToTarget', () { setUp(() { view = EngineFlutterView(EnginePlatformDispatcher.instance, domDocument.body!); + EnginePlatformDispatcher.instance.viewManager.registerView(view); rootElement = view.dom.rootElement; eventSource = createDomElement('div-event-source'); rootElement.append(eventSource); @@ -58,6 +59,7 @@ void doTests() { }); tearDown(() { + EnginePlatformDispatcher.instance.viewManager.unregisterView(view.viewId); view.dispose(); }); @@ -101,6 +103,36 @@ void doTests() { expect(offset.dy, 110); }); + test('eventTarget takes precedence', () async { + final input = view.dom.textEditingHost.appendChild(createDomElement('input')); + + textEditing.strategy.enable( + InputConfiguration(viewId: view.viewId), + onChange: (_, __) {}, + onAction: (_) {}, + ); + + addTearDown(() { + textEditing.strategy.disable(); + }); + + final moveEvent = createDomPointerEvent('pointermove', { + 'bubbles': true, + 'clientX': 10, + 'clientY': 20, + }); + + expect( + () => computeEventOffsetToTarget(moveEvent, view), + throwsA(anything), + ); + + expect( + () => computeEventOffsetToTarget(moveEvent, view, eventTarget: input), + returnsNormally, + ); + }); + test('Event dispatched by TalkBack gets a computed offset', () async { // Fill this in to test _computeOffsetForTalkbackEvent }, skip: 'To be implemented!'); diff --git a/lib/web_ui/test/engine/pointer_binding_test.dart b/lib/web_ui/test/engine/pointer_binding_test.dart index f765c492f7226..374ed872f428e 100644 --- a/lib/web_ui/test/engine/pointer_binding_test.dart +++ b/lib/web_ui/test/engine/pointer_binding_test.dart @@ -2526,6 +2526,88 @@ void testMain() { }, ); + test('ignores pointerId on coalesced events', () { + final _MultiPointerEventMixin context = _PointerEventContext(); + final List packets = []; + List data; + ui.PlatformDispatcher.instance.onPointerDataPacket = (ui.PointerDataPacket packet) { + packets.add(packet); + }; + + context.multiTouchDown(const <_TouchDetails>[ + _TouchDetails(pointer: 52, clientX: 100, clientY: 101), + ]).forEach(rootElement.dispatchEvent); + expect(packets.length, 1); + + data = packets.single.data; + expect(data, hasLength(2)); + expect(data[0].change, equals(ui.PointerChange.add)); + expect(data[0].synthesized, isTrue); + expect(data[0].device, equals(52)); + expect(data[0].physicalX, equals(100 * dpi)); + expect(data[0].physicalY, equals(101 * dpi)); + + expect(data[1].change, equals(ui.PointerChange.down)); + expect(data[1].device, equals(52)); + expect(data[1].buttons, equals(1)); + expect(data[1].physicalX, equals(100 * dpi)); + expect(data[1].physicalY, equals(101 * dpi)); + expect(data[1].physicalDeltaX, equals(0)); + expect(data[1].physicalDeltaY, equals(0)); + packets.clear(); + + // Pointer move with coaleasced events + context.multiTouchMove(const <_TouchDetails>[ + _TouchDetails(pointer: 52, coalescedEvents: <_CoalescedTouchDetails>[ + _CoalescedTouchDetails(pointer: 0, clientX: 301, clientY: 302), + _CoalescedTouchDetails(pointer: 0, clientX: 401, clientY: 402), + ]), + ]).forEach(rootElement.dispatchEvent); + expect(packets.length, 1); + + data = packets.single.data; + expect(data, hasLength(2)); + expect(data[0].change, equals(ui.PointerChange.move)); + expect(data[0].device, equals(52)); + expect(data[0].buttons, equals(1)); + expect(data[0].physicalX, equals(301 * dpi)); + expect(data[0].physicalY, equals(302 * dpi)); + expect(data[0].physicalDeltaX, equals(201 * dpi)); + expect(data[0].physicalDeltaY, equals(201 * dpi)); + + expect(data[1].change, equals(ui.PointerChange.move)); + expect(data[1].device, equals(52)); + expect(data[1].buttons, equals(1)); + expect(data[1].physicalX, equals(401 * dpi)); + expect(data[1].physicalY, equals(402 * dpi)); + expect(data[1].physicalDeltaX, equals(100 * dpi)); + expect(data[1].physicalDeltaY, equals(100 * dpi)); + packets.clear(); + + // Pointer up + context.multiTouchUp(const <_TouchDetails>[ + _TouchDetails(pointer: 52, clientX: 401, clientY: 402), + ]).forEach(rootElement.dispatchEvent); + expect(packets, hasLength(1)); + expect(packets[0].data, hasLength(2)); + expect(packets[0].data[0].change, equals(ui.PointerChange.up)); + expect(packets[0].data[0].device, equals(52)); + expect(packets[0].data[0].buttons, equals(0)); + expect(packets[0].data[0].physicalX, equals(401 * dpi)); + expect(packets[0].data[0].physicalY, equals(402 * dpi)); + expect(packets[0].data[0].physicalDeltaX, equals(0)); + expect(packets[0].data[0].physicalDeltaY, equals(0)); + + expect(packets[0].data[1].change, equals(ui.PointerChange.remove)); + expect(packets[0].data[1].device, equals(52)); + expect(packets[0].data[1].buttons, equals(0)); + expect(packets[0].data[1].physicalX, equals(401 * dpi)); + expect(packets[0].data[1].physicalY, equals(402 * dpi)); + expect(packets[0].data[1].physicalDeltaX, equals(0)); + expect(packets[0].data[1].physicalDeltaY, equals(0)); + packets.clear(); + }); + test( 'correctly parses cancel event', () { @@ -3336,7 +3418,26 @@ mixin _ButtonedEventMixin on _BasicEventContext { } class _TouchDetails { - const _TouchDetails({this.pointer, this.clientX, this.clientY}); + const _TouchDetails({ + this.pointer, + this.clientX, + this.clientY, + this.coalescedEvents, + }); + + final int? pointer; + final double? clientX; + final double? clientY; + + final List<_CoalescedTouchDetails>? coalescedEvents; +} + +class _CoalescedTouchDetails { + const _CoalescedTouchDetails({ + this.pointer, + this.clientX, + this.clientY, + }); final int? pointer; final double? clientX; @@ -3395,6 +3496,10 @@ class _PointerEventContext extends _BasicEventContext @override List multiTouchDown(List<_TouchDetails> touches) { + assert( + touches.every((_TouchDetails details) => details.coalescedEvents == null), + 'Coalesced events are not allowed for pointerdown events.', + ); return touches .map((_TouchDetails details) => _downWithFullDetails( pointer: details.pointer, @@ -3458,6 +3563,7 @@ class _PointerEventContext extends _BasicEventContext clientX: details.clientX, clientY: details.clientY, pointerType: 'touch', + coalescedEvents: details.coalescedEvents, )) .toList(); } @@ -3487,8 +3593,9 @@ class _PointerEventContext extends _BasicEventContext int? buttons, int? pointer, String? pointerType, + List<_CoalescedTouchDetails>? coalescedEvents, }) { - return createDomPointerEvent('pointermove', { + final event = createDomPointerEvent('pointermove', { 'bubbles': true, 'pointerId': pointer, 'button': button, @@ -3497,6 +3604,26 @@ class _PointerEventContext extends _BasicEventContext 'clientY': clientY, 'pointerType': pointerType, }); + + if (coalescedEvents != null) { + // There's no JS API for setting coalesced events, so we need to + // monkey-patch the `getCoalescedEvents` method to return what we want. + final coalescedEventJs = coalescedEvents + .map((_CoalescedTouchDetails details) => _moveWithFullDetails( + pointer: details.pointer, + button: button, + buttons: buttons, + clientX: details.clientX, + clientY: details.clientY, + pointerType: 'touch', + )).toJSAnyDeep; + + js_util.setProperty(event, 'getCoalescedEvents', js_util.allowInterop(() { + return coalescedEventJs; + })); + } + + return event; } @override @@ -3537,6 +3664,10 @@ class _PointerEventContext extends _BasicEventContext @override List multiTouchUp(List<_TouchDetails> touches) { + assert( + touches.every((_TouchDetails details) => details.coalescedEvents == null), + 'Coalesced events are not allowed for pointerup events.', + ); return touches .map((_TouchDetails details) => _upWithFullDetails( pointer: details.pointer, @@ -3587,6 +3718,10 @@ class _PointerEventContext extends _BasicEventContext @override List multiTouchCancel(List<_TouchDetails> touches) { + assert( + touches.every((_TouchDetails details) => details.coalescedEvents == null), + 'Coalesced events are not allowed for pointercancel events.', + ); return touches .map((_TouchDetails details) => createDomPointerEvent('pointercancel', { diff --git a/lib/web_ui/test/ui/image/html_image_element_codec_test.dart b/lib/web_ui/test/ui/image/html_image_element_codec_test.dart new file mode 100644 index 0000000000000..772aeeb31cabb --- /dev/null +++ b/lib/web_ui/test/ui/image/html_image_element_codec_test.dart @@ -0,0 +1,150 @@ +// 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:async'; +import 'dart:typed_data'; + +import 'package:test/bootstrap/browser.dart'; +import 'package:test/test.dart'; +import 'package:ui/src/engine/canvaskit/image.dart'; +import 'package:ui/src/engine/dom.dart'; +import 'package:ui/src/engine/html/image.dart'; +import 'package:ui/src/engine/html_image_element_codec.dart'; +import 'package:ui/ui.dart' as ui; +import 'package:ui/ui_web/src/ui_web.dart' as ui_web; + +import '../../common/test_initialization.dart'; +import '../../ui/utils.dart'; + +void main() { + internalBootstrapBrowserTest(() => testMain); +} + +Future testMain() async { + setUpUnitTests(); + group('$HtmlImageElementCodec', () { + test('supports raw images - RGBA8888', () async { + final Completer completer = Completer(); + const int width = 200; + const int height = 300; + final Uint32List list = Uint32List(width * height); + for (int index = 0; index < list.length; index += 1) { + list[index] = 0xFF0000FF; + } + ui.decodeImageFromPixels( + list.buffer.asUint8List(), + width, + height, + ui.PixelFormat.rgba8888, + (ui.Image image) => completer.complete(image), + ); + final ui.Image image = await completer.future; + expect(image.width, width); + expect(image.height, height); + }); + test('supports raw images - BGRA8888', () async { + final Completer completer = Completer(); + const int width = 200; + const int height = 300; + final Uint32List list = Uint32List(width * height); + for (int index = 0; index < list.length; index += 1) { + list[index] = 0xFF0000FF; + } + ui.decodeImageFromPixels( + list.buffer.asUint8List(), + width, + height, + ui.PixelFormat.bgra8888, + (ui.Image image) => completer.complete(image), + ); + final ui.Image image = await completer.future; + expect(image.width, width); + expect(image.height, height); + }); + test('loads sample image', () async { + final HtmlImageElementCodec codec = createImageElementCodec('sample_image1.png'); + final ui.FrameInfo frameInfo = await codec.getNextFrame(); + + expect(codec.imgElement, isNotNull); + expect(codec.imgElement!.src, contains('sample_image1.png')); + expect(codec.imgElement!.crossOrigin, isHtml ? isNull : 'anonymous'); + expect(codec.imgElement!.decoding, 'async'); + + expect(frameInfo.image, isNotNull); + expect(frameInfo.image.width, 100); + expect(frameInfo.image.toString(), '[100×100]'); + }); + test('dispose image image', () async { + final HtmlImageElementCodec codec = createImageElementCodec('sample_image1.png'); + final ui.FrameInfo frameInfo = await codec.getNextFrame(); + expect(frameInfo.image, isNotNull); + expect(frameInfo.image.debugDisposed, isFalse); + frameInfo.image.dispose(); + expect(frameInfo.image.debugDisposed, isTrue); + }); + test('provides image loading progress', () async { + final StringBuffer buffer = StringBuffer(); + final HtmlImageElementCodec codec = createImageElementCodec( + 'sample_image1.png', chunkCallback: (int loaded, int total) { + buffer.write('$loaded/$total,'); + }); + await codec.getNextFrame(); + expect(buffer.toString(), '0/100,100/100,'); + }); + + /// Regression test for Firefox + /// https://github.com/flutter/flutter/issues/66412 + test('Returns nonzero natural width/height', () async { + final HtmlImageElementCodec codec = createImageElementCodec( + 'data:image/svg+xml;base64,PHN2ZyByb2xlPSJpbWciIHZpZXdCb3g9I' + 'jAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48dG' + 'l0bGU+QWJzdHJhY3QgaWNvbjwvdGl0bGU+PHBhdGggZD0iTTEyIDBjOS42MDEgMCAx' + 'MiAyLjM5OSAxMiAxMiAwIDkuNjAxLTIuMzk5IDEyLTEyIDEyLTkuNjAxIDAtMTItMi' + '4zOTktMTItMTJDMCAyLjM5OSAyLjM5OSAwIDEyIDB6bS0xLjk2OSAxOC41NjRjMi41' + 'MjQuMDAzIDQuNjA0LTIuMDcgNC42MDktNC41OTUgMC0yLjUyMS0yLjA3NC00LjU5NS' + '00LjU5NS00LjU5NVM1LjQ1IDExLjQ0OSA1LjQ1IDEzLjk2OWMwIDIuNTE2IDIuMDY1' + 'IDQuNTg4IDQuNTgxIDQuNTk1em04LjM0NC0uMTg5VjUuNjI1SDUuNjI1djIuMjQ3aD' + 'EwLjQ5OHYxMC41MDNoMi4yNTJ6bS04LjM0NC02Ljc0OGEyLjM0MyAyLjM0MyAwIDEx' + 'LS4wMDIgNC42ODYgMi4zNDMgMi4zNDMgMCAwMS4wMDItNC42ODZ6Ii8+PC9zdmc+'); + final ui.FrameInfo frameInfo = await codec.getNextFrame(); + expect(frameInfo.image.width, isNot(0)); + }); + }, skip: isSkwasm); + + group('ImageCodecUrl', () { + test('loads sample image from web', () async { + final Uri uri = Uri.base.resolve('sample_image1.png'); + final HtmlImageElementCodec codec = + await ui_web.createImageCodecFromUrl(uri) as HtmlImageElementCodec; + final ui.FrameInfo frameInfo = await codec.getNextFrame(); + + expect(codec.imgElement, isNotNull); + expect(codec.imgElement!.src, contains('sample_image1.png')); + expect(codec.imgElement!.crossOrigin, isHtml ? isNull : 'anonymous'); + expect(codec.imgElement!.decoding, 'async'); + + expect(frameInfo.image, isNotNull); + expect(frameInfo.image.width, 100); + }); + test('provides image loading progress from web', () async { + final Uri uri = Uri.base.resolve('sample_image1.png'); + final StringBuffer buffer = StringBuffer(); + final HtmlImageElementCodec codec = await ui_web + .createImageCodecFromUrl(uri, chunkCallback: (int loaded, int total) { + buffer.write('$loaded/$total,'); + }) as HtmlImageElementCodec; + await codec.getNextFrame(); + expect(buffer.toString(), '0/100,100/100,'); + }); + }, skip: isSkwasm); +} + +HtmlImageElementCodec createImageElementCodec( + String src, { + ui_web.ImageCodecChunkCallback? chunkCallback, +}) { + return isHtml + ? HtmlRendererImageCodec(src, chunkCallback: chunkCallback) + : CkImageElementCodec(src, chunkCallback: chunkCallback); +} diff --git a/lib/web_ui/test/ui/image/sample_image1.png b/lib/web_ui/test/ui/image/sample_image1.png new file mode 100644 index 0000000000000..0d1393b805213 Binary files /dev/null and b/lib/web_ui/test/ui/image/sample_image1.png differ diff --git a/shell/platform/android/io/flutter/embedding/android/FlutterView.java b/shell/platform/android/io/flutter/embedding/android/FlutterView.java index 0d5ae3a8efc5f..69f54010e4d04 100644 --- a/shell/platform/android/io/flutter/embedding/android/FlutterView.java +++ b/shell/platform/android/io/flutter/embedding/android/FlutterView.java @@ -194,13 +194,7 @@ public void onFlutterUiNoLongerDisplayed() { } }; - private final Consumer windowInfoListener = - new Consumer() { - @Override - public void accept(WindowLayoutInfo layoutInfo) { - setWindowInfoListenerDisplayFeatures(layoutInfo); - } - }; + private Consumer windowInfoListener; /** * Constructs a {@code FlutterView} programmatically, without any XML attributes. @@ -512,6 +506,10 @@ protected void onAttachedToWindow() { this.windowInfoRepo = createWindowInfoRepo(); Activity activity = ViewUtils.getActivity(getContext()); if (windowInfoRepo != null && activity != null) { + // Creating windowInfoListener on-demand instead of at initialization is necessary in order to + // prevent it from capturing the wrong instance of `this` when spying for testing. + // See https://github.com/mockito/mockito/issues/3479 + windowInfoListener = this::setWindowInfoListenerDisplayFeatures; windowInfoRepo.addWindowLayoutInfoListener( activity, ContextCompat.getMainExecutor(getContext()), windowInfoListener); } @@ -524,9 +522,10 @@ protected void onAttachedToWindow() { */ @Override protected void onDetachedFromWindow() { - if (windowInfoRepo != null) { + if (windowInfoRepo != null && windowInfoListener != null) { windowInfoRepo.removeWindowLayoutInfoListener(windowInfoListener); } + windowInfoListener = null; this.windowInfoRepo = null; super.onDetachedFromWindow(); } @@ -537,12 +536,12 @@ protected void onDetachedFromWindow() { */ @TargetApi(API_LEVELS.API_28) protected void setWindowInfoListenerDisplayFeatures(WindowLayoutInfo layoutInfo) { - List displayFeatures = layoutInfo.getDisplayFeatures(); - List result = new ArrayList<>(); + List newDisplayFeatures = layoutInfo.getDisplayFeatures(); + List flutterDisplayFeatures = new ArrayList<>(); // Data from WindowInfoTracker display features. Fold and hinge areas are // populated here. - for (DisplayFeature displayFeature : displayFeatures) { + for (DisplayFeature displayFeature : newDisplayFeatures) { Log.v( TAG, "WindowInfoTracker Display Feature reported with bounds = " @@ -565,31 +564,17 @@ protected void setWindowInfoListenerDisplayFeatures(WindowLayoutInfo layoutInfo) } else { state = DisplayFeatureState.UNKNOWN; } - result.add(new FlutterRenderer.DisplayFeature(displayFeature.getBounds(), type, state)); + flutterDisplayFeatures.add( + new FlutterRenderer.DisplayFeature(displayFeature.getBounds(), type, state)); } else { - result.add( + flutterDisplayFeatures.add( new FlutterRenderer.DisplayFeature( displayFeature.getBounds(), DisplayFeatureType.UNKNOWN, DisplayFeatureState.UNKNOWN)); } } - - // Data from the DisplayCutout bounds. Cutouts for cameras and other sensors are - // populated here. DisplayCutout was introduced in API 28. - if (Build.VERSION.SDK_INT >= API_LEVELS.API_28) { - WindowInsets insets = getRootWindowInsets(); - if (insets != null) { - DisplayCutout cutout = insets.getDisplayCutout(); - if (cutout != null) { - for (Rect bounds : cutout.getBoundingRects()) { - Log.v(TAG, "DisplayCutout area reported with bounds = " + bounds.toString()); - result.add(new FlutterRenderer.DisplayFeature(bounds, DisplayFeatureType.CUTOUT)); - } - } - } - } - viewportMetrics.displayFeatures = result; + viewportMetrics.setDisplayFeatures(flutterDisplayFeatures); sendViewportMetricsToFlutter(); } @@ -782,6 +767,22 @@ navigationBarVisible && guessBottomKeyboardInset(insets) == 0 viewportMetrics.viewInsetLeft = 0; } + // Data from the DisplayCutout bounds. Cutouts for cameras and other sensors are + // populated here. DisplayCutout was introduced in API 28. + List displayCutouts = new ArrayList<>(); + if (Build.VERSION.SDK_INT >= API_LEVELS.API_28) { + DisplayCutout cutout = insets.getDisplayCutout(); + if (cutout != null) { + for (Rect bounds : cutout.getBoundingRects()) { + Log.v(TAG, "DisplayCutout area reported with bounds = " + bounds.toString()); + displayCutouts.add( + new FlutterRenderer.DisplayFeature( + bounds, DisplayFeatureType.CUTOUT, DisplayFeatureState.UNKNOWN)); + } + } + } + viewportMetrics.setDisplayCutouts(displayCutouts); + // The caption bar inset is a new addition, and the APIs called to query it utilize a list of // bounding Rects instead of an Insets object, which is a newer API method, as compared to the // existing Insets-based method calls above. diff --git a/shell/platform/android/io/flutter/embedding/engine/renderer/FlutterRenderer.java b/shell/platform/android/io/flutter/embedding/engine/renderer/FlutterRenderer.java index f66ea69fc94e7..0009c062744ea 100644 --- a/shell/platform/android/io/flutter/embedding/engine/renderer/FlutterRenderer.java +++ b/shell/platform/android/io/flutter/embedding/engine/renderer/FlutterRenderer.java @@ -1137,6 +1137,13 @@ public void stopRenderingToSurface() { } } + private void translateFeatureBounds(int[] displayFeatureBounds, int offset, Rect bounds) { + displayFeatureBounds[offset] = bounds.left; + displayFeatureBounds[offset + 1] = bounds.top; + displayFeatureBounds[offset + 2] = bounds.right; + displayFeatureBounds[offset + 3] = bounds.bottom; + } + /** * Notifies Flutter that the viewport metrics, e.g. window height and width, have changed. * @@ -1187,20 +1194,31 @@ public void setViewportMetrics(@NonNull ViewportMetrics viewportMetrics) { + viewportMetrics.systemGestureInsetRight + "\n" + "Display Features: " - + viewportMetrics.displayFeatures.size()); - - int[] displayFeaturesBounds = new int[viewportMetrics.displayFeatures.size() * 4]; - int[] displayFeaturesType = new int[viewportMetrics.displayFeatures.size()]; - int[] displayFeaturesState = new int[viewportMetrics.displayFeatures.size()]; + + viewportMetrics.displayFeatures.size() + + "\n" + + "Display Cutouts: " + + viewportMetrics.displayCutouts.size()); + + int totalFeaturesAndCutouts = + viewportMetrics.displayFeatures.size() + viewportMetrics.displayCutouts.size(); + int[] displayFeaturesBounds = new int[totalFeaturesAndCutouts * 4]; + int[] displayFeaturesType = new int[totalFeaturesAndCutouts]; + int[] displayFeaturesState = new int[totalFeaturesAndCutouts]; for (int i = 0; i < viewportMetrics.displayFeatures.size(); i++) { DisplayFeature displayFeature = viewportMetrics.displayFeatures.get(i); - displayFeaturesBounds[4 * i] = displayFeature.bounds.left; - displayFeaturesBounds[4 * i + 1] = displayFeature.bounds.top; - displayFeaturesBounds[4 * i + 2] = displayFeature.bounds.right; - displayFeaturesBounds[4 * i + 3] = displayFeature.bounds.bottom; + translateFeatureBounds(displayFeaturesBounds, 4 * i, displayFeature.bounds); displayFeaturesType[i] = displayFeature.type.encodedValue; displayFeaturesState[i] = displayFeature.state.encodedValue; } + int cutoutOffset = viewportMetrics.displayFeatures.size() * 4; + for (int i = 0; i < viewportMetrics.displayCutouts.size(); i++) { + DisplayFeature displayCutout = viewportMetrics.displayCutouts.get(i); + translateFeatureBounds(displayFeaturesBounds, cutoutOffset + 4 * i, displayCutout.bounds); + displayFeaturesType[viewportMetrics.displayFeatures.size() + i] = + displayCutout.type.encodedValue; + displayFeaturesState[viewportMetrics.displayFeatures.size() + i] = + displayCutout.state.encodedValue; + } flutterJNI.setViewportMetrics( viewportMetrics.devicePixelRatio, @@ -1314,7 +1332,29 @@ boolean validate() { return width > 0 && height > 0 && devicePixelRatio > 0; } - public List displayFeatures = new ArrayList<>(); + // Features + private final List displayFeatures = new ArrayList<>(); + + // Specifically display cutouts. + private final List displayCutouts = new ArrayList<>(); + + public List getDisplayFeatures() { + return displayFeatures; + } + + public List getDisplayCutouts() { + return displayCutouts; + } + + public void setDisplayFeatures(List newFeatures) { + displayFeatures.clear(); + displayFeatures.addAll(newFeatures); + } + + public void setDisplayCutouts(List newCutouts) { + displayCutouts.clear(); + displayCutouts.addAll(newCutouts); + } } /** @@ -1337,12 +1377,6 @@ public DisplayFeature(Rect bounds, DisplayFeatureType type, DisplayFeatureState this.type = type; this.state = state; } - - public DisplayFeature(Rect bounds, DisplayFeatureType type) { - this.bounds = bounds; - this.type = type; - this.state = DisplayFeatureState.UNKNOWN; - } } /** diff --git a/shell/platform/android/surface_texture_external_texture.cc b/shell/platform/android/surface_texture_external_texture.cc index 9ace4b1c41d6d..7f0511138b479 100644 --- a/shell/platform/android/surface_texture_external_texture.cc +++ b/shell/platform/android/surface_texture_external_texture.cc @@ -52,7 +52,10 @@ void SurfaceTextureExternalTexture::Paint(PaintContext& context, if (should_process_frame) { ProcessFrame(context, bounds); } - FML_CHECK(state_ == AttachmentState::kAttached); + // If process frame failed, this may not be in attached state. + if (state_ != AttachmentState::kAttached) { + return; + } if (!dl_image_) { FML_LOG(WARNING) diff --git a/shell/platform/android/test/io/flutter/embedding/android/FlutterViewTest.java b/shell/platform/android/test/io/flutter/embedding/android/FlutterViewTest.java index dc62638fb2e32..7247873ef1e0d 100644 --- a/shell/platform/android/test/io/flutter/embedding/android/FlutterViewTest.java +++ b/shell/platform/android/test/io/flutter/embedding/android/FlutterViewTest.java @@ -55,6 +55,8 @@ import io.flutter.plugin.platform.PlatformViewsController; import java.lang.reflect.Method; import java.util.Arrays; +import java.util.Collections; +import java.util.List; import java.util.concurrent.atomic.AtomicReference; import org.junit.Before; import org.junit.Test; @@ -634,21 +636,103 @@ public void systemInsetDisplayCutoutSimple() { when(windowInsets.getSystemGestureInsets()).thenReturn(systemGestureInsets); when(windowInsets.getDisplayCutout()).thenReturn(displayCutout); - Insets waterfallInsets = Insets.of(200, 0, 200, 0); + Insets waterfallInsets = Insets.of(200, 0, 250, 0); when(displayCutout.getWaterfallInsets()).thenReturn(waterfallInsets); - when(displayCutout.getSafeInsetTop()).thenReturn(150); - when(displayCutout.getSafeInsetBottom()).thenReturn(150); - when(displayCutout.getSafeInsetLeft()).thenReturn(150); - when(displayCutout.getSafeInsetRight()).thenReturn(150); + when(displayCutout.getSafeInsetLeft()).thenReturn(110); + when(displayCutout.getSafeInsetTop()).thenReturn(120); + when(displayCutout.getSafeInsetRight()).thenReturn(130); + when(displayCutout.getSafeInsetBottom()).thenReturn(140); flutterView.onApplyWindowInsets(windowInsets); verify(flutterRenderer, times(2)).setViewportMetrics(viewportMetricsCaptor.capture()); - validateViewportMetricPadding(viewportMetricsCaptor, 200, 150, 200, 150); + // Each dimension of the viewport metric paddings should be the maximum of the corresponding + // dimension from the display cutout's safe insets and waterfall insets. + validateViewportMetricPadding(viewportMetricsCaptor, 200, 120, 250, 140); assertEquals(100, viewportMetricsCaptor.getValue().viewInsetTop); } + @SuppressWarnings("deprecation") + @Test + @Config(minSdk = 28) + public void onApplyWindowInsetsSetsDisplayCutouts() { + // Use an Activity context so that FlutterView.onAttachedToWindow completes. + Context context = Robolectric.setupActivity(Activity.class); + FlutterView flutterView = spy(new FlutterView(context)); + assertEquals(0, flutterView.getSystemUiVisibility()); + when(flutterView.getWindowSystemUiVisibility()).thenReturn(0); + when(flutterView.getContext()).thenReturn(context); + + FlutterEngine flutterEngine = spy(new FlutterEngine(ctx, mockFlutterLoader, mockFlutterJni)); + FlutterRenderer flutterRenderer = spy(new FlutterRenderer(mockFlutterJni)); + when(flutterEngine.getRenderer()).thenReturn(flutterRenderer); + + // When we attach a new FlutterView to the engine without any system insets, + // the viewport metrics default to 0. + flutterView.attachToFlutterEngine(flutterEngine); + ArgumentCaptor viewportMetricsCaptor = + ArgumentCaptor.forClass(FlutterRenderer.ViewportMetrics.class); + verify(flutterRenderer).setViewportMetrics(viewportMetricsCaptor.capture()); + assertEquals(0, viewportMetricsCaptor.getValue().viewPaddingTop); + + // Capture flutterView.setWindowInfoListenerDisplayFeatures. + WindowInfoRepositoryCallbackAdapterWrapper windowInfoRepo = + mock(WindowInfoRepositoryCallbackAdapterWrapper.class); + doReturn(windowInfoRepo).when(flutterView).createWindowInfoRepo(); + ArgumentCaptor> consumerCaptor = + ArgumentCaptor.forClass(Consumer.class); + flutterView.onAttachedToWindow(); + verify(windowInfoRepo).addWindowLayoutInfoListener(any(), any(), consumerCaptor.capture()); + Consumer consumer = consumerCaptor.getValue(); + + // Set display features in flutterView to ensure they are not overridden by display cutouts. + FoldingFeature displayFeature = mock(FoldingFeature.class); + Rect featureBounds = new Rect(10, 20, 30, 40); + when(displayFeature.getBounds()).thenReturn(featureBounds); + when(displayFeature.getOcclusionType()).thenReturn(FoldingFeature.OcclusionType.FULL); + when(displayFeature.getState()).thenReturn(FoldingFeature.State.FLAT); + WindowLayoutInfo windowLayout = new WindowLayoutInfo(Collections.singletonList(displayFeature)); + clearInvocations(flutterRenderer); + consumer.accept(windowLayout); + + // Assert the display feature is set. + verify(flutterRenderer).setViewportMetrics(viewportMetricsCaptor.capture()); + List features = + viewportMetricsCaptor.getValue().getDisplayFeatures(); + assertEquals(1, features.size()); + assertEquals(FlutterRenderer.DisplayFeatureType.HINGE, features.get(0).type); + assertEquals(FlutterRenderer.DisplayFeatureState.POSTURE_FLAT, features.get(0).state); + assertEquals(featureBounds, features.get(0).bounds); + + // Then we simulate the system applying a window inset. + List cutoutBoundingRects = + Arrays.asList(new Rect(0, 200, 300, 400), new Rect(150, 0, 300, 150)); + WindowInsets windowInsets = setupMockDisplayCutout(cutoutBoundingRects); + + clearInvocations(flutterRenderer); + flutterView.onApplyWindowInsets(windowInsets); + verify(flutterRenderer).setViewportMetrics(viewportMetricsCaptor.capture()); + + features = viewportMetricsCaptor.getValue().getDisplayFeatures(); + + // Assert the old display feature is still present. + assertEquals(1, features.size()); + assertEquals(FlutterRenderer.DisplayFeatureType.HINGE, features.get(0).type); + assertEquals(FlutterRenderer.DisplayFeatureState.POSTURE_FLAT, features.get(0).state); + assertEquals(featureBounds, features.get(0).bounds); + + List cutouts = + viewportMetricsCaptor.getValue().getDisplayCutouts(); + // Asserts for display cutouts. + assertEquals(2, cutouts.size()); + for (int i = 0; i < 2; i++) { + assertEquals(cutoutBoundingRects.get(i), cutouts.get(i).bounds); + assertEquals(FlutterRenderer.DisplayFeatureType.CUTOUT, cutouts.get(i).type); + assertEquals(FlutterRenderer.DisplayFeatureState.UNKNOWN, cutouts.get(i).state); + } + } + @SuppressWarnings("deprecation") // Robolectric.setupActivity // TODO(reidbaker): https://github.com/flutter/flutter/issues/133151 @@ -694,36 +778,59 @@ public void itSendsHingeDisplayFeatureToFlutter() { FlutterRenderer flutterRenderer = spy(new FlutterRenderer(mockFlutterJni)); when(flutterEngine.getRenderer()).thenReturn(flutterRenderer); + // Display features should be empty on attaching to engine. + flutterView.attachToFlutterEngine(flutterEngine); + ArgumentCaptor viewportMetricsCaptor = + ArgumentCaptor.forClass(FlutterRenderer.ViewportMetrics.class); + verify(flutterRenderer).setViewportMetrics(viewportMetricsCaptor.capture()); + assertEquals(Collections.emptyList(), viewportMetricsCaptor.getValue().getDisplayFeatures()); + clearInvocations(flutterRenderer); + + // Test that display features do not override cutouts. + List cutoutBoundingRects = Collections.singletonList(new Rect(0, 200, 300, 400)); + WindowInsets windowInsets = setupMockDisplayCutout(cutoutBoundingRects); + flutterView.onApplyWindowInsets(windowInsets); + verify(flutterRenderer).setViewportMetrics(viewportMetricsCaptor.capture()); + assertEquals(1, viewportMetricsCaptor.getValue().getDisplayCutouts().size()); + assertEquals( + cutoutBoundingRects.get(0), + viewportMetricsCaptor.getValue().getDisplayCutouts().get(0).bounds); + clearInvocations(flutterRenderer); + FoldingFeature displayFeature = mock(FoldingFeature.class); - when(displayFeature.getBounds()).thenReturn(new Rect(0, 0, 100, 100)); + Rect featureRect = new Rect(0, 0, 100, 100); + when(displayFeature.getBounds()).thenReturn(featureRect); when(displayFeature.getOcclusionType()).thenReturn(FoldingFeature.OcclusionType.FULL); when(displayFeature.getState()).thenReturn(FoldingFeature.State.FLAT); - WindowLayoutInfo testWindowLayout = new WindowLayoutInfo(Arrays.asList(displayFeature)); + WindowLayoutInfo testWindowLayout = + new WindowLayoutInfo(Collections.singletonList(displayFeature)); // When FlutterView is attached to the engine and window, and a hinge display feature exists - flutterView.attachToFlutterEngine(flutterEngine); - ArgumentCaptor viewportMetricsCaptor = - ArgumentCaptor.forClass(FlutterRenderer.ViewportMetrics.class); - verify(flutterRenderer).setViewportMetrics(viewportMetricsCaptor.capture()); - assertEquals(Arrays.asList(), viewportMetricsCaptor.getValue().displayFeatures); flutterView.onAttachedToWindow(); ArgumentCaptor> wmConsumerCaptor = - ArgumentCaptor.forClass((Class) Consumer.class); + ArgumentCaptor.forClass(Consumer.class); verify(windowInfoRepo).addWindowLayoutInfoListener(any(), any(), wmConsumerCaptor.capture()); Consumer wmConsumer = wmConsumerCaptor.getValue(); + clearInvocations(flutterRenderer); wmConsumer.accept(testWindowLayout); // Then the Renderer receives the display feature verify(flutterRenderer).setViewportMetrics(viewportMetricsCaptor.capture()); - assertEquals( - FlutterRenderer.DisplayFeatureType.HINGE, - viewportMetricsCaptor.getValue().displayFeatures.get(0).type); - assertEquals( - FlutterRenderer.DisplayFeatureState.POSTURE_FLAT, - viewportMetricsCaptor.getValue().displayFeatures.get(0).state); - assertEquals( - new Rect(0, 0, 100, 100), viewportMetricsCaptor.getValue().displayFeatures.get(0).bounds); + assertEquals(1, viewportMetricsCaptor.getValue().getDisplayFeatures().size()); + FlutterRenderer.DisplayFeature feature = + viewportMetricsCaptor.getValue().getDisplayFeatures().get(0); + assertEquals(FlutterRenderer.DisplayFeatureType.HINGE, feature.type); + assertEquals(FlutterRenderer.DisplayFeatureState.POSTURE_FLAT, feature.state); + assertEquals(featureRect, feature.bounds); + + // Assert the display cutout is unaffected. + assertEquals(1, viewportMetricsCaptor.getValue().getDisplayCutouts().size()); + FlutterRenderer.DisplayFeature cutout = + viewportMetricsCaptor.getValue().getDisplayCutouts().get(0); + assertEquals(cutoutBoundingRects.get(0), cutout.bounds); + assertEquals(FlutterRenderer.DisplayFeatureType.CUTOUT, cutout.type); + assertEquals(FlutterRenderer.DisplayFeatureState.UNKNOWN, cutout.state); } @Test @@ -1173,6 +1280,34 @@ private void mockSystemGestureInsetsIfNeed(WindowInsets windowInsets) { } } + @SuppressWarnings("deprecation") + private WindowInsets setupMockDisplayCutout(List boundingRects) { + WindowInsets windowInsets = mock(WindowInsets.class); + DisplayCutout displayCutout = mock(DisplayCutout.class); + when(windowInsets.getDisplayCutout()).thenReturn(displayCutout); + when(displayCutout.getBoundingRects()).thenReturn(boundingRects); + // The following mocked methods are necessary to avoid a NullPointerException when calling + // onApplyWindowInsets, but are irrelevant to the behavior this test concerns. + Insets unusedInsets = Insets.of(100, 100, 100, 100); + // WindowInsets::getSystemGestureInsets was added in API 29, deprecated in API 30. + if (Build.VERSION.SDK_INT == 29) { + when(windowInsets.getSystemGestureInsets()).thenReturn(unusedInsets); + } + // WindowInsets::getInsets was added in API 30. + if (Build.VERSION.SDK_INT >= 30) { + when(windowInsets.getInsets(anyInt())).thenReturn(unusedInsets); + } + // DisplayCutout::getWaterfallInsets was added in API 30. + if (Build.VERSION.SDK_INT >= 30) { + when(displayCutout.getWaterfallInsets()).thenReturn(unusedInsets); + } + when(displayCutout.getSafeInsetTop()).thenReturn(100); + when(displayCutout.getSafeInsetLeft()).thenReturn(100); + when(displayCutout.getSafeInsetBottom()).thenReturn(100); + when(displayCutout.getSafeInsetRight()).thenReturn(100); + return windowInsets; + } + /* * A custom shadow that reports fullscreen flag for system UI visibility */ diff --git a/shell/platform/android/test/io/flutter/embedding/engine/renderer/FlutterRendererTest.java b/shell/platform/android/test/io/flutter/embedding/engine/renderer/FlutterRendererTest.java index 953a3e490b680..ddfabd1b6bf56 100644 --- a/shell/platform/android/test/io/flutter/embedding/engine/renderer/FlutterRendererTest.java +++ b/shell/platform/android/test/io/flutter/embedding/engine/renderer/FlutterRendererTest.java @@ -288,14 +288,20 @@ public void itConvertsDisplayFeatureArrayToPrimitiveArrays() { metrics.width = 1000; metrics.height = 1000; metrics.devicePixelRatio = 2; - metrics.displayFeatures.add( - new FlutterRenderer.DisplayFeature( - new Rect(10, 20, 30, 40), - FlutterRenderer.DisplayFeatureType.FOLD, - FlutterRenderer.DisplayFeatureState.POSTURE_HALF_OPENED)); - metrics.displayFeatures.add( - new FlutterRenderer.DisplayFeature( - new Rect(50, 60, 70, 80), FlutterRenderer.DisplayFeatureType.CUTOUT)); + metrics + .getDisplayFeatures() + .add( + new FlutterRenderer.DisplayFeature( + new Rect(10, 20, 30, 40), + FlutterRenderer.DisplayFeatureType.FOLD, + FlutterRenderer.DisplayFeatureState.POSTURE_HALF_OPENED)); + metrics + .getDisplayCutouts() + .add( + new FlutterRenderer.DisplayFeature( + new Rect(50, 60, 70, 80), + FlutterRenderer.DisplayFeatureType.CUTOUT, + FlutterRenderer.DisplayFeatureState.UNKNOWN)); // Execute the behavior under test. flutterRenderer.setViewportMetrics(metrics); diff --git a/shell/platform/darwin/ios/BUILD.gn b/shell/platform/darwin/ios/BUILD.gn index 6bd0397b26b9f..ab58295121949 100644 --- a/shell/platform/darwin/ios/BUILD.gn +++ b/shell/platform/darwin/ios/BUILD.gn @@ -222,6 +222,7 @@ source_set("flutter_framework_source") { "CoreMedia.framework", "CoreVideo.framework", "QuartzCore.framework", + "WebKit.framework", "UIKit.framework", ] if (flutter_runtime_mode == "profile" || flutter_runtime_mode == "debug") { diff --git a/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm b/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm index 713d4fd0e288f..01ee1847ea97c 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm @@ -792,10 +792,8 @@ + (NSString*)generateThreadLabel:(NSString*)labelPrefix { // initialized. fml::MessageLoop::EnsureInitializedForCurrentThread(); - uint32_t threadHostType = flutter::ThreadHost::Type::kRaster | flutter::ThreadHost::Type::kIo; - if (!settings.enable_impeller) { - threadHostType |= flutter::ThreadHost::Type::kUi; - } + uint32_t threadHostType = flutter::ThreadHost::Type::kRaster | flutter::ThreadHost::Type::kIo | + flutter::ThreadHost::Type::kUi; if ([FlutterEngine isProfilerEnabled]) { threadHostType = threadHostType | flutter::ThreadHost::Type::kProfiler; @@ -877,12 +875,7 @@ - (BOOL)createShell:(NSString*)entrypoint flutter::Shell::CreateCallback on_create_rasterizer = [](flutter::Shell& shell) { return std::make_unique(shell); }; - fml::RefPtr ui_runner; - if (settings.enable_impeller) { - ui_runner = fml::MessageLoop::GetCurrent().GetTaskRunner(); - } else { - ui_runner = _threadHost->ui_thread->GetTaskRunner(); - } + fml::RefPtr ui_runner = _threadHost->ui_thread->GetTaskRunner(); flutter::TaskRunners task_runners(threadLabel.UTF8String, // label fml::MessageLoop::GetCurrent().GetTaskRunner(), // platform _threadHost->raster_thread->GetTaskRunner(), // raster diff --git a/shell/platform/darwin/ios/framework/Source/FlutterEngineTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterEngineTest.mm index 26ad1e1c52f55..aeab1dff7ae40 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterEngineTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterEngineTest.mm @@ -477,8 +477,8 @@ - (void)testCanMergePlatformAndUIThread { FlutterEngine* engine = [[FlutterEngine alloc] initWithName:@"foobar" project:project]; [engine run]; - XCTAssertEqual(engine.shell.GetTaskRunners().GetUITaskRunner(), - engine.shell.GetTaskRunners().GetPlatformTaskRunner()); + XCTAssertNotEqual(engine.shell.GetTaskRunners().GetUITaskRunner(), + engine.shell.GetTaskRunners().GetPlatformTaskRunner()); } - (void)testCanNotUnMergePlatformAndUIThread { @@ -488,8 +488,8 @@ - (void)testCanNotUnMergePlatformAndUIThread { FlutterEngine* engine = [[FlutterEngine alloc] initWithName:@"foobar" project:project]; [engine run]; - XCTAssertEqual(engine.shell.GetTaskRunners().GetUITaskRunner(), - engine.shell.GetTaskRunners().GetPlatformTaskRunner()); + XCTAssertNotEqual(engine.shell.GetTaskRunners().GetUITaskRunner(), + engine.shell.GetTaskRunners().GetPlatformTaskRunner()); } @end diff --git a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm index 904a3eace2564..b91fde939a861 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm @@ -4,6 +4,7 @@ #import #import +#import #import #include "fml/synchronization/count_down_latch.h" #include "shell/platform/darwin/ios/framework/Source/platform_views_controller.h" @@ -20,7 +21,7 @@ FLUTTER_ASSERT_ARC @class FlutterPlatformViewsTestMockPlatformView; -__weak static FlutterPlatformViewsTestMockPlatformView* gMockPlatformView = nil; +__weak static UIView* gMockPlatformView = nil; const float kFloatCompareEpsilon = 0.001; @interface FlutterPlatformViewsTestMockPlatformView : UIView @@ -68,6 +69,9 @@ - (void)checkViewCreatedOnce { self.viewCreated = YES; } +- (void)dealloc { + gMockPlatformView = nil; +} @end @interface FlutterPlatformViewsTestMockFlutterPlatformFactory @@ -83,6 +87,49 @@ @implementation FlutterPlatformViewsTestMockFlutterPlatformFactory @end +@interface FlutterPlatformViewsTestMockWebView : NSObject +@property(nonatomic, strong) UIView* view; +@property(nonatomic, assign) BOOL viewCreated; +@end + +@implementation FlutterPlatformViewsTestMockWebView +- (instancetype)init { + if (self = [super init]) { + _view = [[WKWebView alloc] init]; + gMockPlatformView = _view; + _viewCreated = NO; + } + return self; +} + +- (UIView*)view { + [self checkViewCreatedOnce]; + return _view; +} + +- (void)checkViewCreatedOnce { + if (self.viewCreated) { + abort(); + } + self.viewCreated = YES; +} + +- (void)dealloc { + gMockPlatformView = nil; +} +@end + +@interface FlutterPlatformViewsTestMockWebViewFactory : NSObject +@end + +@implementation FlutterPlatformViewsTestMockWebViewFactory +- (NSObject*)createWithFrame:(CGRect)frame + viewIdentifier:(int64_t)viewId + arguments:(id _Nullable)args { + return [[FlutterPlatformViewsTestMockWebView alloc] init]; +} +@end + @interface FlutterPlatformViewsTestNilFlutterPlatformFactory : NSObject @end @@ -95,6 +142,93 @@ @implementation FlutterPlatformViewsTestNilFlutterPlatformFactory @end +@interface FlutterPlatformViewsTestMockWrapperWebView : NSObject +@property(nonatomic, strong) UIView* view; +@property(nonatomic, assign) BOOL viewCreated; +@end + +@implementation FlutterPlatformViewsTestMockWrapperWebView +- (instancetype)init { + if (self = [super init]) { + _view = [[UIView alloc] init]; + [_view addSubview:[[WKWebView alloc] init]]; + gMockPlatformView = _view; + _viewCreated = NO; + } + return self; +} + +- (UIView*)view { + [self checkViewCreatedOnce]; + return _view; +} + +- (void)checkViewCreatedOnce { + if (self.viewCreated) { + abort(); + } + self.viewCreated = YES; +} + +- (void)dealloc { + gMockPlatformView = nil; +} +@end + +@interface FlutterPlatformViewsTestMockWrapperWebViewFactory : NSObject +@end + +@implementation FlutterPlatformViewsTestMockWrapperWebViewFactory +- (NSObject*)createWithFrame:(CGRect)frame + viewIdentifier:(int64_t)viewId + arguments:(id _Nullable)args { + return [[FlutterPlatformViewsTestMockWrapperWebView alloc] init]; +} +@end + +@interface FlutterPlatformViewsTestMockNestedWrapperWebView : NSObject +@property(nonatomic, strong) UIView* view; +@property(nonatomic, assign) BOOL viewCreated; +@end + +@implementation FlutterPlatformViewsTestMockNestedWrapperWebView +- (instancetype)init { + if (self = [super init]) { + _view = [[UIView alloc] init]; + UIView* childView = [[UIView alloc] init]; + [_view addSubview:childView]; + [childView addSubview:[[WKWebView alloc] init]]; + gMockPlatformView = _view; + _viewCreated = NO; + } + return self; +} + +- (UIView*)view { + [self checkViewCreatedOnce]; + return _view; +} + +- (void)checkViewCreatedOnce { + if (self.viewCreated) { + abort(); + } + self.viewCreated = YES; +} +@end + +@interface FlutterPlatformViewsTestMockNestedWrapperWebViewFactory + : NSObject +@end + +@implementation FlutterPlatformViewsTestMockNestedWrapperWebViewFactory +- (NSObject*)createWithFrame:(CGRect)frame + viewIdentifier:(int64_t)viewId + arguments:(id _Nullable)args { + return [[FlutterPlatformViewsTestMockNestedWrapperWebView alloc] init]; +} +@end + namespace flutter { namespace { class FlutterPlatformViewsTestMockPlatformViewDelegate : public PlatformView::Delegate { @@ -2782,6 +2916,243 @@ - (void)testFlutterPlatformViewTouchesCancelledEventAreForcedToBeCancelled { flutterPlatformViewsController->Reset(); } +- (void) + testFlutterPlatformViewBlockGestureUnderEagerPolicyShouldRemoveAndAddBackDelayingRecognizerForWebView { + flutter::FlutterPlatformViewsTestMockPlatformViewDelegate mock_delegate; + + flutter::TaskRunners runners(/*label=*/self.name.UTF8String, + /*platform=*/GetDefaultTaskRunner(), + /*raster=*/GetDefaultTaskRunner(), + /*ui=*/GetDefaultTaskRunner(), + /*io=*/GetDefaultTaskRunner()); + auto flutterPlatformViewsController = std::make_shared(); + flutterPlatformViewsController->SetTaskRunner(GetDefaultTaskRunner()); + auto platform_view = std::make_unique( + /*delegate=*/mock_delegate, + /*rendering_api=*/mock_delegate.settings_.enable_impeller + ? flutter::IOSRenderingAPI::kMetal + : flutter::IOSRenderingAPI::kSoftware, + /*platform_views_controller=*/flutterPlatformViewsController, + /*task_runners=*/runners, + /*worker_task_runner=*/nil, + /*is_gpu_disabled_jsync_switch=*/std::make_shared()); + + FlutterPlatformViewsTestMockWebViewFactory* factory = + [[FlutterPlatformViewsTestMockWebViewFactory alloc] init]; + flutterPlatformViewsController->RegisterViewFactory( + factory, @"MockWebView", FlutterPlatformViewGestureRecognizersBlockingPolicyEager); + FlutterResult result = ^(id result) { + }; + flutterPlatformViewsController->OnMethodCall( + [FlutterMethodCall methodCallWithMethodName:@"create" + arguments:@{@"id" : @2, @"viewType" : @"MockWebView"}], + result); + + XCTAssertNotNil(gMockPlatformView); + + // Find touch inteceptor view + UIView* touchInteceptorView = gMockPlatformView; + while (touchInteceptorView != nil && + ![touchInteceptorView isKindOfClass:[FlutterTouchInterceptingView class]]) { + touchInteceptorView = touchInteceptorView.superview; + } + XCTAssertNotNil(touchInteceptorView); + + XCTAssert(touchInteceptorView.gestureRecognizers.count == 2); + UIGestureRecognizer* delayingRecognizer = touchInteceptorView.gestureRecognizers[0]; + UIGestureRecognizer* forwardingRecognizer = touchInteceptorView.gestureRecognizers[1]; + + XCTAssert([delayingRecognizer isKindOfClass:[FlutterDelayingGestureRecognizer class]]); + XCTAssert([forwardingRecognizer isKindOfClass:[ForwardingGestureRecognizer class]]); + + [(FlutterTouchInterceptingView*)touchInteceptorView blockGesture]; + + if (@available(iOS 18.2, *)) { + // Since we remove and add back delayingRecognizer, it would be reordered to the last. + XCTAssertEqual(touchInteceptorView.gestureRecognizers[0], forwardingRecognizer); + XCTAssertEqual(touchInteceptorView.gestureRecognizers[1], delayingRecognizer); + } else { + XCTAssertEqual(touchInteceptorView.gestureRecognizers[0], delayingRecognizer); + XCTAssertEqual(touchInteceptorView.gestureRecognizers[1], forwardingRecognizer); + } +} + +- (void) + testFlutterPlatformViewBlockGestureUnderEagerPolicyShouldRemoveAndAddBackDelayingRecognizerForWrapperWebView { + flutter::FlutterPlatformViewsTestMockPlatformViewDelegate mock_delegate; + + flutter::TaskRunners runners(/*label=*/self.name.UTF8String, + /*platform=*/GetDefaultTaskRunner(), + /*raster=*/GetDefaultTaskRunner(), + /*ui=*/GetDefaultTaskRunner(), + /*io=*/GetDefaultTaskRunner()); + auto flutterPlatformViewsController = std::make_shared(); + flutterPlatformViewsController->SetTaskRunner(GetDefaultTaskRunner()); + auto platform_view = std::make_unique( + /*delegate=*/mock_delegate, + /*rendering_api=*/mock_delegate.settings_.enable_impeller + ? flutter::IOSRenderingAPI::kMetal + : flutter::IOSRenderingAPI::kSoftware, + /*platform_views_controller=*/flutterPlatformViewsController, + /*task_runners=*/runners, + /*worker_task_runner=*/nil, + /*is_gpu_disabled_jsync_switch=*/std::make_shared()); + + FlutterPlatformViewsTestMockWrapperWebViewFactory* factory = + [[FlutterPlatformViewsTestMockWrapperWebViewFactory alloc] init]; + flutterPlatformViewsController->RegisterViewFactory( + factory, @"MockWrapperWebView", FlutterPlatformViewGestureRecognizersBlockingPolicyEager); + FlutterResult result = ^(id result) { + }; + flutterPlatformViewsController->OnMethodCall( + [FlutterMethodCall + methodCallWithMethodName:@"create" + arguments:@{@"id" : @2, @"viewType" : @"MockWrapperWebView"}], + result); + + XCTAssertNotNil(gMockPlatformView); + + // Find touch inteceptor view + UIView* touchInteceptorView = gMockPlatformView; + while (touchInteceptorView != nil && + ![touchInteceptorView isKindOfClass:[FlutterTouchInterceptingView class]]) { + touchInteceptorView = touchInteceptorView.superview; + } + XCTAssertNotNil(touchInteceptorView); + + XCTAssert(touchInteceptorView.gestureRecognizers.count == 2); + UIGestureRecognizer* delayingRecognizer = touchInteceptorView.gestureRecognizers[0]; + UIGestureRecognizer* forwardingRecognizer = touchInteceptorView.gestureRecognizers[1]; + + XCTAssert([delayingRecognizer isKindOfClass:[FlutterDelayingGestureRecognizer class]]); + XCTAssert([forwardingRecognizer isKindOfClass:[ForwardingGestureRecognizer class]]); + + [(FlutterTouchInterceptingView*)touchInteceptorView blockGesture]; + + if (@available(iOS 18.2, *)) { + // Since we remove and add back delayingRecognizer, it would be reordered to the last. + XCTAssertEqual(touchInteceptorView.gestureRecognizers[0], forwardingRecognizer); + XCTAssertEqual(touchInteceptorView.gestureRecognizers[1], delayingRecognizer); + } else { + XCTAssertEqual(touchInteceptorView.gestureRecognizers[0], delayingRecognizer); + XCTAssertEqual(touchInteceptorView.gestureRecognizers[1], forwardingRecognizer); + } +} + +- (void) + testFlutterPlatformViewBlockGestureUnderEagerPolicyShouldNotRemoveAndAddBackDelayingRecognizerForNestedWrapperWebView { + flutter::FlutterPlatformViewsTestMockPlatformViewDelegate mock_delegate; + + flutter::TaskRunners runners(/*label=*/self.name.UTF8String, + /*platform=*/GetDefaultTaskRunner(), + /*raster=*/GetDefaultTaskRunner(), + /*ui=*/GetDefaultTaskRunner(), + /*io=*/GetDefaultTaskRunner()); + auto flutterPlatformViewsController = std::make_shared(); + flutterPlatformViewsController->SetTaskRunner(GetDefaultTaskRunner()); + auto platform_view = std::make_unique( + /*delegate=*/mock_delegate, + /*rendering_api=*/mock_delegate.settings_.enable_impeller + ? flutter::IOSRenderingAPI::kMetal + : flutter::IOSRenderingAPI::kSoftware, + /*platform_views_controller=*/flutterPlatformViewsController, + /*task_runners=*/runners, + /*worker_task_runner=*/nil, + /*is_gpu_disabled_jsync_switch=*/std::make_shared()); + + FlutterPlatformViewsTestMockNestedWrapperWebViewFactory* factory = + [[FlutterPlatformViewsTestMockNestedWrapperWebViewFactory alloc] init]; + flutterPlatformViewsController->RegisterViewFactory( + factory, @"MockNestedWrapperWebView", + FlutterPlatformViewGestureRecognizersBlockingPolicyEager); + FlutterResult result = ^(id result) { + }; + flutterPlatformViewsController->OnMethodCall( + [FlutterMethodCall + methodCallWithMethodName:@"create" + arguments:@{@"id" : @2, @"viewType" : @"MockNestedWrapperWebView"}], + result); + + XCTAssertNotNil(gMockPlatformView); + + // Find touch inteceptor view + UIView* touchInteceptorView = gMockPlatformView; + while (touchInteceptorView != nil && + ![touchInteceptorView isKindOfClass:[FlutterTouchInterceptingView class]]) { + touchInteceptorView = touchInteceptorView.superview; + } + XCTAssertNotNil(touchInteceptorView); + + XCTAssert(touchInteceptorView.gestureRecognizers.count == 2); + UIGestureRecognizer* delayingRecognizer = touchInteceptorView.gestureRecognizers[0]; + UIGestureRecognizer* forwardingRecognizer = touchInteceptorView.gestureRecognizers[1]; + + XCTAssert([delayingRecognizer isKindOfClass:[FlutterDelayingGestureRecognizer class]]); + XCTAssert([forwardingRecognizer isKindOfClass:[ForwardingGestureRecognizer class]]); + + [(FlutterTouchInterceptingView*)touchInteceptorView blockGesture]; + + XCTAssertEqual(touchInteceptorView.gestureRecognizers[0], delayingRecognizer); + XCTAssertEqual(touchInteceptorView.gestureRecognizers[1], forwardingRecognizer); +} + +- (void) + testFlutterPlatformViewBlockGestureUnderEagerPolicyShouldNotRemoveAndAddBackDelayingRecognizerForNonWebView { + flutter::FlutterPlatformViewsTestMockPlatformViewDelegate mock_delegate; + + flutter::TaskRunners runners(/*label=*/self.name.UTF8String, + /*platform=*/GetDefaultTaskRunner(), + /*raster=*/GetDefaultTaskRunner(), + /*ui=*/GetDefaultTaskRunner(), + /*io=*/GetDefaultTaskRunner()); + auto flutterPlatformViewsController = std::make_shared(); + flutterPlatformViewsController->SetTaskRunner(GetDefaultTaskRunner()); + auto platform_view = std::make_unique( + /*delegate=*/mock_delegate, + /*rendering_api=*/mock_delegate.settings_.enable_impeller + ? flutter::IOSRenderingAPI::kMetal + : flutter::IOSRenderingAPI::kSoftware, + /*platform_views_controller=*/flutterPlatformViewsController, + /*task_runners=*/runners, + /*worker_task_runner=*/nil, + /*is_gpu_disabled_jsync_switch=*/std::make_shared()); + + FlutterPlatformViewsTestMockFlutterPlatformFactory* factory = + [[FlutterPlatformViewsTestMockFlutterPlatformFactory alloc] init]; + flutterPlatformViewsController->RegisterViewFactory( + factory, @"MockFlutterPlatformView", + FlutterPlatformViewGestureRecognizersBlockingPolicyEager); + FlutterResult result = ^(id result) { + }; + flutterPlatformViewsController->OnMethodCall( + [FlutterMethodCall + methodCallWithMethodName:@"create" + arguments:@{@"id" : @2, @"viewType" : @"MockFlutterPlatformView"}], + result); + + XCTAssertNotNil(gMockPlatformView); + + // Find touch inteceptor view + UIView* touchInteceptorView = gMockPlatformView; + while (touchInteceptorView != nil && + ![touchInteceptorView isKindOfClass:[FlutterTouchInterceptingView class]]) { + touchInteceptorView = touchInteceptorView.superview; + } + XCTAssertNotNil(touchInteceptorView); + + XCTAssert(touchInteceptorView.gestureRecognizers.count == 2); + UIGestureRecognizer* delayingRecognizer = touchInteceptorView.gestureRecognizers[0]; + UIGestureRecognizer* forwardingRecognizer = touchInteceptorView.gestureRecognizers[1]; + + XCTAssert([delayingRecognizer isKindOfClass:[FlutterDelayingGestureRecognizer class]]); + XCTAssert([forwardingRecognizer isKindOfClass:[ForwardingGestureRecognizer class]]); + + [(FlutterTouchInterceptingView*)touchInteceptorView blockGesture]; + + XCTAssertEqual(touchInteceptorView.gestureRecognizers[0], delayingRecognizer); + XCTAssertEqual(touchInteceptorView.gestureRecognizers[1], forwardingRecognizer); +} + - (void)testFlutterPlatformViewControllerSubmitFrameWithoutFlutterViewNotCrashing { flutter::FlutterPlatformViewsTestMockPlatformViewDelegate mock_delegate; diff --git a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.h b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.h index b42c267074d4f..d9d9097cbe8d3 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.h +++ b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.h @@ -160,4 +160,43 @@ @property(nonatomic, readonly) BOOL flt_hasFirstResponderInViewHierarchySubtree; @end +// This recognizer delays touch events from being dispatched to the responder chain until it failed +// recognizing a gesture. +// +// We only fail this recognizer when asked to do so by the Flutter framework (which does so by +// invoking an acceptGesture method on the platform_views channel). And this is how we allow the +// Flutter framework to delay or prevent the embedded view from getting a touch sequence. +@interface FlutterDelayingGestureRecognizer : UIGestureRecognizer + +// Indicates that if the `FlutterDelayingGestureRecognizer`'s state should be set to +// `UIGestureRecognizerStateEnded` during next `touchesEnded` call. +@property(nonatomic) BOOL shouldEndInNextTouchesEnded; + +// Indicates that the `FlutterDelayingGestureRecognizer`'s `touchesEnded` has been invoked without +// setting the state to `UIGestureRecognizerStateEnded`. +@property(nonatomic) BOOL touchedEndedWithoutBlocking; + +@property(nonatomic, readonly) UIGestureRecognizer* forwardingRecognizer; + +- (instancetype)initWithTarget:(id)target + action:(SEL)action + forwardingRecognizer:(UIGestureRecognizer*)forwardingRecognizer; +@end + +// While the FlutterDelayingGestureRecognizer is preventing touches from hitting the responder chain +// the touch events are not arriving to the FlutterView (and thus not arriving to the Flutter +// framework). We use this gesture recognizer to dispatch the events directly to the FlutterView +// while during this phase. +// +// If the Flutter framework decides to dispatch events to the embedded view, we fail the +// FlutterDelayingGestureRecognizer which sends the events up the responder chain. But since the +// events are handled by the embedded view they are not delivered to the Flutter framework in this +// phase as well. So during this phase as well the ForwardingGestureRecognizer dispatched the events +// directly to the FlutterView. +@interface ForwardingGestureRecognizer : UIGestureRecognizer +- (instancetype)initWithTarget:(id)target + platformViewsController: + (fml::WeakPtr)platformViewsController; +@end + #endif // FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERPLATFORMVIEWS_INTERNAL_H_ diff --git a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.mm b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.mm index 5e76654bed179..7071183caa3b7 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.mm @@ -4,6 +4,8 @@ #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.h" +#import + #include "flutter/display_list/effects/dl_image_filter.h" #include "flutter/fml/platform/darwin/cf_utils.h" #import "flutter/shell/platform/darwin/ios/ios_surface.h" @@ -510,45 +512,6 @@ - (BOOL)flt_hasFirstResponderInViewHierarchySubtree { } @end -// This recognizer delays touch events from being dispatched to the responder chain until it failed -// recognizing a gesture. -// -// We only fail this recognizer when asked to do so by the Flutter framework (which does so by -// invoking an acceptGesture method on the platform_views channel). And this is how we allow the -// Flutter framework to delay or prevent the embedded view from getting a touch sequence. -@interface FlutterDelayingGestureRecognizer : UIGestureRecognizer - -// Indicates that if the `FlutterDelayingGestureRecognizer`'s state should be set to -// `UIGestureRecognizerStateEnded` during next `touchesEnded` call. -@property(nonatomic) BOOL shouldEndInNextTouchesEnded; - -// Indicates that the `FlutterDelayingGestureRecognizer`'s `touchesEnded` has been invoked without -// setting the state to `UIGestureRecognizerStateEnded`. -@property(nonatomic) BOOL touchedEndedWithoutBlocking; - -@property(nonatomic, readonly) UIGestureRecognizer* forwardingRecognizer; - -- (instancetype)initWithTarget:(id)target - action:(SEL)action - forwardingRecognizer:(UIGestureRecognizer*)forwardingRecognizer; -@end - -// While the FlutterDelayingGestureRecognizer is preventing touches from hitting the responder chain -// the touch events are not arriving to the FlutterView (and thus not arriving to the Flutter -// framework). We use this gesture recognizer to dispatch the events directly to the FlutterView -// while during this phase. -// -// If the Flutter framework decides to dispatch events to the embedded view, we fail the -// FlutterDelayingGestureRecognizer which sends the events up the responder chain. But since the -// events are handled by the embedded view they are not delivered to the Flutter framework in this -// phase as well. So during this phase as well the ForwardingGestureRecognizer dispatched the events -// directly to the FlutterView. -@interface ForwardingGestureRecognizer : UIGestureRecognizer -- (instancetype)initWithTarget:(id)target - platformViewsController: - (fml::WeakPtr)platformViewsController; -@end - @interface FlutterTouchInterceptingView () @property(nonatomic, weak, readonly) UIView* embeddedView; @property(nonatomic, readonly) FlutterDelayingGestureRecognizer* delayingRecognizer; @@ -590,11 +553,48 @@ - (void)releaseGesture { self.delayingRecognizer.state = UIGestureRecognizerStateFailed; } +- (BOOL)containsWebView:(UIView*)view remainingSubviewDepth:(int)remainingSubviewDepth { + if (remainingSubviewDepth < 0) { + return NO; + } + if ([view isKindOfClass:[WKWebView class]]) { + return YES; + } + for (UIView* subview in view.subviews) { + if ([self containsWebView:subview remainingSubviewDepth:remainingSubviewDepth - 1]) { + return YES; + } + } + return NO; +} + - (void)blockGesture { switch (_blockingPolicy) { case FlutterPlatformViewGestureRecognizersBlockingPolicyEager: // We block all other gesture recognizers immediately in this policy. self.delayingRecognizer.state = UIGestureRecognizerStateEnded; + + // On iOS 18.2, WKWebView's internal recognizer likely caches the old state of its blocking + // recognizers (i.e. delaying recognizer), resulting in non-tappable links. See + // https://github.com/flutter/flutter/issues/158961. Removing and adding back the delaying + // recognizer solves the problem, possibly because UIKit notifies all the recognizers related + // to (blocking or blocked by) this recognizer. It is not possible to inject this workaround + // from the web view plugin level. Right now we only observe this issue for + // FlutterPlatformViewGestureRecognizersBlockingPolicyEager, but we should try it if a similar + // issue arises for the other policy. + if (@available(iOS 18.2, *)) { + // This workaround is designed for WKWebView only. The 1P web view plugin provides a + // WKWebView itself as the platform view. However, some 3P plugins provide wrappers of + // WKWebView instead. So we perform DFS to search the view hierarchy (with a depth limit). + // Passing a limit of 0 means only searching for platform view itself; Pass 1 to include its + // children as well, and so on. We should be conservative and start with a small number. The + // AdMob banner has a WKWebView at depth 7. + if ([self containsWebView:self.embeddedView remainingSubviewDepth:1]) { + [self removeGestureRecognizer:self.delayingRecognizer]; + [self addGestureRecognizer:self.delayingRecognizer]; + } + } + break; case FlutterPlatformViewGestureRecognizersBlockingPolicyWaitUntilTouchesEnded: if (self.delayingRecognizer.touchedEndedWithoutBlocking) { diff --git a/shell/platform/darwin/ios/framework/Source/platform_views_controller.mm b/shell/platform/darwin/ios/framework/Source/platform_views_controller.mm index 7f8a73294953c..1afc84a0ad291 100644 --- a/shell/platform/darwin/ios/framework/Source/platform_views_controller.mm +++ b/shell/platform/darwin/ios/framework/Source/platform_views_controller.mm @@ -796,6 +796,7 @@ bool ClipRRectContainsPlatformViewBoundingRect(const SkRRect& clip_rrect, previous_composition_order_.clear(); NSMutableArray* desired_platform_subviews = [NSMutableArray array]; for (int64_t platform_view_id : composition_order) { + previous_composition_order_.push_back(platform_view_id); UIView* platform_view_root = platform_views_[platform_view_id].root_view.get(); if (platform_view_root != nil) { [desired_platform_subviews addObject:platform_view_root]; @@ -806,7 +807,6 @@ bool ClipRRectContainsPlatformViewBoundingRect(const SkRRect& clip_rrect, auto view = maybe_layer_data->second.layer->overlay_view_wrapper; if (view != nil) { [desired_platform_subviews addObject:view]; - previous_composition_order_.push_back(platform_view_id); } } } diff --git a/shell/platform/darwin/macos/BUILD.gn b/shell/platform/darwin/macos/BUILD.gn index 1224e15e02e5c..3ce4bfc1f0a77 100644 --- a/shell/platform/darwin/macos/BUILD.gn +++ b/shell/platform/darwin/macos/BUILD.gn @@ -253,7 +253,8 @@ copy("copy_framework_module_map") { copy("copy_framework_privacy_manifest") { visibility = [ ":*" ] sources = [ "framework/PrivacyInfo.xcprivacy" ] - outputs = [ "$_flutter_framework_dir/PrivacyInfo.xcprivacy" ] + outputs = + [ "$_flutter_framework_dir/Versions/A/Resources/PrivacyInfo.xcprivacy" ] } action("copy_framework_headers") { diff --git a/shell/platform/darwin/macos/framework/Source/FlutterViewControllerTest.mm b/shell/platform/darwin/macos/framework/Source/FlutterViewControllerTest.mm index b63cf439c96a1..2eff3d715677b 100644 --- a/shell/platform/darwin/macos/framework/Source/FlutterViewControllerTest.mm +++ b/shell/platform/darwin/macos/framework/Source/FlutterViewControllerTest.mm @@ -21,6 +21,9 @@ #pragma mark - Test Helper Classes +static const FlutterPointerEvent kDefaultFlutterPointerEvent = {}; +static const FlutterKeyEvent kDefaultFlutterKeyEvent = {}; + // A wrap to convert FlutterKeyEvent to a ObjC class. @interface KeyEventWrapper : NSObject @property(nonatomic) FlutterKeyEvent* data; @@ -339,7 +342,7 @@ - (bool)testKeyEventsAreSentToFramework:(id)engineMock { OCMStub( // NOLINT(google-objc-avoid-throwing-exception) [engineMock binaryMessenger]) .andReturn(binaryMessengerMock); - OCMStub([[engineMock ignoringNonObjectArgs] sendKeyEvent:FlutterKeyEvent {} + OCMStub([[engineMock ignoringNonObjectArgs] sendKeyEvent:kDefaultFlutterKeyEvent callback:nil userData:nil]) .andCall([FlutterViewControllerTestObjC class], @@ -375,7 +378,7 @@ - (bool)testKeyEventsAreSentToFramework:(id)engineMock { - (bool)testCtrlTabKeyEventIsPropagated:(id)engineMock { __block bool called = false; __block FlutterKeyEvent last_event; - OCMStub([[engineMock ignoringNonObjectArgs] sendKeyEvent:FlutterKeyEvent {} + OCMStub([[engineMock ignoringNonObjectArgs] sendKeyEvent:kDefaultFlutterKeyEvent callback:nil userData:nil]) .andDo((^(NSInvocation* invocation) { @@ -419,7 +422,7 @@ - (bool)testCtrlTabKeyEventIsPropagated:(id)engineMock { - (bool)testKeyEquivalentIsPassedToTextInputPlugin:(id)engineMock { __block bool called = false; __block FlutterKeyEvent last_event; - OCMStub([[engineMock ignoringNonObjectArgs] sendKeyEvent:FlutterKeyEvent {} + OCMStub([[engineMock ignoringNonObjectArgs] sendKeyEvent:kDefaultFlutterKeyEvent callback:nil userData:nil]) .andDo((^(NSInvocation* invocation) { @@ -471,7 +474,7 @@ - (bool)testKeyEventsArePropagatedIfNotHandled:(id)engineMock { OCMStub( // NOLINT(google-objc-avoid-throwing-exception) [engineMock binaryMessenger]) .andReturn(binaryMessengerMock); - OCMStub([[engineMock ignoringNonObjectArgs] sendKeyEvent:FlutterKeyEvent {} + OCMStub([[engineMock ignoringNonObjectArgs] sendKeyEvent:kDefaultFlutterKeyEvent callback:nil userData:nil]) .andCall([FlutterViewControllerTestObjC class], @@ -545,7 +548,7 @@ - (bool)testFlagsChangedEventsArePropagatedIfNotHandled:(id)engineMock { OCMStub( // NOLINT(google-objc-avoid-throwing-exception) [engineMock binaryMessenger]) .andReturn(binaryMessengerMock); - OCMStub([[engineMock ignoringNonObjectArgs] sendKeyEvent:FlutterKeyEvent {} + OCMStub([[engineMock ignoringNonObjectArgs] sendKeyEvent:kDefaultFlutterKeyEvent callback:nil userData:nil]) .andCall([FlutterViewControllerTestObjC class], @@ -598,7 +601,7 @@ - (bool)testKeyEventsAreNotPropagatedIfHandled:(id)engineMock { OCMStub( // NOLINT(google-objc-avoid-throwing-exception) [engineMock binaryMessenger]) .andReturn(binaryMessengerMock); - OCMStub([[engineMock ignoringNonObjectArgs] sendKeyEvent:FlutterKeyEvent {} + OCMStub([[engineMock ignoringNonObjectArgs] sendKeyEvent:kDefaultFlutterKeyEvent callback:nil userData:nil]) .andCall([FlutterViewControllerTestObjC class], @@ -655,7 +658,7 @@ - (bool)testKeyboardIsRestartedOnEngineRestart:(id)engineMock { .andReturn(binaryMessengerMock); __block bool called = false; __block FlutterKeyEvent last_event; - OCMStub([[engineMock ignoringNonObjectArgs] sendKeyEvent:FlutterKeyEvent {} + OCMStub([[engineMock ignoringNonObjectArgs] sendKeyEvent:kDefaultFlutterKeyEvent callback:nil userData:nil]) .andDo((^(NSInvocation* invocation) { @@ -715,7 +718,7 @@ - (bool)testTrackpadGesturesAreSentToFramework:(id)engineMock { OCMStub([engineMock renderer]).andReturn(renderer_); __block bool called = false; __block FlutterPointerEvent last_event; - OCMStub([[engineMock ignoringNonObjectArgs] sendPointerEvent:FlutterPointerEvent{}]) + OCMStub([[engineMock ignoringNonObjectArgs] sendPointerEvent:kDefaultFlutterPointerEvent]) .andDo((^(NSInvocation* invocation) { FlutterPointerEvent* event; [invocation getArgument:&event atIndex:2]; @@ -1139,7 +1142,7 @@ - (bool)testModifierKeysAreSynthesizedOnMouseMove:(id)engineMock { // Capture calls to sendKeyEvent __block NSMutableArray* events = [NSMutableArray array]; - OCMStub([[engineMock ignoringNonObjectArgs] sendKeyEvent:FlutterKeyEvent {} + OCMStub([[engineMock ignoringNonObjectArgs] sendKeyEvent:kDefaultFlutterKeyEvent callback:nil userData:nil]) .andDo((^(NSInvocation* invocation) { diff --git a/testing/dart/codec_test.dart b/testing/dart/codec_test.dart index e21b099a8cee0..7d0a2a23d2758 100644 --- a/testing/dart/codec_test.dart +++ b/testing/dart/codec_test.dart @@ -252,6 +252,46 @@ void main() { imageData = (await image.toByteData())!; expect(imageData.getUint32(imageData.lengthInBytes - 4), 0x00000000); }); + + test( + 'Animated apng frame decode does not crash with invalid destination region', + () async { + final Uint8List data = File( + path.join('flutter', 'lib', 'ui', 'fixtures', 'out_of_bounds.apng'), + ).readAsBytesSync(); + + final ui.Codec codec = await ui.instantiateImageCodec(data); + try { + await codec.getNextFrame(); + fail('exception not thrown'); + } on Exception catch (e) { + if (impellerEnabled) { + expect(e.toString(), contains('Could not decompress image.')); + } else { + expect(e.toString(), contains('Codec failed')); + } + } + }); + + test( + 'Animated apng frame decode does not crash with invalid destination region and bounds wrapping', + () async { + final Uint8List data = File( + path.join('flutter', 'lib', 'ui', 'fixtures', 'out_of_bounds_wrapping.apng'), + ).readAsBytesSync(); + + final ui.Codec codec = await ui.instantiateImageCodec(data); + try { + await codec.getNextFrame(); + fail('exception not thrown'); + } on Exception catch (e) { + if (impellerEnabled) { + expect(e.toString(), contains('Could not decompress image.')); + } else { + expect(e.toString(), contains('Codec failed')); + } + } + }); } /// Returns a File handle to a file in the skia/resources directory. diff --git a/testing/impeller_golden_tests_output.txt b/testing/impeller_golden_tests_output.txt index 0979bbc406399..886c7a0f3527c 100644 --- a/testing/impeller_golden_tests_output.txt +++ b/testing/impeller_golden_tests_output.txt @@ -548,6 +548,12 @@ impeller_Play_AiksTest_CoordinateConversionsAreCorrect_Vulkan.png impeller_Play_AiksTest_CoverageOriginShouldBeAccountedForInSubpasses_Metal.png impeller_Play_AiksTest_CoverageOriginShouldBeAccountedForInSubpasses_OpenGLES.png impeller_Play_AiksTest_CoverageOriginShouldBeAccountedForInSubpasses_Vulkan.png +impeller_Play_AiksTest_DepthValuesForLineMode_Metal.png +impeller_Play_AiksTest_DepthValuesForLineMode_OpenGLES.png +impeller_Play_AiksTest_DepthValuesForLineMode_Vulkan.png +impeller_Play_AiksTest_DepthValuesForPolygonMode_Metal.png +impeller_Play_AiksTest_DepthValuesForPolygonMode_OpenGLES.png +impeller_Play_AiksTest_DepthValuesForPolygonMode_Vulkan.png impeller_Play_AiksTest_DispatcherDoesNotCullPerspectiveTransformedChildDisplayLists_Metal.png impeller_Play_AiksTest_DispatcherDoesNotCullPerspectiveTransformedChildDisplayLists_OpenGLES.png impeller_Play_AiksTest_DispatcherDoesNotCullPerspectiveTransformedChildDisplayLists_Vulkan.png