From 4df8f73548cfe54b4dd837e1dc2c44ce8f595b59 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Fri, 21 Aug 2026 15:31:42 +0000 Subject: [PATCH 01/46] Roll Packages from 1785501174f3 to 252bb33ad366 (6 revisions) (#191480) https://github.com/flutter/packages/compare/1785501174f3...252bb33ad366 2026-08-20 pateltirth454@gmail.com [material_ui] A typo in the README file of the package. (flutter/packages#12526) 2026-08-20 jmccandless@google.com [material_ui] Animated theme test from flutter/flutter (flutter/packages#12487) 2026-08-20 fluttergithubbot@gmail.com Sync release-material_ui-1.0.1 to main (flutter/packages#12513) 2026-08-20 pateltirth454@gmail.com [material_ui] [cupertino_ui] Add missing Widget of the Week videos to widget API docs (flutter/packages#12468) 2026-08-20 43054281+camsim99@users.noreply.github.com [camera_android_camerax] Add agentic guidance for adding native unit tests (flutter/packages#12369) 2026-08-20 21270878+elliette@users.noreply.github.com [material_ui] SearchAnchor overlay expands to full screen when viewport size changes (flutter/packages#12466) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/flutter-packages-flutter-autoroll Please CC flutter-ecosystem@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- bin/internal/flutter_packages.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/flutter_packages.version b/bin/internal/flutter_packages.version index f8e805bba2cab..56e1df57b98bb 100644 --- a/bin/internal/flutter_packages.version +++ b/bin/internal/flutter_packages.version @@ -1 +1 @@ -1785501174f39c234ec99d9fc2da503eb9fbfc7b +252bb33ad3666c7d28621c87edccafff86e210fd From 3512ebea9c7fd7c6cda88d214c21ae4df00bc9bc Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Fri, 21 Aug 2026 16:10:58 +0000 Subject: [PATCH 02/46] Roll Skia from 70988bed1b3b to f6900c5b8439 (2 revisions) (#191481) https://skia.googlesource.com/skia.git/+log/70988bed1b3b..f6900c5b8439 2026-08-21 thomsmit@google.com [graphite] Add sparse strips coverage oracle 2026-08-21 skia-autoroll@skia-public.iam.gserviceaccount.com Roll vulkan-deps from 71c3f1723099 to 3cc0a5d1905e (3 revisions) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/skia-flutter-autoroll Please CC awolff@google.com,kjlubick@google.com,robertphillips@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Skia: https://bugs.chromium.org/p/skia/issues/entry To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPS b/DEPS index 3c3e807f5806c..4c8bf5839f1cf 100644 --- a/DEPS +++ b/DEPS @@ -15,7 +15,7 @@ vars = { 'skia_git': 'https://skia.googlesource.com', 'llvm_git': 'https://llvm.googlesource.com', 'dart_ai_rev': '9c96bfe5f091c9451eff5b59c9bffeb2e806b875', - 'skia_revision': '70988bed1b3bea0026026083ca0accd215620d53', + 'skia_revision': 'f6900c5b8439132de9ad98b56ea67a65430a7dbc', # Do not download the Emscripten SDK by default. # This prevents us from downloading the Emscripten toolchain for builds From e210443e9ba4d249a4044de8eb18e79ba7784de6 Mon Sep 17 00:00:00 2001 From: Ben Konyi Date: Fri, 21 Aug 2026 16:45:08 +0000 Subject: [PATCH 03/46] [flutter_tools] Deprecate --build and --no-build flags on flutter run (#191358) ## Description This PR formally deprecates the \`--build\` and \`--no-build\` flags on \`flutter run\` in favor of \`--use-application-binary\`, aligning \`flutter run\` with \`flutter drive\` (which previously deprecated \`--build\`). ### Context & Changes * In 2019 ([#37735](https://github.com/flutter/flutter/pull/37735)), \`flutter run\` stopped handling the \`build\` flag when \`--use-application-binary\` was added, but the flag definition was unintentionally left on \`argParser\`, causing it to remain in \`--help\` as a silent no-op. * Rather than restoring implicit build-skipping behavior (which introduces ambiguity regarding which derived artifact to run across build flavors, split APKs, and schemes), we deprecate \`--[no-]build\` and direct users to \`--use-application-binary\`. * Hides \`--build\` from standard \`flutter run -h\` output (\`hide: !verboseHelp\`). * Emits a deprecation warning when \`--build\` or \`--no-build\` is passed. * Adds hermetic unit tests in \`run_test.dart\` for the deprecation warnings. ## Related Issues Fixes #68529 ## Tests - Added hermetic unit tests in \`packages/flutter_tools/test/commands.shard/hermetic/run_test.dart\` verifying warning triggers and default execution. - Verified \`packages/flutter_tools/test/general.shard/args_test.dart\` passes. --- .../flutter_tools/lib/src/commands/run.dart | 23 +++++- .../commands.shard/hermetic/run_test.dart | 72 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/packages/flutter_tools/lib/src/commands/run.dart b/packages/flutter_tools/lib/src/commands/run.dart index 2063b544440cb..107e16a5d8d0c 100644 --- a/packages/flutter_tools/lib/src/commands/run.dart +++ b/packages/flutter_tools/lib/src/commands/run.dart @@ -489,7 +489,14 @@ class RunCommand extends RunCommandBase { 'a test using "flutter run" for debugging purposes. This flag is ' 'only available when running in debug mode.', ) - ..addFlag('build', defaultsTo: true, help: 'If necessary, build the app before running.') + ..addFlag( + 'build', + defaultsTo: true, + hide: !verboseHelp, + help: + '(deprecated) If necessary, build the app before running. To use an existing app, pass the "--${FlutterOptions.kUseApplicationBinary}" ' + 'flag with an existing application artifact.', + ) ..addOption('project-root', hide: !verboseHelp, help: 'Specify the project root directory.') ..addFlag( 'hot', @@ -751,6 +758,20 @@ class RunCommand extends RunCommandBase { 'behave differently in a future release.', ); } + + if (argResults!.wasParsed('build')) { + if (boolArg('build')) { + globals.printWarning( + 'The "--build" flag is deprecated and will be removed in a future release. ' + 'Building is the default behavior, so this flag can be safely removed.', + ); + } else { + globals.printWarning( + 'The "--no-build" flag is deprecated and will be removed in a future release. ' + 'To use a prebuilt application, pass "--${FlutterOptions.kUseApplicationBinary}".', + ); + } + } } @visibleForTesting diff --git a/packages/flutter_tools/test/commands.shard/hermetic/run_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/run_test.dart index e096756bb5679..ef23243828a58 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/run_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/run_test.dart @@ -1913,6 +1913,78 @@ server: // https://github.com/flutter/flutter/issues/142060 skip: true, ); + + testUsingContext( + 'warning triggered when --build flag is passed', + () async { + final CommandRunner runner = createTestCommandRunner( + TestRunCommandThatOnlyValidates(), + ); + await runner.run(['run', '--build']); + + expect( + testLogger.warningText, + contains( + 'The "--build" flag is deprecated and will be removed in a future release. ' + 'Building is the default behavior, so this flag can be safely removed.', + ), + ); + }, + overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => FakeProcessManager.any(), + Logger: () => logger, + DeviceManager: () => testDeviceManager, + }, + initializeFlutterRoot: false, + ); + + testUsingContext( + 'warning triggered when --no-build flag is passed', + () async { + final CommandRunner runner = createTestCommandRunner( + TestRunCommandThatOnlyValidates(), + ); + await runner.run(['run', '--no-build']); + + expect( + testLogger.warningText, + contains( + 'The "--no-build" flag is deprecated and will be removed in a future release. ' + 'To use a prebuilt application, pass "--${FlutterOptions.kUseApplicationBinary}".', + ), + ); + }, + overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => FakeProcessManager.any(), + Logger: () => logger, + DeviceManager: () => testDeviceManager, + }, + initializeFlutterRoot: false, + ); + + testUsingContext( + 'no warning triggered when --build or --no-build flag is not passed', + () async { + final CommandRunner runner = createTestCommandRunner( + TestRunCommandThatOnlyValidates(), + ); + await runner.run(['run']); + + expect( + testLogger.warningText, + isNot(contains('is deprecated')), + ); + }, + overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => FakeProcessManager.any(), + Logger: () => logger, + DeviceManager: () => testDeviceManager, + }, + initializeFlutterRoot: false, + ); }); } From 8bb6d75985d5165cc5a684c521f985ccad6b812e Mon Sep 17 00:00:00 2001 From: Ben Konyi Date: Fri, 21 Aug 2026 16:54:15 +0000 Subject: [PATCH 04/46] [Widget Preview] Isolate PageStorage scope in widget preview group expansion tile (#191378) ## Description When a preview group is collapsed and re-expanded in the Widget Previewer, descendant scrollables (which search ancestor elements for a `PageStorageKey` when restoring scroll offset) inherit the `PageStorageKey` from the `ExpansionTile`. They then attempt to read the `ExpansionTile`'s boolean expansion state as a double, throwing a `TypeError: true: type 'bool' is not a subtype of type 'double?'`. This PR wraps the `ExpansionTile` children in a `PageStorage` widget with a new `PageStorageBucket`, preventing descendant scrollable widgets from inheriting the `ExpansionTile`'s `PageStorageKey`. ## Related Issues Fixes https://github.com/flutter/flutter/issues/191242 ## Tests - Tested in widget preview scaffold template. --- .../lib/src/widget_preview_rendering.dart | 53 +++++++++++++------ .../src/widget_preview_rendering.dart.tmpl | 53 +++++++++++++------ 2 files changed, 74 insertions(+), 32 deletions(-) diff --git a/dev/integration_tests/widget_preview_scaffold/lib/src/widget_preview_rendering.dart b/dev/integration_tests/widget_preview_scaffold/lib/src/widget_preview_rendering.dart index b731021768edb..05c7f552c841a 100644 --- a/dev/integration_tests/widget_preview_scaffold/lib/src/widget_preview_rendering.dart +++ b/dev/integration_tests/widget_preview_scaffold/lib/src/widget_preview_rendering.dart @@ -217,7 +217,7 @@ class PreviewWidgetElement extends StatelessElement { PreviewWidgetElement(super.widget); } -class WidgetPreviewGroupWidget extends StatelessWidget { +class WidgetPreviewGroupWidget extends StatefulWidget { const WidgetPreviewGroupWidget({ super.key, required this.controller, @@ -235,6 +235,14 @@ class WidgetPreviewGroupWidget extends StatelessWidget { // TODO(bkonyi): inherit this from the theme. static const _kCardRadius = Radius.circular(12); + @override + State createState() => + _WidgetPreviewGroupWidgetState(); +} + +class _WidgetPreviewGroupWidgetState extends State { + final _bucket = PageStorageBucket(); + Widget _buildGridViewFlex(List previews) { return Wrap( spacing: WidgetPreviewGroupWidget._gridSpacing, @@ -242,7 +250,7 @@ class WidgetPreviewGroupWidget extends StatelessWidget { alignment: WrapAlignment.start, children: [ for (final WidgetPreview preview in previews) - WidgetPreviewWidget(controller: controller, preview: preview), + WidgetPreviewWidget(controller: widget.controller, preview: preview), ], ); } @@ -253,7 +261,7 @@ class WidgetPreviewGroupWidget extends StatelessWidget { for (final preview in previews) Center( child: WidgetPreviewWidget( - controller: controller, + controller: widget.controller, preview: preview, ), ), @@ -269,7 +277,9 @@ class WidgetPreviewGroupWidget extends StatelessWidget { data: ListTileTheme.of(context).copyWith( dense: true, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(_kCardRadius), + borderRadius: BorderRadius.all( + WidgetPreviewGroupWidget._kCardRadius, + ), ), ), child: Theme( @@ -277,20 +287,31 @@ class WidgetPreviewGroupWidget extends StatelessWidget { // expanded ExpansionTile. data: theme.copyWith(dividerColor: Colors.transparent), child: ExpansionTile( - key: PageStorageKey(group.name), - title: Text(group.name), + key: PageStorageKey(widget.group.name), + title: Text(widget.group.name), initiallyExpanded: true, children: [ - ValueListenableBuilder( - valueListenable: controller.layoutTypeListenable, - builder: (context, selectedLayout, _) { - return switch (selectedLayout) { - LayoutType.gridView => _buildGridViewFlex(group.previews), - LayoutType.listView => _buildVerticalListView( - group.previews, - ), - }; - }, + // Wrap children in a PageStorage to create a storage boundary. + // Without this, descendant scrollables (which search ancestor + // elements for a PageStorageKey when restoring scroll offset) + // inherit the PageStorageKey from the ExpansionTile and attempt + // to read the ExpansionTile's boolean expansion state as a double, + // throwing a TypeError (see https://github.com/flutter/flutter/issues/191242). + PageStorage( + bucket: _bucket, + child: ValueListenableBuilder( + valueListenable: widget.controller.layoutTypeListenable, + builder: (context, selectedLayout, _) { + return switch (selectedLayout) { + LayoutType.gridView => _buildGridViewFlex( + widget.group.previews, + ), + LayoutType.listView => _buildVerticalListView( + widget.group.previews, + ), + }; + }, + ), ), ], ), diff --git a/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview_rendering.dart.tmpl b/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview_rendering.dart.tmpl index b731021768edb..05c7f552c841a 100644 --- a/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview_rendering.dart.tmpl +++ b/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview_rendering.dart.tmpl @@ -217,7 +217,7 @@ class PreviewWidgetElement extends StatelessElement { PreviewWidgetElement(super.widget); } -class WidgetPreviewGroupWidget extends StatelessWidget { +class WidgetPreviewGroupWidget extends StatefulWidget { const WidgetPreviewGroupWidget({ super.key, required this.controller, @@ -235,6 +235,14 @@ class WidgetPreviewGroupWidget extends StatelessWidget { // TODO(bkonyi): inherit this from the theme. static const _kCardRadius = Radius.circular(12); + @override + State createState() => + _WidgetPreviewGroupWidgetState(); +} + +class _WidgetPreviewGroupWidgetState extends State { + final _bucket = PageStorageBucket(); + Widget _buildGridViewFlex(List previews) { return Wrap( spacing: WidgetPreviewGroupWidget._gridSpacing, @@ -242,7 +250,7 @@ class WidgetPreviewGroupWidget extends StatelessWidget { alignment: WrapAlignment.start, children: [ for (final WidgetPreview preview in previews) - WidgetPreviewWidget(controller: controller, preview: preview), + WidgetPreviewWidget(controller: widget.controller, preview: preview), ], ); } @@ -253,7 +261,7 @@ class WidgetPreviewGroupWidget extends StatelessWidget { for (final preview in previews) Center( child: WidgetPreviewWidget( - controller: controller, + controller: widget.controller, preview: preview, ), ), @@ -269,7 +277,9 @@ class WidgetPreviewGroupWidget extends StatelessWidget { data: ListTileTheme.of(context).copyWith( dense: true, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(_kCardRadius), + borderRadius: BorderRadius.all( + WidgetPreviewGroupWidget._kCardRadius, + ), ), ), child: Theme( @@ -277,20 +287,31 @@ class WidgetPreviewGroupWidget extends StatelessWidget { // expanded ExpansionTile. data: theme.copyWith(dividerColor: Colors.transparent), child: ExpansionTile( - key: PageStorageKey(group.name), - title: Text(group.name), + key: PageStorageKey(widget.group.name), + title: Text(widget.group.name), initiallyExpanded: true, children: [ - ValueListenableBuilder( - valueListenable: controller.layoutTypeListenable, - builder: (context, selectedLayout, _) { - return switch (selectedLayout) { - LayoutType.gridView => _buildGridViewFlex(group.previews), - LayoutType.listView => _buildVerticalListView( - group.previews, - ), - }; - }, + // Wrap children in a PageStorage to create a storage boundary. + // Without this, descendant scrollables (which search ancestor + // elements for a PageStorageKey when restoring scroll offset) + // inherit the PageStorageKey from the ExpansionTile and attempt + // to read the ExpansionTile's boolean expansion state as a double, + // throwing a TypeError (see https://github.com/flutter/flutter/issues/191242). + PageStorage( + bucket: _bucket, + child: ValueListenableBuilder( + valueListenable: widget.controller.layoutTypeListenable, + builder: (context, selectedLayout, _) { + return switch (selectedLayout) { + LayoutType.gridView => _buildGridViewFlex( + widget.group.previews, + ), + LayoutType.listView => _buildVerticalListView( + widget.group.previews, + ), + }; + }, + ), ), ], ), From e1c692d73b17fdf7b059901929f56b0c6d42e408 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Fri, 21 Aug 2026 17:11:01 +0000 Subject: [PATCH 05/46] Roll Fuchsia Linux SDK from GCQlmt6h-esJsNubS... to ic6GjOSn-KN508XyK... (#191485) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/fuchsia-linux-sdk-flutter Please CC awolff@google.com,zra@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPS b/DEPS index 4c8bf5839f1cf..2368e2b5177fb 100644 --- a/DEPS +++ b/DEPS @@ -830,7 +830,7 @@ deps = { 'packages': [ { 'package': 'fuchsia/sdk/core/linux-amd64', - 'version': 'GCQlmt6h-esJsNubSmgdOPOVSJoeftcg9q5Bmq8H51AC' + 'version': 'ic6GjOSn-KN508XyKYH2OKCY_ohOptmnefcGEpegTG4C' } ], 'condition': 'download_fuchsia_deps and not download_fuchsia_sdk', From 27f8676912f04490f21542d1d2a1056f5926c683 Mon Sep 17 00:00:00 2001 From: Harry Terkelsen <1961493+harryterkelsen@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:11:36 +0000 Subject: [PATCH 06/46] [web] Move CanvasKit fragment shader classes to canvaskit/fragment_shader.dart (#191451) Moves `CkFragmentProgram`, `CkFragmentShader`, and uniform slot classes from `canvaskit/painting.dart` to `canvaskit/fragment_shader.dart` to decouple fragment shader compilation from paint descriptor logic in preparation for `ui.Paint` unification. ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [AI contribution guidelines] and understand my responsibilities, or I am not using AI tools. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [ ] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. If this change needs to override an active code freeze, provide a comment explaining why. The code freeze workflow can be overridden by code reviewers. See pinned issues for any active code freezes with guidance. **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [AI contribution guidelines]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --- .../flutter/lib/web_ui/lib/src/engine.dart | 1 + .../src/engine/canvaskit/fragment_shader.dart | 534 ++++++++++++++++++ .../lib/src/engine/canvaskit/painting.dart | 528 ----------------- .../test/canvaskit/fragment_program_test.dart | 27 + 4 files changed, 562 insertions(+), 528 deletions(-) create mode 100644 engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/fragment_shader.dart diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine.dart b/engine/src/flutter/lib/web_ui/lib/src/engine.dart index ed7836f7b7559..4848483f37932 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine.dart @@ -30,6 +30,7 @@ export 'engine/canvaskit/canvas.dart'; export 'engine/canvaskit/canvaskit_api.dart'; export 'engine/canvaskit/color_filter.dart'; export 'engine/canvaskit/fonts.dart'; +export 'engine/canvaskit/fragment_shader.dart'; export 'engine/canvaskit/image.dart'; export 'engine/canvaskit/image_filter.dart'; export 'engine/canvaskit/mask_filter.dart'; diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/fragment_shader.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/fragment_shader.dart new file mode 100644 index 0000000000000..d5dbeb94de3d6 --- /dev/null +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/fragment_shader.dart @@ -0,0 +1,534 @@ +// 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:typed_data'; + +import 'package:meta/meta.dart'; +import 'package:ui/src/engine.dart'; +import 'package:ui/ui.dart' as ui; + +class CkFragmentProgram implements ui.FragmentProgram { + CkFragmentProgram(this.name, this.effect, this.uniforms, this.floatCount, this.textureCount); + + factory CkFragmentProgram.fromBytes(String name, Uint8List data) { + final shaderData = ShaderData.fromBytes(data); + final SkRuntimeEffect? effect = MakeRuntimeEffect(shaderData.source); + if (effect == null) { + throw const FormatException('Invalid Shader Source'); + } + + return CkFragmentProgram( + name, + effect, + shaderData.uniforms, + shaderData.floatCount, + shaderData.textureCount, + ); + } + + final String name; + final SkRuntimeEffect effect; + final List uniforms; + final int floatCount; + final int textureCount; + + @override + ui.FragmentShader fragmentShader() { + return EngineFragmentShader(CkFragmentShader(name, effect, this)); + } + + UniformData _getUniformFloatInfo(String name) { + for (final UniformData uniform in uniforms) { + if (uniform.name == name) { + return uniform; + } + } + throw ArgumentError('No uniform named "$name".'); + } +} + +class CkFragmentShader extends BackendFragmentShader implements CkShader { + CkFragmentShader(this.name, this.effect, this._program) + : floats = mallocFloat32List(_program.floatCount + _program.textureCount * 2), + samplers = List.filled(_program.textureCount, null), + lastFloatIndex = _program.floatCount; + + final String name; + final SkRuntimeEffect effect; + final int lastFloatIndex; + final SkFloat32List floats; + final List samplers; + final CkFragmentProgram _program; + + @visibleForTesting + CkUniqueRef? ref; + + @override + bool get isGradient => false; + + @override + SkShader get skShader { + assert(!_debugDisposed, 'FragmentShader has been disposed of.'); + ref?.dispose(); + + final SkShader? result = samplers.isEmpty + ? effect.makeShader(floats) + : effect.makeShaderWithChildren(floats, samplers); + if (result == null) { + throw Exception( + 'Invalid uniform data for shader $name:' + ' floatUniforms: $floats \n' + ' samplerUniforms: $samplers \n', + ); + } + + ref = CkUniqueRef(this, result, 'FragmentShader'); + return result; + } + + @override + void setFloat(int index, double value) { + assert(!_debugDisposed, 'FragmentShader has been disposed of.'); + floats.toTypedArray()[index] = value; + } + + @override + void setImageSampler(int index, BackendImageShader shader, double width, double height) { + assert(!_debugDisposed, 'FragmentShader has been disposed of.'); + samplers[index] = (shader as CkImageShader).skShader; + setFloat(lastFloatIndex + 2 * index, width); + setFloat(lastFloatIndex + 2 * index + 1, height); + } + + @override + void dispose() { + assert(!_debugDisposed, 'Cannot dispose FragmentShader more than once.'); + assert(() { + _debugDisposed = true; + return true; + }()); + ref?.dispose(); + ref = null; + free(floats); + } + + bool _debugDisposed = false; + + bool get debugDisposed => _debugDisposed; + + @override + ui.UniformFloatSlot getUniformFloat(String name, [int? index]) { + index ??= 0; + final UniformData info = _program._getUniformFloatInfo(name); + + IndexError.check(index, info.floatCount, message: 'Index `$index` out of bounds for `$name`.'); + + return CkUniformFloatSlot._(this, index, name, info.floatOffset + index); + } + + @override + ui.UniformVec2Slot getUniformVec2(String name) { + final List slots = _getUniformFloatSlots(name, 2); + return _CkUniformVec2Slot._(slots[0], slots[1]); + } + + @override + ui.UniformVec3Slot getUniformVec3(String name) { + final List slots = _getUniformFloatSlots(name, 3); + return _CkUniformVec3Slot._(slots[0], slots[1], slots[2]); + } + + @override + ui.UniformVec4Slot getUniformVec4(String name) { + final List slots = _getUniformFloatSlots(name, 4); + return _CkUniformVec4Slot._(slots[0], slots[1], slots[2], slots[3]); + } + + ui.UniformArray _getUniformArray( + String name, + int elementSize, + T Function(List slots) elementFactory, + ) { + final UniformData info = _program._getUniformFloatInfo(name); + + if (info.floatCount % elementSize != 0) { + throw ArgumentError( + 'Uniform size (${info.floatCount}) for "$name" is not a multiple of $elementSize.', + ); + } + final int numElements = info.floatCount ~/ elementSize; + + final elements = List.generate(numElements, (i) { + final slots = List.generate(elementSize, (j) { + final int index = i * elementSize + j; + return CkUniformFloatSlot._(this, index, name, info.floatOffset + index); + }); + return elementFactory(slots); + }); + + return _CkUniformFloatArray._(elements); + } + + @override + ui.UniformArray getUniformFloatArray(String name) { + return _getUniformArray(name, 1, (components) => components.first); + } + + @override + ui.UniformArray getUniformVec2Array(String name) { + return _getUniformArray<_CkUniformVec2Slot>( + name, + 2, // 2 floats per element + (components) => _CkUniformVec2Slot._( + components[0], + components[1], + ), // Create Vec2 from two UniformFloat components + ); + } + + @override + ui.UniformArray getUniformVec3Array(String name) { + return _getUniformArray<_CkUniformVec3Slot>( + name, + 3, // 3 floats per element + (components) => + _CkUniformVec3Slot._(components[0], components[1], components[2]), // Create Vec3 + ); + } + + @override + ui.UniformArray getUniformVec4Array(String name) { + return _getUniformArray<_CkUniformVec4Slot>( + name, + 4, // 4 floats per element + (components) => _CkUniformVec4Slot._( + components[0], + components[1], + components[2], + components[3], + ), // Create Vec4 + ); + } + + @override + ui.UniformMat2Slot getUniformMat2(String name) { + final List slots = _getUniformFloatSlots(name, 4); + return _CkUniformMat2Slot._(slots[0], slots[1], slots[2], slots[3]); + } + + @override + ui.UniformMat3Slot getUniformMat3(String name) { + final List slots = _getUniformFloatSlots(name, 9); + return _CkUniformMat3Slot._( + slots[0], + slots[1], + slots[2], + slots[3], + slots[4], + slots[5], + slots[6], + slots[7], + slots[8], + ); + } + + @override + ui.UniformMat4Slot getUniformMat4(String name) { + final List slots = _getUniformFloatSlots(name, 16); + return _CkUniformMat4Slot._( + slots[0], + slots[1], + slots[2], + slots[3], + slots[4], + slots[5], + slots[6], + slots[7], + slots[8], + slots[9], + slots[10], + slots[11], + slots[12], + slots[13], + slots[14], + slots[15], + ); + } + + @override + ui.UniformArray getUniformMat2Array(String name) { + return _getUniformArray<_CkUniformMat2Slot>( + name, + 4, + (components) => + _CkUniformMat2Slot._(components[0], components[1], components[2], components[3]), + ); + } + + @override + ui.UniformArray getUniformMat3Array(String name) { + return _getUniformArray<_CkUniformMat3Slot>( + name, + 9, + (components) => _CkUniformMat3Slot._( + components[0], + components[1], + components[2], + components[3], + components[4], + components[5], + components[6], + components[7], + components[8], + ), + ); + } + + @override + ui.UniformArray getUniformMat4Array(String name) { + return _getUniformArray<_CkUniformMat4Slot>( + name, + 16, + (components) => _CkUniformMat4Slot._( + components[0], + components[1], + components[2], + components[3], + components[4], + components[5], + components[6], + components[7], + components[8], + components[9], + components[10], + components[11], + components[12], + components[13], + components[14], + components[15], + ), + ); + } + + @override + ui.ImageSamplerSlot getImageSampler(String name) { + throw UnsupportedError('getImageSampler is not supported on the web.'); + } + + List _getUniformFloatSlots(String name, int size) { + final UniformData info = _program._getUniformFloatInfo(name); + + if (info.floatCount != size) { + throw ArgumentError('Uniform `$name` has size ${info.floatCount}, not size $size.'); + } + + return List.generate( + size, + (i) => CkUniformFloatSlot._(this, i, name, info.floatOffset + i), + ); + } +} + +class CkUniformFloatSlot implements ui.UniformFloatSlot { + CkUniformFloatSlot._(this._shader, this.index, this.name, this.shaderIndex); + + final CkFragmentShader _shader; + + @override + final int index; + + @override + final String name; + + @override + void set(double val) { + _shader.setFloat(shaderIndex, val); + } + + @override + final int shaderIndex; +} + +class _CkUniformVec2Slot implements ui.UniformVec2Slot { + _CkUniformVec2Slot._(this._xSlot, this._ySlot); + + @override + void set(double x, double y) { + _xSlot.set(x); + _ySlot.set(y); + } + + final CkUniformFloatSlot _xSlot, _ySlot; +} + +class _CkUniformVec3Slot implements ui.UniformVec3Slot { + _CkUniformVec3Slot._(this._xSlot, this._ySlot, this._zSlot); + + @override + void set(double x, double y, double z) { + _xSlot.set(x); + _ySlot.set(y); + _zSlot.set(z); + } + + final CkUniformFloatSlot _xSlot, _ySlot, _zSlot; +} + +class _CkUniformVec4Slot implements ui.UniformVec4Slot { + _CkUniformVec4Slot._(this._xSlot, this._ySlot, this._zSlot, this._wSlot); + + @override + void set(double x, double y, double z, double w) { + _xSlot.set(x); + _ySlot.set(y); + _zSlot.set(z); + _wSlot.set(w); + } + + final CkUniformFloatSlot _xSlot, _ySlot, _zSlot, _wSlot; +} + +class _CkUniformMat2Slot implements ui.UniformMat2Slot { + _CkUniformMat2Slot._(this._m00, this._m10, this._m01, this._m11); + + // Set the elemnts of the matrix in column-major order. + @override + void set(double m00, double m10, double m01, double m11) { + _m00.set(m00); + _m01.set(m01); + _m10.set(m10); + _m11.set(m11); + } + + // The elements of the matrix. Where mij referes to the ith row and the jth column. + final CkUniformFloatSlot _m00, _m10; // Column 0 + final CkUniformFloatSlot _m01, _m11; // Column 1 +} + +class _CkUniformMat3Slot implements ui.UniformMat3Slot { + _CkUniformMat3Slot._( + this._m00, + this._m10, + this._m20, + this._m01, + this._m11, + this._m21, + this._m02, + this._m12, + this._m22, + ); + + // The elements of the matrix. mij refers to the ith row and the jth column. + final CkUniformFloatSlot _m00, _m10, _m20; // Column 0 + final CkUniformFloatSlot _m01, _m11, _m21; // Column 1 + final CkUniformFloatSlot _m02, _m12, _m22; // Column 2 + + /// Set the elements of the matrix in column-major order. + @override + void set( + double m00, + double m10, + double m20, + double m01, + double m11, + double m21, + double m02, + double m12, + double m22, + ) { + _m00.set(m00); + _m10.set(m10); + _m20.set(m20); + + _m01.set(m01); + _m11.set(m11); + _m21.set(m21); + + _m02.set(m02); + _m12.set(m12); + _m22.set(m22); + } +} + +class _CkUniformMat4Slot implements ui.UniformMat4Slot { + _CkUniformMat4Slot._( + this._m00, + this._m10, + this._m20, + this._m30, + this._m01, + this._m11, + this._m21, + this._m31, + this._m02, + this._m12, + this._m22, + this._m32, + this._m03, + this._m13, + this._m23, + this._m33, + ); + + // The elements of the matrix. mij refers to the ith row and the jth column. + final CkUniformFloatSlot _m00, _m10, _m20, _m30; // Column 0 + final CkUniformFloatSlot _m01, _m11, _m21, _m31; // Column 1 + final CkUniformFloatSlot _m02, _m12, _m22, _m32; // Column 2 + final CkUniformFloatSlot _m03, _m13, _m23, _m33; // Column 3 + + /// Set the elements of the matrix in column-major order. + @override + void set( + double m00, + double m10, + double m20, + double m30, + double m01, + double m11, + double m21, + double m31, + double m02, + double m12, + double m22, + double m32, + double m03, + double m13, + double m23, + double m33, + ) { + _m00.set(m00); + _m10.set(m10); + _m20.set(m20); + _m30.set(m30); + + _m01.set(m01); + _m11.set(m11); + _m21.set(m21); + _m31.set(m31); + + _m02.set(m02); + _m12.set(m12); + _m22.set(m22); + _m32.set(m32); + + _m03.set(m03); + _m13.set(m13); + _m23.set(m23); + _m33.set(m33); + } +} + +class _CkUniformFloatArray implements ui.UniformArray { + _CkUniformFloatArray._(this._elements); + + @override + T operator [](int index) { + return _elements[index]; + } + + @override + int get length => _elements.length; + + final List _elements; +} diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/painting.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/painting.dart index 4e5a622485b5c..b840815f2e9de 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/painting.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/painting.dart @@ -2,9 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:typed_data'; - -import 'package:meta/meta.dart'; import 'package:ui/src/engine.dart'; import 'package:ui/ui.dart' as ui; @@ -249,528 +246,3 @@ class CkPaint implements ui.Paint { return resultString; } } - -class CkFragmentProgram implements ui.FragmentProgram { - CkFragmentProgram(this.name, this.effect, this.uniforms, this.floatCount, this.textureCount); - - factory CkFragmentProgram.fromBytes(String name, Uint8List data) { - final shaderData = ShaderData.fromBytes(data); - final SkRuntimeEffect? effect = MakeRuntimeEffect(shaderData.source); - if (effect == null) { - throw const FormatException('Invalid Shader Source'); - } - - return CkFragmentProgram( - name, - effect, - shaderData.uniforms, - shaderData.floatCount, - shaderData.textureCount, - ); - } - - final String name; - final SkRuntimeEffect effect; - final List uniforms; - final int floatCount; - final int textureCount; - - @override - ui.FragmentShader fragmentShader() { - return EngineFragmentShader(CkFragmentShader(name, effect, this)); - } - - UniformData _getUniformFloatInfo(String name) { - for (final UniformData uniform in uniforms) { - if (uniform.name == name) { - return uniform; - } - } - throw ArgumentError('No uniform named "$name".'); - } -} - -class CkFragmentShader extends BackendFragmentShader implements CkShader { - CkFragmentShader(this.name, this.effect, this._program) - : floats = mallocFloat32List(_program.floatCount + _program.textureCount * 2), - samplers = List.filled(_program.textureCount, null), - lastFloatIndex = _program.floatCount; - - final String name; - final SkRuntimeEffect effect; - final int lastFloatIndex; - final SkFloat32List floats; - final List samplers; - final CkFragmentProgram _program; - - @visibleForTesting - CkUniqueRef? ref; - - @override - bool get isGradient => false; - - @override - SkShader get skShader { - assert(!_debugDisposed, 'FragmentShader has been disposed of.'); - ref?.dispose(); - - final SkShader? result = samplers.isEmpty - ? effect.makeShader(floats) - : effect.makeShaderWithChildren(floats, samplers); - if (result == null) { - throw Exception( - 'Invalid uniform data for shader $name:' - ' floatUniforms: $floats \n' - ' samplerUniforms: $samplers \n', - ); - } - - ref = CkUniqueRef(this, result, 'FragmentShader'); - return result; - } - - @override - void setFloat(int index, double value) { - assert(!_debugDisposed, 'FragmentShader has been disposed of.'); - floats.toTypedArray()[index] = value; - } - - @override - void setImageSampler(int index, BackendImageShader shader, double width, double height) { - assert(!_debugDisposed, 'FragmentShader has been disposed of.'); - samplers[index] = (shader as CkImageShader).skShader; - setFloat(lastFloatIndex + 2 * index, width); - setFloat(lastFloatIndex + 2 * index + 1, height); - } - - @override - void dispose() { - assert(!_debugDisposed, 'Cannot dispose FragmentShader more than once.'); - assert(() { - _debugDisposed = true; - return true; - }()); - ref?.dispose(); - ref = null; - free(floats); - } - - bool _debugDisposed = false; - - bool get debugDisposed => _debugDisposed; - - @override - ui.UniformFloatSlot getUniformFloat(String name, [int? index]) { - index ??= 0; - final UniformData info = _program._getUniformFloatInfo(name); - - IndexError.check(index, info.floatCount, message: 'Index `$index` out of bounds for `$name`.'); - - return CkUniformFloatSlot._(this, index, name, info.floatOffset + index); - } - - @override - ui.UniformVec2Slot getUniformVec2(String name) { - final List slots = _getUniformFloatSlots(name, 2); - return _CkUniformVec2Slot._(slots[0], slots[1]); - } - - @override - ui.UniformVec3Slot getUniformVec3(String name) { - final List slots = _getUniformFloatSlots(name, 3); - return _CkUniformVec3Slot._(slots[0], slots[1], slots[2]); - } - - @override - ui.UniformVec4Slot getUniformVec4(String name) { - final List slots = _getUniformFloatSlots(name, 4); - return _CkUniformVec4Slot._(slots[0], slots[1], slots[2], slots[3]); - } - - ui.UniformArray _getUniformArray( - String name, - int elementSize, - T Function(List slots) elementFactory, - ) { - final UniformData info = _program._getUniformFloatInfo(name); - - if (info.floatCount % elementSize != 0) { - throw ArgumentError( - 'Uniform size (${info.floatCount}) for "$name" is not a multiple of $elementSize.', - ); - } - final int numElements = info.floatCount ~/ elementSize; - - final elements = List.generate(numElements, (i) { - final slots = List.generate( - info.floatCount, - (j) => CkUniformFloatSlot._(this, j, name, info.floatOffset + i * elementSize + j), - ); - return elementFactory(slots); - }); - - return _CkUniformFloatArray._(elements); - } - - @override - ui.UniformArray getUniformFloatArray(String name) { - return _getUniformArray(name, 1, (components) => components.first); - } - - @override - ui.UniformArray getUniformVec2Array(String name) { - return _getUniformArray<_CkUniformVec2Slot>( - name, - 2, // 2 floats per element - (components) => _CkUniformVec2Slot._( - components[0], - components[1], - ), // Create Vec2 from two UniformFloat components - ); - } - - @override - ui.UniformArray getUniformVec3Array(String name) { - return _getUniformArray<_CkUniformVec3Slot>( - name, - 3, // 3 floats per element - (components) => - _CkUniformVec3Slot._(components[0], components[1], components[2]), // Create Vec3 - ); - } - - @override - ui.UniformArray getUniformVec4Array(String name) { - return _getUniformArray<_CkUniformVec4Slot>( - name, - 4, // 4 floats per element - (components) => _CkUniformVec4Slot._( - components[0], - components[1], - components[2], - components[3], - ), // Create Vec4 - ); - } - - @override - ui.UniformMat2Slot getUniformMat2(String name) { - final List slots = _getUniformFloatSlots(name, 4); - return _CkUniformMat2Slot._(slots[0], slots[1], slots[2], slots[3]); - } - - @override - ui.UniformMat3Slot getUniformMat3(String name) { - final List slots = _getUniformFloatSlots(name, 9); - return _CkUniformMat3Slot._( - slots[0], - slots[1], - slots[2], - slots[3], - slots[4], - slots[5], - slots[6], - slots[7], - slots[8], - ); - } - - @override - ui.UniformMat4Slot getUniformMat4(String name) { - final List slots = _getUniformFloatSlots(name, 16); - return _CkUniformMat4Slot._( - slots[0], - slots[1], - slots[2], - slots[3], - slots[4], - slots[5], - slots[6], - slots[7], - slots[8], - slots[9], - slots[10], - slots[11], - slots[12], - slots[13], - slots[14], - slots[15], - ); - } - - @override - ui.UniformArray getUniformMat2Array(String name) { - return _getUniformArray<_CkUniformMat2Slot>( - name, - 4, - (components) => - _CkUniformMat2Slot._(components[0], components[1], components[2], components[3]), - ); - } - - @override - ui.UniformArray getUniformMat3Array(String name) { - return _getUniformArray<_CkUniformMat3Slot>( - name, - 9, - (components) => _CkUniformMat3Slot._( - components[0], - components[1], - components[2], - components[3], - components[4], - components[5], - components[6], - components[7], - components[8], - ), - ); - } - - @override - ui.UniformArray getUniformMat4Array(String name) { - return _getUniformArray<_CkUniformMat4Slot>( - name, - 16, - (components) => _CkUniformMat4Slot._( - components[0], - components[1], - components[2], - components[3], - components[4], - components[5], - components[6], - components[7], - components[8], - components[9], - components[10], - components[11], - components[12], - components[13], - components[14], - components[15], - ), - ); - } - - @override - ui.ImageSamplerSlot getImageSampler(String name) { - throw UnsupportedError('getImageSampler is not supported on the web.'); - } - - List _getUniformFloatSlots(String name, int size) { - final UniformData info = _program._getUniformFloatInfo(name); - - if (info.floatCount != size) { - throw ArgumentError('Uniform `$name` has size ${info.floatCount}, not size $size.'); - } - - return List.generate( - size, - (i) => CkUniformFloatSlot._(this, i, name, info.floatOffset + i), - ); - } -} - -class CkUniformFloatSlot implements ui.UniformFloatSlot { - CkUniformFloatSlot._(this._shader, this.index, this.name, this.shaderIndex); - - final CkFragmentShader _shader; - - @override - final int index; - - @override - final String name; - - @override - void set(double val) { - _shader.setFloat(shaderIndex, val); - } - - @override - final int shaderIndex; -} - -class _CkUniformVec2Slot implements ui.UniformVec2Slot { - _CkUniformVec2Slot._(this._xSlot, this._ySlot); - - @override - void set(double x, double y) { - _xSlot.set(x); - _ySlot.set(y); - } - - final CkUniformFloatSlot _xSlot, _ySlot; -} - -class _CkUniformVec3Slot implements ui.UniformVec3Slot { - _CkUniformVec3Slot._(this._xSlot, this._ySlot, this._zSlot); - - @override - void set(double x, double y, double z) { - _xSlot.set(x); - _ySlot.set(y); - _zSlot.set(z); - } - - final CkUniformFloatSlot _xSlot, _ySlot, _zSlot; -} - -class _CkUniformVec4Slot implements ui.UniformVec4Slot { - _CkUniformVec4Slot._(this._xSlot, this._ySlot, this._zSlot, this._wSlot); - - @override - void set(double x, double y, double z, double w) { - _xSlot.set(x); - _ySlot.set(y); - _zSlot.set(z); - _wSlot.set(w); - } - - final CkUniformFloatSlot _xSlot, _ySlot, _zSlot, _wSlot; -} - -class _CkUniformMat2Slot implements ui.UniformMat2Slot { - _CkUniformMat2Slot._(this._m00, this._m10, this._m01, this._m11); - - // Set the elemnts of the matrix in column-major order. - @override - void set(double m00, double m10, double m01, double m11) { - _m00.set(m00); - _m01.set(m01); - _m10.set(m10); - _m11.set(m11); - } - - // The elements of the matrix. Where mij referes to the ith row and the jth column. - final CkUniformFloatSlot _m00, _m10; // Column 0 - final CkUniformFloatSlot _m01, _m11; // Column 1 -} - -class _CkUniformMat3Slot implements ui.UniformMat3Slot { - _CkUniformMat3Slot._( - this._m00, - this._m10, - this._m20, - this._m01, - this._m11, - this._m21, - this._m02, - this._m12, - this._m22, - ); - - // The elements of the matrix. mij refers to the ith row and the jth column. - final CkUniformFloatSlot _m00, _m10, _m20; // Column 0 - final CkUniformFloatSlot _m01, _m11, _m21; // Column 1 - final CkUniformFloatSlot _m02, _m12, _m22; // Column 2 - - /// Set the elements of the matrix in column-major order. - @override - void set( - double m00, - double m10, - double m20, - double m01, - double m11, - double m21, - double m02, - double m12, - double m22, - ) { - _m00.set(m00); - _m10.set(m10); - _m20.set(m20); - - _m01.set(m01); - _m11.set(m11); - _m21.set(m21); - - _m02.set(m02); - _m12.set(m12); - _m22.set(m22); - } -} - -class _CkUniformMat4Slot implements ui.UniformMat4Slot { - _CkUniformMat4Slot._( - this._m00, - this._m10, - this._m20, - this._m30, - this._m01, - this._m11, - this._m21, - this._m31, - this._m02, - this._m12, - this._m22, - this._m32, - this._m03, - this._m13, - this._m23, - this._m33, - ); - - // The elements of the matrix. mij refers to the ith row and the jth column. - final CkUniformFloatSlot _m00, _m10, _m20, _m30; // Column 0 - final CkUniformFloatSlot _m01, _m11, _m21, _m31; // Column 1 - final CkUniformFloatSlot _m02, _m12, _m22, _m32; // Column 2 - final CkUniformFloatSlot _m03, _m13, _m23, _m33; // Column 3 - - /// Set the elements of the matrix in column-major order. - @override - void set( - double m00, - double m10, - double m20, - double m30, - double m01, - double m11, - double m21, - double m31, - double m02, - double m12, - double m22, - double m32, - double m03, - double m13, - double m23, - double m33, - ) { - _m00.set(m00); - _m10.set(m10); - _m20.set(m20); - _m30.set(m30); - - _m01.set(m01); - _m11.set(m11); - _m21.set(m21); - _m31.set(m31); - - _m02.set(m02); - _m12.set(m12); - _m22.set(m22); - _m32.set(m32); - - _m03.set(m03); - _m13.set(m13); - _m23.set(m23); - _m33.set(m33); - } -} - -class _CkUniformFloatArray implements ui.UniformArray { - _CkUniformFloatArray._(this._elements); - - @override - T operator [](int index) { - return _elements[index]; - } - - @override - int get length => _elements.length; - - final List _elements; -} diff --git a/engine/src/flutter/lib/web_ui/test/canvaskit/fragment_program_test.dart b/engine/src/flutter/lib/web_ui/test/canvaskit/fragment_program_test.dart index 891425588bec9..2be2e49928f15 100644 --- a/engine/src/flutter/lib/web_ui/test/canvaskit/fragment_program_test.dart +++ b/engine/src/flutter/lib/web_ui/test/canvaskit/fragment_program_test.dart @@ -347,5 +347,32 @@ void testMain() { expect(program.textureCount, 0); expect(program.uniforms, hasLength(7)); expect(program.name, 'test'); + + final shader = + (program.fragmentShader() as EngineFragmentShader).getBackendShader(ui.FilterQuality.none) + as CkFragmentShader; + + final ui.UniformArray floatArray = shader.getUniformFloatArray('uFloats'); + expect(floatArray.length, 10); + for (var i = 0; i < floatArray.length; i++) { + expect(floatArray[i].name, 'uFloats'); + expect(floatArray[i].index, i); + expect(floatArray[i].shaderIndex, 2 + i); + } + + final ui.UniformArray vec2Array = shader.getUniformVec2Array('uVectors'); + expect(vec2Array.length, 3); + vec2Array[0].set(1.0, 2.0); + expect(shader.floats.toTypedArray()[13], 1.0); + expect(shader.floats.toTypedArray()[14], 2.0); + vec2Array[1].set(3.0, 4.0); + expect(shader.floats.toTypedArray()[15], 3.0); + expect(shader.floats.toTypedArray()[16], 4.0); + vec2Array[2].set(5.0, 6.0); + expect(shader.floats.toTypedArray()[17], 5.0); + expect(shader.floats.toTypedArray()[18], 6.0); + + final ui.UniformArray mat4Array = shader.getUniformMat4Array('uMatrices'); + expect(mat4Array.length, 2); }); } From 4bb854b004535853c514bb73a266a02af934da07 Mon Sep 17 00:00:00 2001 From: Ben Konyi Date: Fri, 21 Aug 2026 18:15:00 +0000 Subject: [PATCH 07/46] tools: Extract Dart SDK to temp directory before moving to final location (#191263) Extract the downloaded Dart SDK zip/tar into a temporary staging directory first, and only move/rename it to `bin/cache/dart-sdk` once extraction succeeds. This prevents the cache directory from being left in a partially populated or corrupted state if download or extraction is interrupted. Fixes https://github.com/flutter/flutter/issues/15552 ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [AI contribution guidelines] and understand my responsibilities, or I am not using AI tools. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [AI contribution guidelines]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --- bin/internal/update_dart_sdk.ps1 | 61 +++++++++++++++++++++++--------- bin/internal/update_dart_sdk.sh | 47 ++++++++++++++++++------ 2 files changed, 80 insertions(+), 28 deletions(-) diff --git a/bin/internal/update_dart_sdk.ps1 b/bin/internal/update_dart_sdk.ps1 index 111a34e92210a..89b32a2472706 100644 --- a/bin/internal/update_dart_sdk.ps1 +++ b/bin/internal/update_dart_sdk.ps1 @@ -70,20 +70,11 @@ if ($env:FLUTTER_HOST_ARCH -eq "arm64") { } $dartSdkUrl = "$dartSdkBaseUrl/flutter_infra_release/flutter/$engineVersion/$dartZipName" -if ((Test-Path $dartSdkPath) -or (Test-Path $dartSdkLicense)) { - # Move old SDK to a new location instead of deleting it in case it is still in use (e.g. by IntelliJ). - $oldDartSdkSuffix = 1 - while (Test-Path "$cachePath\$oldDartSdkPrefix$oldDartSdkSuffix") { $oldDartSdkSuffix++ } - - if (Test-Path $dartSdkPath) { - Rename-Item $dartSdkPath "$oldDartSdkPrefix$oldDartSdkSuffix" - } - - if (Test-Path $dartSdkLicense) { - Rename-Item $dartSdkLicense "$oldDartSdkPrefix$oldDartSdkSuffix.LICENSE.md" - } +$dartSdkPathTemp = "$cachePath\dart-sdk.tmp" +if (Test-Path $dartSdkPathTemp) { + Remove-Item $dartSdkPathTemp -Recurse -Force } -New-Item $dartSdkPath -force -type directory | Out-Null +New-Item $dartSdkPathTemp -force -type directory | Out-Null $dartSdkZip = "$cachePath\$dartZipName" Try { @@ -106,27 +97,63 @@ Catch { If (Get-Command 7z -errorAction SilentlyContinue) { Write-Host "Expanding downloaded archive with 7z..." # The built-in unzippers are painfully slow. Use 7-Zip, if available. - & 7z x $dartSdkZip "-o$cachePath" -bd | Out-Null + & 7z x $dartSdkZip "-o$dartSdkPathTemp" -bd | Out-Null } ElseIf (Get-Command 7za -errorAction SilentlyContinue) { Write-Host "Expanding downloaded archive with 7za..." # Use 7-Zip's standalone version 7za.exe, if available. - & 7za x $dartSdkZip "-o$cachePath" -bd | Out-Null + & 7za x $dartSdkZip "-o$dartSdkPathTemp" -bd | Out-Null } ElseIf (Get-Command Microsoft.PowerShell.Archive\Expand-Archive -errorAction SilentlyContinue) { Write-Host "Expanding downloaded archive with PowerShell..." # Use PowerShell's built-in unzipper, if available (requires PowerShell 5+). $global:ProgressPreference='SilentlyContinue' - Microsoft.PowerShell.Archive\Expand-Archive $dartSdkZip -DestinationPath $cachePath + Microsoft.PowerShell.Archive\Expand-Archive $dartSdkZip -DestinationPath $dartSdkPathTemp } Else { Write-Host "Expanding downloaded archive with Windows..." # As last resort: fall back to the Windows GUI. $shell = New-Object -com shell.application $zip = $shell.NameSpace($dartSdkZip) foreach($item in $zip.items()) { - $shell.Namespace($cachePath).copyhere($item) + $shell.Namespace($dartSdkPathTemp).copyhere($item) } } Remove-Item $dartSdkZip + +if (-not (Test-Path "$dartSdkPathTemp\dart-sdk")) { + Remove-Item $dartSdkPathTemp -Recurse -Force -ErrorAction SilentlyContinue + Write-Error "Dart SDK extraction failed: '$dartSdkPathTemp\dart-sdk' not found." + exit 1 +} + +# Move old SDK to a new location instead of deleting it in case it is still in use (e.g. by IntelliJ). +if ((Test-Path $dartSdkPath) -or (Test-Path $dartSdkLicense)) { + $oldDartSdkSuffix = 1 + while (Test-Path "$cachePath\$oldDartSdkPrefix$oldDartSdkSuffix") { $oldDartSdkSuffix++ } + + if (Test-Path $dartSdkPath) { + Rename-Item $dartSdkPath "$oldDartSdkPrefix$oldDartSdkSuffix" -ErrorAction Stop + } + + if (Test-Path $dartSdkLicense) { + Rename-Item $dartSdkLicense "$oldDartSdkPrefix$oldDartSdkSuffix.LICENSE.md" -ErrorAction Stop + } +} + +# The unzip might have extracted LICENSE.dart_sdk_archive.md to the temp dir +$tempLicense = "$dartSdkPathTemp\LICENSE.dart_sdk_archive.md" +if (Test-Path $tempLicense) { + if (Test-Path $dartSdkLicense) { + Remove-Item $dartSdkLicense -Force -ErrorAction Stop + } + Move-Item $tempLicense $dartSdkLicense -ErrorAction Stop +} + +# Move the extracted SDK to the final location +try { + Move-Item "$dartSdkPathTemp\dart-sdk" $dartSdkPath -ErrorAction Stop +} finally { + Remove-Item $dartSdkPathTemp -Recurse -Force -ErrorAction SilentlyContinue +} $engineVersion | Out-File $engineStamp -Encoding ASCII # Try to delete all old SDKs and license files. diff --git a/bin/internal/update_dart_sdk.sh b/bin/internal/update_dart_sdk.sh index cf306f0c1cd11..080a41c900585 100755 --- a/bin/internal/update_dart_sdk.sh +++ b/bin/internal/update_dart_sdk.sh @@ -133,15 +133,11 @@ if [ ! -f "$ENGINE_STAMP" ] || [ "$ENGINE_VERSION" != "$(< "$ENGINE_STAMP")" ]; DART_SDK_BASE_URL="${FLUTTER_STORAGE_BASE_URL:-https://storage.googleapis.com}${ENGINE_REALM:+/$ENGINE_REALM}" DART_SDK_URL="$DART_SDK_BASE_URL/flutter_infra_release/flutter/$ENGINE_VERSION/$DART_ZIP_NAME" - # if the sdk path exists, copy it to a temporary location - if [ -d "$DART_SDK_PATH" ]; then - rm -rf "$DART_SDK_PATH_OLD" - mv "$DART_SDK_PATH" "$DART_SDK_PATH_OLD" - fi + # Create a temporary directory for extraction to ensure atomicity + DART_SDK_PATH_TEMP="$FLUTTER_ROOT/bin/cache/dart-sdk.tmp" + rm -rf -- "$DART_SDK_PATH_TEMP" + mkdir -m 755 -p -- "$DART_SDK_PATH_TEMP" - # install the new sdk - rm -rf -- "$DART_SDK_PATH" - mkdir -m 755 -p -- "$DART_SDK_PATH" DART_SDK_ZIP="$FLUTTER_ROOT/bin/cache/$DART_ZIP_NAME" # Conditionally set verbose flag for LUCI @@ -172,20 +168,49 @@ if [ ! -f "$ENGINE_STAMP" ] || [ "$ENGINE_VERSION" != "$(< "$ENGINE_STAMP")" ]; >&2 echo " https://flutter.dev/community/china" >&2 echo rm -f -- "$DART_SDK_ZIP" + rm -rf -- "$DART_SDK_PATH_TEMP" exit 1 } - unzip -o -q "$DART_SDK_ZIP" -d "$FLUTTER_ROOT/bin/cache" || { + unzip -o -q "$DART_SDK_ZIP" -d "$DART_SDK_PATH_TEMP" || { >&2 echo >&2 echo "It appears that the downloaded file is corrupt; please try again." >&2 echo "If this problem persists, please report the problem at:" >&2 echo " https://github.com/flutter/flutter/issues/new?template=01_activation.yml" >&2 echo rm -f -- "$DART_SDK_ZIP" + rm -rf -- "$DART_SDK_PATH_TEMP" exit 1 } rm -f -- "$DART_SDK_ZIP" - $FIND "$DART_SDK_PATH" -type d -exec chmod 755 {} + - $FIND "$DART_SDK_PATH" -type f $IS_USER_EXECUTABLE -exec chmod a+x,a+r {} + + + if [ ! -d "$DART_SDK_PATH_TEMP/dart-sdk" ]; then + >&2 echo "Dart SDK extraction failed: '$DART_SDK_PATH_TEMP/dart-sdk' not found." + rm -rf -- "$DART_SDK_PATH_TEMP" + exit 1 + fi + + # The unzip might have extracted LICENSE.dart_sdk_archive.md to the temp dir + if [ -f "$DART_SDK_PATH_TEMP/LICENSE.dart_sdk_archive.md" ]; then + mv "$DART_SDK_PATH_TEMP/LICENSE.dart_sdk_archive.md" "$FLUTTER_ROOT/bin/cache/LICENSE.dart_sdk_archive.md" + fi + + $FIND "$DART_SDK_PATH_TEMP/dart-sdk" -type d -exec chmod 755 {} + + $FIND "$DART_SDK_PATH_TEMP/dart-sdk" -type f $IS_USER_EXECUTABLE -exec chmod a+x,a+r {} + + + # Move old SDK to a temporary location in case it is still in use (e.g. by an IDE). + if [ -d "$DART_SDK_PATH" ]; then + rm -rf "$DART_SDK_PATH_OLD" + mv "$DART_SDK_PATH" "$DART_SDK_PATH_OLD" + fi + + # Move the extracted SDK to the final location + mv "$DART_SDK_PATH_TEMP/dart-sdk" "$DART_SDK_PATH" || { + >&2 echo "Failed to move Dart SDK to final destination." + rm -rf -- "$DART_SDK_PATH_TEMP" + exit 1 + } + rm -rf -- "$DART_SDK_PATH_TEMP" + echo "$ENGINE_VERSION" > "$ENGINE_STAMP" # delete any temporary sdk path From 3479680076e5a996a5d76c3415c4b7854d28a0a3 Mon Sep 17 00:00:00 2001 From: Kevin Moore Date: Fri, 21 Aug 2026 18:58:10 +0000 Subject: [PATCH 08/46] [flutter_tools] refactor CLI argument architecture with typed option descriptors and bundles (PoC) (#191018) Exploring a type-safe, declarative argument parsing foundation for `flutter_tools`, validated end-to-end against `flutter build web`. Today, `flutter_tools` subcommands rely heavily on imperative option registration methods scattered across `FlutterCommand` (~48 `uses*` / `add*` methods) and loose string-based argument extraction (`stringArg('foo')`, `boolArg('bar')`), which leads to high boilerplate, runtime string typos, and difficulty refactoring CLI flags. ### Key Changes 1. **Typed Option Descriptors (`lib/src/runner/options/option_descriptor.dart`)**: - Introduces metadata-rich `OptionDescriptor`, `StringOptionDescriptor`, `FlagOptionDescriptor`, `NullableFlagOptionDescriptor`, and `MultiOptionDescriptor`. - Option definitions are strongly typed `const` declarations owning their name, abbreviations, defaults, help text, allowed values, and `verboseOnly` dynamic visibility. 2. **Domain Option Bundles (`lib/src/runner/options/option_bundle.dart`)**: - `OptionBundle` allows cohesive grouping of related options into reusable compile-time `const` units with automatic section header separators in `--help` output (via `title` and `subBundles`). - Automatically auto-wires `command.verboseHelp` down to all child descriptors during registration. - Introduces foundational bundles in `common_options.dart` (`CommonBuildOptionsBundle`, `BuildModeOptionsBundle`, `DartCompileOptionsBundle`). 3. **Type-Safe Argument Access (`lib/src/runner/options/safe_arg_results.dart`)**: - Adds a single generic `T getValue(OptionDescriptor descriptor)` on `FlutterCommand` that leverages Dart type inference to return non-nullable `bool`, `bool?`, `List`, or `String?` without type casting or null-coalescing noise. - Adds `wasProvided()` (aliased `wasParsed()`) for explicit presence queries. 4. **Vertical Slice Validation on `BuildWebCommand`**: - Replaced ~160 lines of imperative constructor setup in `BuildWebCommand` with 4 declarative `const` bundles. - Modularized 18 web options into `WebCoreOptionsBundle`, `WebJsOptionsBundle`, and `WebWasmOptionsBundle` in `lib/src/web/web_options.dart`. ### Verification - Whole-package `dart analyze` across `packages/flutter_tools`: **0 issues**. - Unit and hermetic tests (`option_descriptor_test.dart`, `build_web_test.dart`, `compile_web_test.dart`): **51/51 tests passing**. --- .../flutter_tools/lib/src/commands/build.dart | 2 +- .../lib/src/commands/build_web.dart | 193 ++-------- .../lib/src/runner/flutter_command.dart | 37 ++ .../src/runner/options/common_options.dart | 185 ++++++++++ .../lib/src/runner/options/option_bundle.dart | 45 +++ .../src/runner/options/option_descriptor.dart | 340 ++++++++++++++++++ .../src/runner/options/safe_arg_results.dart | 20 ++ .../lib/src/web/web_options.dart | 225 ++++++++++++ .../hermetic/build_web_test.dart | 63 ++++ .../options/option_descriptor_test.dart | 332 +++++++++++++++++ .../general.shard/web/compile_web_test.dart | 6 +- 11 files changed, 1286 insertions(+), 162 deletions(-) create mode 100644 packages/flutter_tools/lib/src/runner/options/common_options.dart create mode 100644 packages/flutter_tools/lib/src/runner/options/option_bundle.dart create mode 100644 packages/flutter_tools/lib/src/runner/options/option_descriptor.dart create mode 100644 packages/flutter_tools/lib/src/runner/options/safe_arg_results.dart create mode 100644 packages/flutter_tools/lib/src/web/web_options.dart create mode 100644 packages/flutter_tools/test/general.shard/runner/options/option_descriptor_test.dart diff --git a/packages/flutter_tools/lib/src/commands/build.dart b/packages/flutter_tools/lib/src/commands/build.dart index 08977c9a70b4c..4a23a09185917 100644 --- a/packages/flutter_tools/lib/src/commands/build.dart +++ b/packages/flutter_tools/lib/src/commands/build.dart @@ -174,7 +174,7 @@ class BuildCommand extends FlutterCommand { } abstract class BuildSubCommand extends FlutterCommand { - BuildSubCommand({required this.logger, required bool verboseHelp}) { + BuildSubCommand({required this.logger, required super.verboseHelp}) { requiresPubspecYaml(); usesFatalWarningsOption(verboseHelp: verboseHelp); } diff --git a/packages/flutter_tools/lib/src/commands/build_web.dart b/packages/flutter_tools/lib/src/commands/build_web.dart index 31231def25d20..7e6639924af86 100644 --- a/packages/flutter_tools/lib/src/commands/build_web.dart +++ b/packages/flutter_tools/lib/src/commands/build_web.dart @@ -4,15 +4,14 @@ import '../base/common.dart'; import '../base/file_system.dart'; -import '../base/utils.dart'; import '../build_info.dart'; import '../features.dart'; import '../globals.dart' as globals; -import '../runner/flutter_command.dart' - show DevelopmentArtifact, FlutterCommandResult, FlutterOptions; +import '../runner/flutter_command.dart'; import '../web/compile.dart'; import '../web/file_generators/flutter_service_worker_js.dart'; import '../web/web_constants.dart'; +import '../web/web_options.dart'; import '../web_template.dart'; import 'build.dart'; @@ -20,136 +19,14 @@ class BuildWebCommand extends BuildSubCommand { BuildWebCommand({ required super.logger, required FileSystem fileSystem, - required bool verboseHelp, - }) : _fileSystem = fileSystem, - super(verboseHelp: verboseHelp) { - addTreeShakeIconsFlag(); - usesTargetOption(); - usesOutputDir(); - usesPubOption(); - usesBuildNumberOption(); - usesBuildNameOption(); - addBuildModeFlags(verboseHelp: verboseHelp); - usesDartDefineOption(); - usesWebDefineOption(); - addEnableExperimentation(hide: !verboseHelp); - addNativeNullAssertions(); - - // - // Flutter web-specific options - // - argParser.addSeparator('Flutter web options'); - usesBaseHrefOption(); - argParser.addOption( - 'static-assets-url', - help: - 'Used when serving the static assets from a different domain the application is hosted on. ' - 'The value has to end with a slash "/". ' - 'When this is set, it will replace all $kStaticAssetsUrlPlaceholder in web/index.html for the given value.', - ); - argParser.addOption( - 'pwa-strategy', - hide: true, - help: - 'This option is deprecated and will be removed in a future Flutter release.\n' - 'The caching strategy to be used by the PWA service worker.', - allowed: ServiceWorkerStrategy.values.map((ServiceWorkerStrategy e) => e.cliName), - allowedHelp: CliEnum.allowedHelp(ServiceWorkerStrategy.values), - ); - usesWebResourcesCdnFlag(); - - // - // Common compilation options among JavaScript and Wasm - // - argParser.addOption( - 'optimization-level', - abbr: 'O', - help: 'Sets the optimization level used for Dart compilation to JavaScript/Wasm.', - allowed: const ['0', '1', '2', '3', '4'], - ); - argParser.addFlag( - 'source-maps', - help: - 'Generate a sourcemap file. These can be used by browsers ' - 'to view and debug the original source code of a compiled and minified Dart ' - 'application.', - ); - - // - // JavaScript compilation options - // - argParser.addSeparator('JavaScript compilation options'); - argParser.addFlag( - 'csp', - negatable: false, - help: - 'Disable dynamic generation of code in the generated output. ' - 'This is necessary to satisfy CSP restrictions (see http://www.w3.org/TR/CSP/).', - ); - argParser.addOption( - 'dart2js-optimization', - help: - 'Sets the optimization level used for Dart compilation to JavaScript. ' - 'Deprecated: Please use "-O=" / "--optimization-level=".', - allowed: const ['O1', 'O2', 'O3', 'O4'], - ); - argParser.addFlag( - 'dump-info', - negatable: false, - help: - 'Passes "--dump-info" to the Javascript compiler which generates ' - 'information about the generated code in main.dart.js.info.json.', - hide: !verboseHelp, - ); - argParser.addFlag( - 'minify-js', - help: - 'Generate minified output for js. ' - 'If not explicitly set, uses the compilation mode (debug, profile, release).', - hide: !verboseHelp, - ); - argParser.addFlag( - 'minify-wasm', - help: - 'Generate minified output for wasm. ' - 'If not explicitly set, uses the compilation mode (debug, profile, release).', - hide: !verboseHelp, - ); - argParser.addFlag( - 'enable-wasm-deferred-loading', - help: 'Enable multi-module deferred loading for Wasm.', - hide: !verboseHelp, - ); - argParser.addFlag( - 'wasm-dry-run', - defaultsTo: true, - help: - 'Compiles wasm in dry run mode during JS only compilations. ' - 'Disable to suppress warnings.', - ); - argParser.addFlag( - 'no-frequency-based-minification', - negatable: false, - help: - 'Disables the frequency based minifier. ' - 'Useful for comparing the output between builds.', - hide: !verboseHelp, - ); - - // - // WebAssembly compilation options - // - argParser.addSeparator('WebAssembly compilation options'); - argParser.addFlag( - FlutterOptions.kWebWasmFlag, - help: 'Compile to WebAssembly (with fallback to JavaScript).\n$kWasmMoreInfo', - negatable: false, - ); - argParser.addFlag( - 'strip-wasm', - help: 'Whether to strip the resulting wasm file of static symbol names.', - defaultsTo: true, - ); + required super.verboseHelp, + }) : _fileSystem = fileSystem { + registerOptionBundles(const [ + CommonBuildOptionsBundle(), + BuildModeOptionsBundle(), + DartCompileOptionsBundle(), + WebOptionsBundle(), + ]); } final FileSystem _fileSystem; @@ -176,12 +53,12 @@ class BuildWebCommand extends BuildSubCommand { ); } - final String? optimizationLevelArg = stringArg('optimization-level'); + final String? optimizationLevelArg = getValue(WebOptions.optimizationLevel); final int? optimizationLevel = optimizationLevelArg != null ? int.parse(optimizationLevelArg) : null; - final String? dart2jsOptimizationLevelValue = stringArg('dart2js-optimization'); + final String? dart2jsOptimizationLevelValue = getValue(WebOptions.dart2jsOptimization); final int? jsOptimizationLevel = dart2jsOptimizationLevelValue != null ? int.parse(dart2jsOptimizationLevelValue.substring(1)) : optimizationLevel; @@ -189,13 +66,13 @@ class BuildWebCommand extends BuildSubCommand { final List dartDefines = extractDartDefines( defineConfigJsonMap: extractDartDefineConfigJsonMap(), ); - final bool useWasm = boolArg(FlutterOptions.kWebWasmFlag); + final bool useWasm = getValue(WebOptions.wasm); // See also: RunCommandBase.webRenderer and TestCommand.webRenderer. final webRenderer = WebRendererMode.fromDartDefines(dartDefines, useWasm: useWasm); - final bool sourceMaps = boolArg('source-maps'); - final bool? minifyJs = argResults!.wasParsed('minify-js') ? boolArg('minify-js') : null; - final bool? minifyWasm = argResults!.wasParsed('minify-wasm') ? boolArg('minify-wasm') : null; + final bool sourceMaps = getValue(WebOptions.sourceMaps); + final bool? minifyJs = getValue(WebOptions.minifyJs); + final bool? minifyWasm = getValue(WebOptions.minifyWasm); final List compilerConfigs; @@ -212,17 +89,17 @@ class BuildWebCommand extends BuildSubCommand { compilerConfigs = [ WasmCompilerConfig( optimizationLevel: optimizationLevel, - stripWasm: boolArg('strip-wasm'), + stripWasm: getValue(WebOptions.stripWasm), sourceMaps: sourceMaps, minify: minifyWasm, - enableWasmDeferredLoading: boolArg('enable-wasm-deferred-loading'), + enableWasmDeferredLoading: getValue(WebOptions.enableWasmDeferredLoading), ), JsCompilerConfig( - csp: boolArg('csp'), - dumpInfo: boolArg('dump-info'), + csp: getValue(WebOptions.csp), + dumpInfo: getValue(WebOptions.dumpInfo), minify: minifyJs, - nativeNullAssertions: boolArg('native-null-assertions'), - useFrequencyBasedMinification: !boolArg('no-frequency-based-minification'), + nativeNullAssertions: getValue(CommonOptions.nativeNullAssertions), + useFrequencyBasedMinification: !getValue(WebOptions.noFrequencyBasedMinification), optimizationLevel: jsOptimizationLevel, sourceMaps: sourceMaps, ), @@ -230,30 +107,31 @@ class BuildWebCommand extends BuildSubCommand { } else { compilerConfigs = [ JsCompilerConfig( - csp: boolArg('csp'), - dumpInfo: boolArg('dump-info'), + csp: getValue(WebOptions.csp), + dumpInfo: getValue(WebOptions.dumpInfo), minify: minifyJs, - nativeNullAssertions: boolArg('native-null-assertions'), - useFrequencyBasedMinification: !boolArg('no-frequency-based-minification'), + nativeNullAssertions: getValue(CommonOptions.nativeNullAssertions), + useFrequencyBasedMinification: !getValue(WebOptions.noFrequencyBasedMinification), optimizationLevel: jsOptimizationLevel, sourceMaps: sourceMaps, renderer: webRenderer, ), - if (boolArg('wasm-dry-run')) + + if (getValue(WebOptions.wasmDryRun)) WasmCompilerConfig( optimizationLevel: optimizationLevel, - stripWasm: boolArg('strip-wasm'), + stripWasm: getValue(WebOptions.stripWasm), sourceMaps: sourceMaps, minify: minifyWasm, - enableWasmDeferredLoading: boolArg('enable-wasm-deferred-loading'), + enableWasmDeferredLoading: getValue(WebOptions.enableWasmDeferredLoading), dryRun: true, ), ]; } final BuildInfo buildInfo = await getBuildInfo(); - final String? baseHref = stringArg('base-href'); - final String? staticAssetsUrl = stringArg('static-assets-url'); + final String? baseHref = getValue(WebOptions.baseHref); + final String? staticAssetsUrl = getValue(WebOptions.staticAssetsUrl); if (baseHref != null && !(baseHref.startsWith('/') && baseHref.endsWith('/'))) { throwToolExit( 'Received a --base-href value of "$baseHref"\n' @@ -284,11 +162,10 @@ class BuildWebCommand extends BuildSubCommand { ); } - // Currently supporting options [output-dir] and [output] as - // valid approaches for setting output directory of build artifacts - final String? outputDirectoryPath = stringArg('output'); + final String? outputDirectoryPath = getValue(CommonOptions.outputDir); final Map webDefines = extractWebDefines(); + final webBuilder = WebBuilder( logger: globals.logger, processManager: globals.processManager, @@ -301,7 +178,7 @@ class BuildWebCommand extends BuildSubCommand { project, targetFile, buildInfo, - ServiceWorkerStrategy.fromCliName(stringArg('pwa-strategy')), + ServiceWorkerStrategy.fromCliName(getValue(WebOptions.pwaStrategy)), compilerConfigs: compilerConfigs, baseHref: baseHref, staticAssetsUrl: staticAssetsUrl, diff --git a/packages/flutter_tools/lib/src/runner/flutter_command.dart b/packages/flutter_tools/lib/src/runner/flutter_command.dart index 3badb570baeb4..2264d1b0cfa26 100644 --- a/packages/flutter_tools/lib/src/runner/flutter_command.dart +++ b/packages/flutter_tools/lib/src/runner/flutter_command.dart @@ -29,9 +29,16 @@ import '../project.dart'; import '../reporting/unified_analytics.dart'; import '../version.dart'; import 'flutter_command_runner.dart'; + +import 'options/option_bundle.dart'; +import 'options/option_descriptor.dart'; import 'target_devices.dart'; export '../cache.dart' show DevelopmentArtifact; +export 'options/common_options.dart'; +export 'options/option_bundle.dart'; +export 'options/option_descriptor.dart'; +export 'options/safe_arg_results.dart'; abstract class DotEnvRegex { // Dot env multi-line block value regex @@ -162,7 +169,13 @@ abstract final class FlutterCommandCategory { } abstract class FlutterCommand extends Command { + FlutterCommand({this.verboseHelp = false}); + + /// Whether this command was invoked with verbose help enabled. + final bool verboseHelp; + /// The currently executing command (or sub-command). + /// /// Will be `null` until the top-most command has begun execution. static FlutterCommand? get current => context.get(); @@ -230,8 +243,18 @@ abstract class FlutterCommand extends Command { /// Whether this command uses the 'target' option. var _usesTargetOption = false; + /// Enables the target option flag behavior on this command. + void enableUsesTargetOption() { + _usesTargetOption = true; + } + var _usesPubOption = false; + /// Enables the pub option flag behavior on this command. + void enableUsesPubOption() { + _usesPubOption = true; + } + var _usesPortOption = false; var _usesIpv6Flag = false; @@ -268,6 +291,20 @@ abstract class FlutterCommand extends Command { /// easily reference it or overwrite as necessary. Analytics get analytics => globals.analytics; + final Map> _optionRegistry = + >{}; + + /// Option descriptor registry for type-safe lookups. + Map> get optionRegistry => _optionRegistry; + + /// Registers an [OptionBundle] with this command. + void registerOptionBundle(OptionBundle bundle) { + bundle.register(this, argParser, _optionRegistry); + } + + /// Registers multiple [OptionBundle] instances with this command. + void registerOptionBundles(List bundles) => bundles.forEach(registerOptionBundle); + void requiresPubspecYaml() { _requiresPubspecYaml = true; } diff --git a/packages/flutter_tools/lib/src/runner/options/common_options.dart b/packages/flutter_tools/lib/src/runner/options/common_options.dart new file mode 100644 index 0000000000000..0126d398fe11d --- /dev/null +++ b/packages/flutter_tools/lib/src/runner/options/common_options.dart @@ -0,0 +1,185 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import '../../build_info.dart'; +import '../flutter_command.dart'; + +/// Common typed option descriptors across flutter commands. +abstract final class CommonOptions { + static const treeShakeIcons = FlagOptionDescriptor( + name: 'tree-shake-icons', + defaultsTo: true, + help: 'Tree shake icon fonts so that only glyphs used by the application are included.', + ); + + static const target = StringOptionDescriptor( + name: 'target', + abbr: 't', + defaultsTo: 'lib/main.dart', + help: + 'The main entrypoint file of the application, as run on the device.\n' + 'If the "--target" option is omitted, but a file name is provided on ' + 'the command line, then that file is used instead.', + ); + + static const outputDir = StringOptionDescriptor( + name: 'output', + abbr: 'o', + aliases: ['output-dir'], + help: + 'The absolute path to the directory where the repository is generated. ' + 'By default, this is /build/.\n' + 'Currently supported for subcommands: aar, web.', + ); + + static const pub = FlagOptionDescriptor( + name: 'pub', + defaultsTo: true, + help: 'Whether to run "flutter pub get" before executing this command.', + ); + + static const buildNumber = StringOptionDescriptor( + name: 'build-number', + valueHelp: '1.0.0', + help: + 'An identifier used as an internal version number.\n' + 'Each build must have a unique identifier to differentiate it from previous builds.', + ); + + static const buildName = StringOptionDescriptor( + name: 'build-name', + valueHelp: 'x.y.z', + help: + 'A "x.y.z" string used as the version number shown to users.\n' + 'For each new version of your app, you will provide a version number to differentiate it.', + ); + + static const debugMode = FlagOptionDescriptor( + name: 'debug', + negatable: false, + help: 'Build a debug version of your app.', + ); + + static const profileMode = FlagOptionDescriptor( + name: 'profile', + negatable: false, + help: 'Build a version of your app specialized for performance profiling.', + ); + + static const releaseMode = FlagOptionDescriptor( + name: 'release', + negatable: false, + help: 'Build a release version of your app.', + ); + + static const jitReleaseMode = FlagOptionDescriptor( + name: 'jit-release', + negatable: false, + help: 'Build a JIT release version of your app.', + ); + + static const dartDefines = MultiOptionDescriptor( + name: FlutterOptions.kDartDefinesOption, + abbr: 'D', + splitCommas: false, + aliases: ['dart-defines'], + help: + 'Additional key-value pairs that will be available as constants ' + 'from the String.fromEnvironment, bool.fromEnvironment, and int.fromEnvironment ' + 'constructors. Multiple defines can be passed by repeating "--dart-define".', + valueHelp: 'foo=bar', + ); + + static const dartDefineFromFile = MultiOptionDescriptor( + name: FlutterOptions.kDartDefineFromFileOption, + help: + 'The path of a .json or .env file containing key-value pairs that will be available as environment ' + 'variables. These can be accessed using the String.fromEnvironment, bool.fromEnvironment, and ' + 'int.fromEnvironment constructors. Multiple define files can be passed by repeating "--dart-define-from-file".', + valueHelp: 'use-keys.json', + ); + + static const enableExperiment = MultiOptionDescriptor( + name: FlutterOptions.kEnableExperiment, + verboseOnly: true, + help: + 'The name of an experimental Dart feature to enable. ' + 'Multiple experiments can be enabled by repeating "--enable-experiment".', + valueHelp: 'experiment-name', + ); + + static const nullAssertions = FlagOptionDescriptor( + name: 'null-assertions', + negatable: false, + help: + 'Perform additional checks in sound mode to catch dereferences of null ' + 'values, and runtime type checks on types containing the Never type.', + ); + + static const nativeNullAssertions = FlagOptionDescriptor( + name: 'native-null-assertions', + defaultsTo: true, + help: + 'Enables additional runtime null checks in web applications to ensure ' + 'the correct nullability of native (such as in dart:html) and external ' + '(such as with JS interop) types. This is enabled by default but only takes ' + 'effect in sound mode. To report an issue with a null assertion failure in ' + 'dart:html or the other dart web libraries, please file a bug at: ' + 'https://github.com/dart-lang/sdk/issues/labels/web-libraries', + ); +} + +/// A bundle encapsulating standard build mode flags (`--debug`, `--profile`, `--release`, `--jit-release`). +class BuildModeOptionsBundle extends OptionBundle { + const BuildModeOptionsBundle({this.defaultToRelease = true}); + + final bool defaultToRelease; + + @override + void onRegister(FlutterCommand command) { + command.defaultBuildMode = defaultToRelease ? BuildMode.release : BuildMode.debug; + } + + @override + List> get descriptors => const [ + CommonOptions.debugMode, + CommonOptions.profileMode, + CommonOptions.releaseMode, + CommonOptions.jitReleaseMode, + ]; +} + +/// A bundle encapsulating general Dart compilation options. +class DartCompileOptionsBundle extends OptionBundle { + const DartCompileOptionsBundle(); + + @override + List> get descriptors => const [ + CommonOptions.dartDefines, + CommonOptions.dartDefineFromFile, + CommonOptions.enableExperiment, + CommonOptions.nativeNullAssertions, + ]; +} + +/// A bundle encapsulating basic build parameters (target, output-dir, pub, build-number/name). +class CommonBuildOptionsBundle extends OptionBundle { + const CommonBuildOptionsBundle(); + + @override + void onRegister(FlutterCommand command) { + command.enableUsesTargetOption(); + command.enableUsesPubOption(); + } + + @override + List> get descriptors => const [ + CommonOptions.treeShakeIcons, + CommonOptions.target, + CommonOptions.outputDir, + CommonOptions.pub, + CommonOptions.buildNumber, + CommonOptions.buildName, + ]; +} diff --git a/packages/flutter_tools/lib/src/runner/options/option_bundle.dart b/packages/flutter_tools/lib/src/runner/options/option_bundle.dart new file mode 100644 index 0000000000000..c4a01aac17028 --- /dev/null +++ b/packages/flutter_tools/lib/src/runner/options/option_bundle.dart @@ -0,0 +1,45 @@ +// Copyright 2014 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 'package:args/args.dart'; + +import '../flutter_command.dart'; + +/// An aggregation of related [OptionDescriptor] instances that can be +/// registered and bound to a [FlutterCommand] as a cohesive domain unit. +abstract class OptionBundle { + const OptionBundle(); + + /// An optional visual section header displayed above this bundle's options + /// in `--help` usage output. + String? get title => null; + + /// The list of option descriptors contained within this bundle. + List> get descriptors => const >[]; + + /// Optional child bundles composed inside this bundle. + List get subBundles => const []; + + /// Optional lifecycle hook invoked when this bundle is registered on [command]. + void onRegister(FlutterCommand command) {} + + /// Registers all option descriptors and child bundles into [parser] and binds + /// to [command]. + void register( + FlutterCommand command, + ArgParser parser, + Map> registry, + ) { + if (title != null) { + parser.addSeparator(title!); + } + onRegister(command); + for (final OptionDescriptor descriptor in descriptors) { + descriptor.addTo(parser, registry: registry, verboseHelp: command.verboseHelp); + } + for (final OptionBundle subBundle in subBundles) { + subBundle.register(command, parser, registry); + } + } +} diff --git a/packages/flutter_tools/lib/src/runner/options/option_descriptor.dart b/packages/flutter_tools/lib/src/runner/options/option_descriptor.dart new file mode 100644 index 0000000000000..e993a24a85873 --- /dev/null +++ b/packages/flutter_tools/lib/src/runner/options/option_descriptor.dart @@ -0,0 +1,340 @@ +// Copyright 2014 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 'package:args/args.dart'; + +/// Defines the lookup scope for an [OptionDescriptor]. +enum OptionScope { + /// Look up only within the subcommand's local `ArgResults`. + local, + + /// Look up only within the root command runner's `ArgResults.globalResults`. + global, + + /// Check local `ArgResults` first, falling back to `globalResults`. + any, +} + +/// A metadata-rich, type-safe descriptor for a command-line option or flag. +abstract class OptionDescriptor { + const OptionDescriptor({ + required this.name, + required this.help, + this.abbr, + this.valueHelp, + this.defaultsTo, + this.allowed, + this.allowedHelp, + this.scope = OptionScope.local, + this.hide = false, + this.verboseOnly = false, + }); + + /// The CLI option name (without leading dashes). + final String name; + + /// The help string displayed in usage output. + final String help; + + /// An optional single-character abbreviation. + final String? abbr; + + /// An optional label displayed in usage to represent the value. + final String? valueHelp; + + /// The default value if not specified on the command line. + final T? defaultsTo; + + /// An optional list of allowed values. + final List? allowed; + + /// An optional map of allowed values to their descriptions. + final Map? allowedHelp; + + /// The scope where this option's result is located. + final OptionScope scope; + + /// Whether to hide this option from usage output by default. + final bool hide; + + /// Whether to hide this option from usage output unless verbose help is requested. + final bool verboseOnly; + + /// The CLI flag representation (e.g., `--target`). + String get flag => '--$name'; + + /// Registers this option with [parser], maintaining descriptor identity in [registry]. + void addTo( + ArgParser parser, { + Map>? registry, + bool verboseHelp = false, + bool? hideOverride, + }); + + /// Checks if this option was explicitly provided on the command line. + bool wasProvided(ArgResults? results, {ArgResults? globalResults}) { + final ArgResults? target = _resolveTargetResults(results, globalResults); + return target != null && target.options.contains(name) && target.wasParsed(name); + } + + /// Checks if this option was explicitly parsed (alias for [wasProvided]). + bool wasParsed(ArgResults? results, {ArgResults? globalResults}) => + wasProvided(results, globalResults: globalResults); + + /// Returns the resolved value for this option. + T getValue(ArgResults? results, {ArgResults? globalResults}); + + ArgResults? _resolveTargetResults(ArgResults? results, ArgResults? globalResults) { + return switch (scope) { + .local => results, + .global => globalResults, + .any when results?.options.contains(name) == true && results!.wasParsed(name) => results, + .any => globalResults ?? results, + }; + } + + bool _computeEffectiveHide({required bool verboseHelp, bool? hideOverride}) => + hideOverride ?? (hide || (verboseOnly && !verboseHelp)); + + void _throwConflictError(String name, OptionDescriptor? existing) { + final existingInfo = existing != null + ? '${existing.runtimeType} (help: "${existing.help}")' + : 'non-descriptor option'; + throw ArgumentError( + 'Conflicting option descriptor registered for "$name"!\n' + 'Existing: $existingInfo\n' + 'New: $runtimeType (help: "$help")', + ); + } +} + +/// A descriptor for single-value string options. +class StringOptionDescriptor extends OptionDescriptor { + const StringOptionDescriptor({ + required super.name, + required super.help, + super.abbr, + super.valueHelp, + super.defaultsTo, + this.aliases = const [], + super.allowed, + super.allowedHelp, + super.scope, + super.hide, + super.verboseOnly, + }); + + /// Alternative names for this option. + final List aliases; + + @override + void addTo( + ArgParser parser, { + Map>? registry, + bool verboseHelp = false, + bool? hideOverride, + }) { + if (parser.options.containsKey(name)) { + final OptionDescriptor? existing = registry?[name]; + if (existing != null && identical(existing, this)) { + return; + } + _throwConflictError(name, existing); + } + + parser.addOption( + name, + abbr: abbr, + aliases: aliases, + help: help, + valueHelp: valueHelp, + defaultsTo: defaultsTo, + allowed: allowed, + allowedHelp: allowedHelp, + hide: _computeEffectiveHide(verboseHelp: verboseHelp, hideOverride: hideOverride), + ); + registry?[name] = this; + } + + @override + String? getValue(ArgResults? results, {ArgResults? globalResults}) { + if (wasProvided(results, globalResults: globalResults)) { + final ArgResults? target = _resolveTargetResults(results, globalResults); + return target?[name] as String?; + } + return defaultsTo; + } + + /// Returns the resolved value or [fallback] if null. + String getValueOrDefault( + ArgResults? results, { + ArgResults? globalResults, + String fallback = '', + }) => getValue(results, globalResults: globalResults) ?? fallback; +} + +/// A descriptor for boolean flags with a concrete default value. +class FlagOptionDescriptor extends OptionDescriptor { + const FlagOptionDescriptor({ + required super.name, + required super.help, + super.abbr, + super.defaultsTo = false, + this.negatable = true, + super.scope, + super.hide, + super.verboseOnly, + }); + + /// Whether the flag can be negated with `--no-`. + final bool negatable; + + @override + void addTo( + ArgParser parser, { + Map>? registry, + bool verboseHelp = false, + bool? hideOverride, + }) { + if (parser.options.containsKey(name)) { + final OptionDescriptor? existing = registry?[name]; + if (existing != null && identical(existing, this)) { + return; + } + _throwConflictError(name, existing); + } + parser.addFlag( + name, + abbr: abbr, + help: help, + defaultsTo: defaultsTo ?? false, + negatable: negatable, + hide: _computeEffectiveHide(verboseHelp: verboseHelp, hideOverride: hideOverride), + ); + registry?[name] = this; + } + + @override + bool getValue(ArgResults? results, {ArgResults? globalResults}) { + if (wasProvided(results, globalResults: globalResults)) { + final ArgResults? target = _resolveTargetResults(results, globalResults); + return (target?[name] as bool?) ?? defaultsTo ?? false; + } + return defaultsTo ?? false; + } +} + +/// A descriptor for tri-state boolean flags without a default value. +/// +/// When omitted from the command line, [getValue] returns `null`. +class NullableFlagOptionDescriptor extends OptionDescriptor { + const NullableFlagOptionDescriptor({ + required super.name, + required super.help, + super.abbr, + this.negatable = true, + super.scope, + super.hide, + super.verboseOnly, + }) : super(defaultsTo: null); + + /// Whether the flag can be negated with `--no-`. + final bool negatable; + + @override + void addTo( + ArgParser parser, { + Map>? registry, + bool verboseHelp = false, + bool? hideOverride, + }) { + if (parser.options.containsKey(name)) { + final OptionDescriptor? existing = registry?[name]; + if (existing != null && identical(existing, this)) { + return; + } + _throwConflictError(name, existing); + } + parser.addFlag( + name, + abbr: abbr, + help: help, + defaultsTo: null, + negatable: negatable, + hide: _computeEffectiveHide(verboseHelp: verboseHelp, hideOverride: hideOverride), + ); + registry?[name] = this; + } + + @override + bool? getValue(ArgResults? results, {ArgResults? globalResults}) { + if (wasProvided(results, globalResults: globalResults)) { + final ArgResults? target = _resolveTargetResults(results, globalResults); + return target?[name] as bool?; + } + return defaultsTo; + } +} + +/// A descriptor for multi-value options (passed multiple times or comma-separated). +class MultiOptionDescriptor extends OptionDescriptor> { + const MultiOptionDescriptor({ + required super.name, + required super.help, + super.abbr, + super.valueHelp, + this.splitCommas = true, + this.aliases = const [], + super.defaultsTo = const [], + super.allowed, + super.allowedHelp, + super.scope, + super.hide, + super.verboseOnly, + }); + + /// Whether values containing commas are split into multiple entries. + final bool splitCommas; + + /// Alternative names for this option. + final List aliases; + + @override + void addTo( + ArgParser parser, { + Map>? registry, + bool verboseHelp = false, + bool? hideOverride, + }) { + if (parser.options.containsKey(name)) { + final OptionDescriptor? existing = registry?[name]; + if (existing != null && identical(existing, this)) { + return; + } + _throwConflictError(name, existing); + } + parser.addMultiOption( + name, + abbr: abbr, + aliases: aliases, + help: help, + valueHelp: valueHelp, + defaultsTo: defaultsTo, + splitCommas: splitCommas, + allowed: allowed, + allowedHelp: allowedHelp, + hide: _computeEffectiveHide(verboseHelp: verboseHelp, hideOverride: hideOverride), + ); + registry?[name] = this; + } + + @override + List getValue(ArgResults? results, {ArgResults? globalResults}) { + if (wasProvided(results, globalResults: globalResults)) { + final ArgResults? target = _resolveTargetResults(results, globalResults); + return (target?[name] as List?)?.cast() ?? const []; + } + return defaultsTo ?? const []; + } +} diff --git a/packages/flutter_tools/lib/src/runner/options/safe_arg_results.dart b/packages/flutter_tools/lib/src/runner/options/safe_arg_results.dart new file mode 100644 index 0000000000000..cf444a1f83ab0 --- /dev/null +++ b/packages/flutter_tools/lib/src/runner/options/safe_arg_results.dart @@ -0,0 +1,20 @@ +// Copyright 2014 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 '../flutter_command.dart'; + +/// Type-safe argument extraction extension for [FlutterCommand]. +extension SafeArgResults on FlutterCommand { + /// Returns the resolved value for [descriptor], falling back to its default value. + T getValue(OptionDescriptor descriptor) => + descriptor.getValue(argResults, globalResults: globalResults); + + /// Returns whether [descriptor] was explicitly provided on the command line. + bool wasProvided(OptionDescriptor descriptor) => + descriptor.wasProvided(argResults, globalResults: globalResults); + + /// Checks if this option was explicitly parsed (alias for [wasProvided]). + bool wasParsed(OptionDescriptor descriptor) => + descriptor.wasProvided(argResults, globalResults: globalResults); +} diff --git a/packages/flutter_tools/lib/src/web/web_options.dart b/packages/flutter_tools/lib/src/web/web_options.dart new file mode 100644 index 0000000000000..028e3796dd0ab --- /dev/null +++ b/packages/flutter_tools/lib/src/web/web_options.dart @@ -0,0 +1,225 @@ +// Copyright 2014 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 '../base/utils.dart'; +import '../runner/options/option_bundle.dart'; +import '../runner/options/option_descriptor.dart'; +import '../web_template.dart'; +import 'file_generators/flutter_service_worker_js.dart'; +import 'web_constants.dart'; + +/// Typed option descriptors specific to Flutter Web compilation. +abstract final class WebOptions { + static const webDefines = MultiOptionDescriptor( + name: 'web-define', + splitCommas: false, + aliases: ['web-defines'], + help: + 'Additional key-value pairs that will be available as constants ' + 'from the String.fromEnvironment, bool.fromEnvironment, and int.fromEnvironment ' + 'constructors only when compiling for the web. Multiple defines can be passed by repeating "--web-define".', + valueHelp: 'foo=bar', + ); + + static const webDefineFromFile = MultiOptionDescriptor( + name: 'web-define-from-file', + help: + 'The path of a .json or .env file containing key-value pairs that will be available as environment ' + 'variables only when compiling for the web. These can be accessed using the String.fromEnvironment, bool.fromEnvironment, and ' + 'int.fromEnvironment constructors. Multiple define files can be passed by repeating "--web-define-from-file".', + valueHelp: 'use-keys.json', + ); + + static const baseHref = StringOptionDescriptor( + name: 'base-href', + help: + 'Overrides the href attribute of the tag in web/index.html. ' + 'No change is done to web/index.html if this flag is not provided. ' + 'The value has to start and end with a slash "/". ' + 'For more information: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base', + ); + + static const staticAssetsUrl = StringOptionDescriptor( + name: 'static-assets-url', + help: + 'Used when serving the static assets from a different domain the application is hosted on. ' + 'The value has to end with a slash "/". ' + 'When this is set, it will replace all $kStaticAssetsUrlPlaceholder in web/index.html for the given value.', + ); + + static final pwaStrategy = StringOptionDescriptor( + name: 'pwa-strategy', + hide: true, + help: + 'This option is deprecated and will be removed in a future Flutter release.\n' + 'The caching strategy to be used by the PWA service worker.', + allowed: ServiceWorkerStrategy.values.map((ServiceWorkerStrategy e) => e.cliName).toList(), + allowedHelp: CliEnum.allowedHelp(ServiceWorkerStrategy.values), + ); + + static const webResourcesCdn = FlagOptionDescriptor( + name: 'web-resources-cdn', + defaultsTo: true, + help: + 'Use WebAssembly, CanvasKit, and other web resources from a content delivery network (CDN).\n' + 'Set to "--no-web-resources-cdn" to embed all web resources locally in the built app.', + ); + + static const optimizationLevel = StringOptionDescriptor( + name: 'optimization-level', + abbr: 'O', + allowed: ['0', '1', '2', '3', '4'], + help: 'Sets the optimization level used for Dart compilation to JavaScript/Wasm.', + ); + + static const sourceMaps = FlagOptionDescriptor( + name: 'source-maps', + help: + 'Generate a sourcemap file. These can be used by browsers ' + 'to view and debug the original source code of a compiled and minified Dart ' + 'application.', + ); + + static const csp = FlagOptionDescriptor( + name: 'csp', + negatable: false, + help: + 'Disable dynamic generation of code in the generated output. ' + 'This is necessary to satisfy CSP restrictions (see http://www.w3.org/TR/CSP/).', + ); + + static const dart2jsOptimization = StringOptionDescriptor( + name: 'dart2js-optimization', + allowed: ['O1', 'O2', 'O3', 'O4'], + help: + 'Sets the optimization level used for Dart compilation to JavaScript. ' + 'Deprecated: Please use "-O=" / "--optimization-level=".', + ); + + static const dumpInfo = FlagOptionDescriptor( + name: 'dump-info', + negatable: false, + verboseOnly: true, + help: + 'Passes "--dump-info" to the Javascript compiler which generates ' + 'information about the generated code in main.dart.js.info.json.', + ); + + static const minifyJs = NullableFlagOptionDescriptor( + name: 'minify-js', + verboseOnly: true, + help: + 'Generate minified output for js. ' + 'If not explicitly set, uses the compilation mode (debug, profile, release).', + ); + + static const minifyWasm = NullableFlagOptionDescriptor( + name: 'minify-wasm', + verboseOnly: true, + help: + 'Generate minified output for wasm. ' + 'If not explicitly set, uses the compilation mode (debug, profile, release).', + ); + + static const enableWasmDeferredLoading = FlagOptionDescriptor( + name: 'enable-wasm-deferred-loading', + verboseOnly: true, + help: 'Enable multi-module deferred loading for Wasm.', + ); + + static const wasmDryRun = FlagOptionDescriptor( + name: 'wasm-dry-run', + defaultsTo: true, + help: + 'Compiles wasm in dry run mode during JS only compilations. ' + 'Disable to suppress warnings.', + ); + + static const noFrequencyBasedMinification = FlagOptionDescriptor( + name: 'no-frequency-based-minification', + negatable: false, + verboseOnly: true, + help: + 'Disables the frequency based minifier. ' + 'Useful for comparing the output between builds.', + ); + + static const wasm = FlagOptionDescriptor( + name: 'wasm', + negatable: false, + help: 'Compile to WebAssembly (with fallback to JavaScript).\n$kWasmMoreInfo', + ); + + static const stripWasm = FlagOptionDescriptor( + name: 'strip-wasm', + defaultsTo: true, + help: 'Whether to strip the resulting wasm file of static symbol names.', + ); +} + +/// A bundle encapsulating general Flutter Web options and flags. +class WebCoreOptionsBundle extends OptionBundle { + const WebCoreOptionsBundle(); + + @override + String? get title => 'Flutter web options'; + + @override + List> get descriptors => [ + WebOptions.baseHref, + WebOptions.staticAssetsUrl, + WebOptions.pwaStrategy, + WebOptions.webResourcesCdn, + WebOptions.webDefines, + WebOptions.webDefineFromFile, + WebOptions.optimizationLevel, + WebOptions.sourceMaps, + ]; +} + +/// A bundle encapsulating JavaScript-specific compilation options. +class WebJsOptionsBundle extends OptionBundle { + const WebJsOptionsBundle(); + + @override + String? get title => 'JavaScript compilation options'; + + @override + List> get descriptors => const >[ + WebOptions.csp, + WebOptions.dart2jsOptimization, + WebOptions.dumpInfo, + WebOptions.minifyJs, + WebOptions.noFrequencyBasedMinification, + ]; +} + +/// A bundle encapsulating WebAssembly-specific compilation options. +class WebWasmOptionsBundle extends OptionBundle { + const WebWasmOptionsBundle(); + + @override + String? get title => 'WebAssembly compilation options'; + + @override + List> get descriptors => const >[ + WebOptions.wasm, + WebOptions.stripWasm, + WebOptions.minifyWasm, + WebOptions.enableWasmDeferredLoading, + WebOptions.wasmDryRun, + ]; +} + +/// A composite bundle encapsulating all options and flags needed for Flutter Web compilation. +class WebOptionsBundle extends OptionBundle { + const WebOptionsBundle(); + + @override + List get subBundles => const [ + WebCoreOptionsBundle(), + WebJsOptionsBundle(), + WebWasmOptionsBundle(), + ]; +} diff --git a/packages/flutter_tools/test/commands.shard/hermetic/build_web_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/build_web_test.dart index 97d5cec7d692a..794d4cb38a7dc 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/build_web_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/build_web_test.dart @@ -952,7 +952,70 @@ void main() { expect(command.usage, isNot(contains(option))); } + // Deprecated options are always hidden. expectHidden('pwa-strategy'); + + // Verbose-only options are hidden in standard help output. + expectHidden('dump-info'); + expectHidden('minify-js'); + expectHidden('minify-wasm'); + expectHidden('enable-wasm-deferred-loading'); + expectHidden('no-frequency-based-minification'); + expectHidden('enable-experiment'); + + // Standard options are visible. + expectVisible('web-resources-cdn'); + expectVisible('optimization-level'); + expectVisible('source-maps'); + expectVisible('csp'); + expectVisible('dart2js-optimization'); + expectVisible('wasm'); + expectVisible('strip-wasm'); + expectVisible('base-href'); + }, + overrides: { + Platform: () => fakePlatform, + FileSystem: () => fileSystem, + FeatureFlags: () => TestFeatureFlags(isWebEnabled: true), + ProcessManager: () => processManager, + }, + ); + + testUsingContext( + 'flutter build web option visibility with verboseHelp', + () async { + final buildCommand = TestWebBuildCommand(fileSystem: fileSystem, verboseHelp: true); + createTestCommandRunner(buildCommand); + final command = buildCommand.subcommands.values.single as BuildWebCommand; + + void expectVisible(String option) { + expect(command.argParser.options.keys, contains(option)); + expect( + command.argParser.options[option]!.hide, + isFalse, + reason: 'Expecting `$option` to be visible with verboseHelp: true', + ); + expect(command.usage, contains(option)); + } + + void expectHidden(String option) { + expect(command.argParser.options.keys, contains(option)); + expect(command.argParser.options[option]!.hide, isTrue); + expect(command.usage, isNot(contains(option))); + } + + // Deprecated options remain hidden. + expectHidden('pwa-strategy'); + + // Verbose-only options become visible when verboseHelp is true. + expectVisible('dump-info'); + expectVisible('minify-js'); + expectVisible('minify-wasm'); + expectVisible('enable-wasm-deferred-loading'); + expectVisible('no-frequency-based-minification'); + expectVisible('enable-experiment'); + + // Standard options remain visible. expectVisible('web-resources-cdn'); expectVisible('optimization-level'); expectVisible('source-maps'); diff --git a/packages/flutter_tools/test/general.shard/runner/options/option_descriptor_test.dart b/packages/flutter_tools/test/general.shard/runner/options/option_descriptor_test.dart new file mode 100644 index 0000000000000..bdb7dba9cb0ff --- /dev/null +++ b/packages/flutter_tools/test/general.shard/runner/options/option_descriptor_test.dart @@ -0,0 +1,332 @@ +// Copyright 2014 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 'package:args/args.dart'; +import 'package:args/command_runner.dart'; +import 'package:flutter_tools/src/runner/flutter_command.dart'; + +import '../../../src/common.dart'; +import '../../../src/context.dart'; +import '../../../src/test_flutter_command_runner.dart'; + +class _FakeCommand extends FlutterCommand { + _FakeCommand({ + required this.name, + required this.description, + super.verboseHelp = false, + List bundles = const [], + }) { + registerOptionBundles(bundles); + } + + @override + final String name; + + @override + final String description; + + @override + Future runCommand() async => FlutterCommandResult.success(); +} + +void main() { + group('FlagOptionDescriptor', () { + test('defaultsTo true returns true when omitted', () { + const descriptor = FlagOptionDescriptor( + name: 'test-flag', + defaultsTo: true, + help: 'Test flag help', + ); + final parser = ArgParser(); + descriptor.addTo(parser); + + final ArgResults results = parser.parse([]); + expect(descriptor.wasProvided(results), isFalse); + expect(descriptor.wasParsed(results), isFalse); + expect(descriptor.getValue(results), isTrue); + }); + + test('defaultsTo false returns false when omitted', () { + const descriptor = FlagOptionDescriptor(name: 'test-flag', help: 'Test flag help'); + final parser = ArgParser(); + descriptor.addTo(parser); + + final ArgResults results = parser.parse([]); + expect(descriptor.wasProvided(results), isFalse); + expect(descriptor.getValue(results), isFalse); + }); + + test('returns true when passed explicitly', () { + const descriptor = FlagOptionDescriptor(name: 'test-flag', help: 'Test flag help'); + final parser = ArgParser(); + descriptor.addTo(parser); + + final ArgResults results = parser.parse(['--test-flag']); + expect(descriptor.wasProvided(results), isTrue); + expect(descriptor.getValue(results), isTrue); + }); + + test('returns false when negated explicitly', () { + const descriptor = FlagOptionDescriptor( + name: 'test-flag', + defaultsTo: true, + help: 'Test flag help', + ); + final parser = ArgParser(); + descriptor.addTo(parser); + + final ArgResults results = parser.parse(['--no-test-flag']); + expect(descriptor.wasProvided(results), isTrue); + expect(descriptor.getValue(results), isFalse); + }); + }); + + group('NullableFlagOptionDescriptor', () { + test('returns null when omitted from command line', () { + const descriptor = NullableFlagOptionDescriptor( + name: 'tri-flag', + help: 'Tri-state flag help', + ); + final parser = ArgParser(); + descriptor.addTo(parser); + + final ArgResults results = parser.parse([]); + expect(descriptor.wasProvided(results), isFalse); + expect(descriptor.getValue(results), isNull); + }); + + test('returns true when passed explicitly', () { + const descriptor = NullableFlagOptionDescriptor( + name: 'tri-flag', + help: 'Tri-state flag help', + ); + final parser = ArgParser(); + descriptor.addTo(parser); + + final ArgResults results = parser.parse(['--tri-flag']); + expect(descriptor.wasProvided(results), isTrue); + expect(descriptor.getValue(results), isTrue); + }); + + test('returns false when negated explicitly', () { + const descriptor = NullableFlagOptionDescriptor( + name: 'tri-flag', + help: 'Tri-state flag help', + ); + final parser = ArgParser(); + descriptor.addTo(parser); + + final ArgResults results = parser.parse(['--no-tri-flag']); + expect(descriptor.wasProvided(results), isTrue); + expect(descriptor.getValue(results), isFalse); + }); + }); + + group('StringOptionDescriptor', () { + test('returns default value when omitted', () { + const descriptor = StringOptionDescriptor( + name: 'target', + defaultsTo: 'lib/main.dart', + help: 'Target entrypoint', + ); + final parser = ArgParser(); + descriptor.addTo(parser); + + final ArgResults results = parser.parse([]); + expect(descriptor.wasProvided(results), isFalse); + expect(descriptor.getValue(results), 'lib/main.dart'); + }); + + test('returns null when omitted without default value', () { + const descriptor = StringOptionDescriptor(name: 'base-href', help: 'Base href'); + final parser = ArgParser(); + descriptor.addTo(parser); + + final ArgResults results = parser.parse([]); + expect(descriptor.wasProvided(results), isFalse); + expect(descriptor.getValue(results), isNull); + }); + + test('returns explicit value and respects aliases', () { + const descriptor = StringOptionDescriptor( + name: 'output', + aliases: ['output-dir'], + help: 'Output directory', + ); + final parser = ArgParser(); + descriptor.addTo(parser); + + final ArgResults results = parser.parse(['--output-dir=/tmp/build']); + expect(descriptor.wasProvided(results), isTrue); + expect(descriptor.getValue(results), '/tmp/build'); + }); + }); + + group('MultiOptionDescriptor', () { + test('returns empty list when omitted', () { + const descriptor = MultiOptionDescriptor(name: 'define', help: 'Defines'); + final parser = ArgParser(); + descriptor.addTo(parser); + + final ArgResults results = parser.parse([]); + expect(descriptor.wasProvided(results), isFalse); + expect(descriptor.getValue(results), isEmpty); + }); + + test('returns default values when omitted with custom defaultsTo', () { + const descriptor = MultiOptionDescriptor( + name: 'tags', + defaultsTo: ['alpha', 'beta'], + help: 'Tags', + ); + final parser = ArgParser(); + descriptor.addTo(parser); + + expect(parser.options['tags']!.defaultsTo, ['alpha', 'beta']); + + final ArgResults results = parser.parse([]); + expect(descriptor.wasProvided(results), isFalse); + expect(descriptor.getValue(results), ['alpha', 'beta']); + }); + + test('returns all parsed items in order', () { + const descriptor = MultiOptionDescriptor(name: 'define', splitCommas: false, help: 'Defines'); + final parser = ArgParser(); + descriptor.addTo(parser); + + final ArgResults results = parser.parse(['--define=a=1', '--define=b=2']); + expect(descriptor.wasProvided(results), isTrue); + expect(descriptor.getValue(results), ['a=1', 'b=2']); + }); + }); + + group('OptionDescriptor conflicts and identity', () { + test('re-registering identical descriptor succeeds', () { + const descriptor = FlagOptionDescriptor(name: 'shared-flag', help: 'Shared flag'); + final parser = ArgParser(); + final registry = >{}; + + descriptor.addTo(parser, registry: registry); + expect(() => descriptor.addTo(parser, registry: registry), returnsNormally); + }); + + test('registering conflicting descriptor throws detailed ArgumentError', () { + const first = FlagOptionDescriptor(name: 'conflict-flag', help: 'First flag'); + const second = FlagOptionDescriptor(name: 'conflict-flag', help: 'Second flag'); + final parser = ArgParser(); + final registry = >{}; + + first.addTo(parser, registry: registry); + expect( + () => second.addTo(parser, registry: registry), + throwsA( + isA().having( + (ArgumentError e) => e.message, + 'message', + contains('Conflicting option descriptor registered for "conflict-flag"!'), + ), + ), + ); + }); + }); + + group('OptionBundle and Command Integration', () { + testUsingContext('registers options and provides typed values via SafeArgResults', () async { + const flagOpt = FlagOptionDescriptor(name: 'fast', defaultsTo: true, help: 'Fast mode'); + const triFlagOpt = NullableFlagOptionDescriptor(name: 'optimize', help: 'Optimize'); + const stringOpt = StringOptionDescriptor(name: 'name', defaultsTo: 'app', help: 'App name'); + + const bundle = _SimpleBundle( + descriptors: >[flagOpt, triFlagOpt, stringOpt], + ); + final command = _FakeCommand( + name: 'build', + description: 'Build command', + bundles: const [bundle], + ); + + final CommandRunner runner = createTestCommandRunner(command); + await runner.run(['build', '--no-fast', '--optimize', '--name=custom']); + + expect(command.getValue(flagOpt), isFalse); + expect(command.wasProvided(flagOpt), isTrue); + + expect(command.getValue(triFlagOpt), isTrue); + expect(command.wasProvided(triFlagOpt), isTrue); + + expect(command.getValue(stringOpt), 'custom'); + expect(command.wasProvided(stringOpt), isTrue); + }); + + testUsingContext('renders section separator titles in command usage', () { + const bundle = _TitledBundle(); + final command = _FakeCommand( + name: 'build', + description: 'Build command', + bundles: const [bundle], + ); + createTestCommandRunner(command); + + expect(command.usage, contains('Section Header')); + expect(command.usage, contains('--[no-]titled-flag')); + }); + + testUsingContext('auto-wires verboseHelp to dynamically hide or show verboseOnly options', () { + const verboseFlag = FlagOptionDescriptor( + name: 'internal-flag', + verboseOnly: true, + help: 'Internal flag help', + ); + const standardFlag = FlagOptionDescriptor(name: 'public-flag', help: 'Public flag help'); + const bundle = _SimpleBundle( + descriptors: >[verboseFlag, standardFlag], + ); + + final normalCommand = _FakeCommand( + name: 'build', + description: 'Build command', + bundles: const [bundle], + ); + + createTestCommandRunner(normalCommand); + + expect(normalCommand.argParser.options['internal-flag']!.hide, isTrue); + expect(normalCommand.argParser.options['public-flag']!.hide, isFalse); + expect(normalCommand.usage, isNot(contains('internal-flag'))); + expect(normalCommand.usage, contains('public-flag')); + + final verboseCommand = _FakeCommand( + name: 'build', + description: 'Build command', + verboseHelp: true, + bundles: const [bundle], + ); + createTestCommandRunner(verboseCommand); + + expect(verboseCommand.argParser.options['internal-flag']!.hide, isFalse); + expect(verboseCommand.argParser.options['public-flag']!.hide, isFalse); + expect(verboseCommand.usage, contains('internal-flag')); + expect(verboseCommand.usage, contains('public-flag')); + }); + }); +} + +class _SimpleBundle extends OptionBundle { + const _SimpleBundle({required this.descriptors}); + + @override + final List> descriptors; +} + +class _TitledBundle extends OptionBundle { + const _TitledBundle(); + + @override + String get title => 'Section Header'; + + @override + List> get descriptors => const >[ + FlagOptionDescriptor(name: 'titled-flag', help: 'Flag under title'), + ]; +} diff --git a/packages/flutter_tools/test/general.shard/web/compile_web_test.dart b/packages/flutter_tools/test/general.shard/web/compile_web_test.dart index de0864c56d053..f80dd610eb31d 100644 --- a/packages/flutter_tools/test/general.shard/web/compile_web_test.dart +++ b/packages/flutter_tools/test/general.shard/web/compile_web_test.dart @@ -150,7 +150,7 @@ environement: 'target', BuildInfo.debug, ServiceWorkerStrategy.offlineFirst, - compilerConfigs: [], + compilerConfigs: const [], ); expect(logger.statusText, contains('Compiling target for the Web...')); @@ -194,8 +194,8 @@ environement: flutterProject, 'target', BuildInfo.debug, - null, // serviceWorkerStrategy is omitted - compilerConfigs: [], + null, + compilerConfigs: const [], ); expect(logger.statusText, contains('Compiling target for the Web...')); From 7c25794b41a050689bff2399829990f530e61199 Mon Sep 17 00:00:00 2001 From: Reid-Agent <269567208+reidbaker-agent@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:03:05 +0000 Subject: [PATCH 09/46] [rules] Add packages/flutter_tools/gradle/AGENTS.md rules (#191486) This work came in response to pr https://github.com/flutter/flutter/pull/191218 and a conversation with @gmackall about how I did not think the new dsl migration counted as "agentic engineering" because I was not doing the work like we have as part of project one shot in camera_android_camerax. Really the new dsl work is a great opportunity to lay out some of the patterns we have found. @gmackall to double check the engineering principals and add any others he thinks are important. @camsim99 to help review agents.md. @camsim99 we have no skills or really much documentation so the agents.md file has more primary logic than I would normally like but I think in this case it is ok. The purpose is to give agents working on the flutter gradle plugin guidance for what we consider important and how we want to architect work. There are no evals in flutter/flutter so I have not written any. This is acknowledged technical debt so we can get the benefit of this steering on follow-up new dsl prs (4-11). I consider this newDsl pr 3.1. - @reidbaker --- Agent authored description ## Description This PR introduces `packages/flutter_tools/gradle/AGENTS.md` following Jetski's directory-scoped `AGENTS.md` standard. This defines workspace architectural rules, configuration cache compatibility, and development practices for the Flutter Gradle Plugin (FGP). ### Key Rules Included: 1. **Zero Pollution of Customer-Facing Build Scripts**: Enforces consumer build isolation in composite builds (`includeBuild`); forbids contributor verification tasks or lifecycle hooks (`check.dependsOn`/`test.dependsOn`) in `build.gradle.kts`, requiring standard JUnit tests or CI shards instead. 2. **Type-Safe AGP & Gradle API Usage (No `@Suppress`)**: Prohibits raw wildcard casts and `@Suppress("UNCHECKED_CAST")` when dealing with AGP domain objects; mandates type-safe APIs and compatibility bridges. 3. **Lazy Configuration & Execution Avoidance**: Prohibits eager `.get()` during configuration phase; requires Gradle Lazy Provider chaining. 4. **Strict Configuration Cache Compatibility**: Forbids storing `Project`, `SourceSet`, or `Configuration` references in task instances. 5. **Build Avoidance & Path Normalization**: Requires `@PathSensitive(RELATIVE)` or `NAME_ONLY` to maximize local and remote cache hits across machines. 6. **Worker API for Heavy Computation**: Decouples heavy computation, parsing, and tooling execution to non-blocking worker threads. 7. **Namespace & Environment Hygiene**: Enforces `flutter.internal.` property prefixing and full matrix verification across supported AGP/Gradle versions. 8. **Scope & Legacy Code Policy (The Ratchet Principle)**: Enforces compliance on all new and modified code while preventing unapproved scope expansion on legacy code, while instructing agents to surface adjacent improvement opportunities to the user. ## Related Issues - Related to FGP architecture, AGP 8/9 compatibility, and Jetski agent directory rules. ## Tests - Documentation and rule file only (test-exempt). ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] All existing and new tests are passing. --------- Co-authored-by: Reid Baker <1063596+reidbaker@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/flutter_tools/gradle/AGENTS.md | 40 +++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 packages/flutter_tools/gradle/AGENTS.md diff --git a/packages/flutter_tools/gradle/AGENTS.md b/packages/flutter_tools/gradle/AGENTS.md new file mode 100644 index 0000000000000..058dc38d9a765 --- /dev/null +++ b/packages/flutter_tools/gradle/AGENTS.md @@ -0,0 +1,40 @@ +# Flutter Gradle Plugin (FGP) Architecture & Development Rules + +## Scope & Legacy Code Policy (The Ratchet Principle) +- **New Code & Modified Lines**: All newly introduced tasks, properties, build logic, and modified lines must strictly comply with these rules. +- **No Automatic Legacy Refactoring**: Pre-existing code that violates target architectural guidelines (such as Worker API adoption, configuration-cache compliance, or legacy mock boilerplate) should **not** be automatically refactored within an unrelated PR to prevent scope explosion, high review burden, and regression risks. +- **Surface Opportunities to the User**: When encountering adjacent legacy violations or cleanup opportunities during development, bring them to the user's attention with a brief rationale rather than silently skipping them or applying unapproved refactors. The user can then decide whether to include a localized cleanup or track it for a follow-up PR. +- **Dedicated Refactoring**: Large-scale migrations of existing tasks to modern Gradle APIs should be planned and executed in dedicated, standalone pull requests. + +--- + +## 1. Zero Pollution of Customer-Facing Build Scripts +- **Consumer Build Isolation**: `build.gradle.kts` is evaluated directly in customer projects via composite builds (`includeBuild`). Keep build scripts minimal with only logic needed to compile and package the plugin. +- **No Contributor Tasks in Build Scripts**: Never register contributor verification tasks, custom lint tasks, or lifecycle hooks (`check.dependsOn`, `test.dependsOn`) in customer-evaluated build scripts. +- **Offline Verification**: Implement all contributor assertions, bytecode validations, and ABI checks in unit tests (`src/test/kotlin/`) or CI integration test shards (`packages/flutter_tools/test/integration.shard/`). + +## 2. Type-Safe AGP & Gradle API Usage (No `@Suppress`) +- **No Unsafe Casts or Suppression**: Never use raw wildcard casts (e.g., `as NamedDomainObjectContainer`) or `@Suppress("UNCHECKED_CAST")`. Unchecked casts conceal breaking changes across AGP versions. +- **Use Idiomatic Type-Safe APIs**: Use official, type-safe AGP APIs (e.g., `pluginBuildTypes.create(name) { initWith(source) }`) to manipulate domain objects safely across AGP releases. +- **Structured Compatibility Layers**: When bridging binary- or DSL-incompatible AGP versions (e.g., AGP 8 vs 9), introduce explicit, type-safe abstraction wrappers or reflection bridges. + +## 3. Lazy Configuration & Execution Avoidance +- **Never Eagerly Resolve at Configuration Time**: Never call .get() or .getOrNull() during plugin application or task configuration (afterEvaluate, task creation). Wire inputs and outputs lazily using Property, Provider, ListProperty, MapProperty, DirectoryProperty, and RegularFileProperty. +- **Wire Providers Directly**: Pass providers directly into task inputs (e.g., `task.inputDir.set(extension.path)`). + +## 4. Strict Configuration Cache Compatibility +- **No Project State in Tasks**: Tasks must never hold references to `Project`, `SourceSet`, `Configuration`, or other non-serializable Gradle model objects in fields or action closures. +- **Pass Serializable Inputs**: Inject required values as primitive types, serializable data structures, or Gradle Property instances annotated with @Input, @InputFiles, or @InputDirectory. +- **Use Injected Services**: Use Gradle service injection (e.g., @Inject for FileSystemOperations, ArchiveOperations, or ExecOperations) inside tasks instead of calling Project helper methods. + +## 5. Build Avoidance & Path Normalization +- **Path Normalization**: Annotate all file inputs (such as @InputFile or @InputDirectory) with @PathSensitive(PathSensitivity.RELATIVE) or @PathSensitive(PathSensitivity.NAME_ONLY) (instead of absolute paths) to ensure cache hits across different machines and CI environments. +- **Deterministic Cache Keys**: Never mark non-deterministic inputs (such as timestamps or machine-dependent environment variables) as `@Input`. +- **Explicit Outputs**: Declare `@OutputFile` or `@OutputDirectory` for every produced artifact. + +## 6. Worker API for Heavy Computation +- **Thread & Classloader Isolation**: Offload heavy computation, class parsing, code generation, or external process execution to `WorkerExecutor` / `WorkQueue` (using `noIsolation()`, `classLoaderIsolation()`, or `processIsolation()`) to keep the main Gradle daemon thread non-blocking. + +## 7. Namespace & Environment Hygiene +- **Namespace Internal Properties**: Prefix all internal Gradle properties, project extensions, and system properties with `flutter.internal.` (e.g., `flutter.internal.agpVersion`) to prevent collisions with customer app configurations. +- **Matrix Verification**: Verify compatibility against the full matrix of supported AGP versions (AGP 8.x through 9.x) and Gradle versions. From 9479bb7773d49ab1904fd4c751e5314f3720bdea Mon Sep 17 00:00:00 2001 From: Ben Konyi Date: Fri, 21 Aug 2026 19:56:37 +0000 Subject: [PATCH 10/46] [flutter_tools] Fix crash when migrating flow-style exclude lists in analysis_options.yaml (#191269) ## Description Avoids a crash in the `yaml_edit` package when migrating `analysis_options.yaml` where the `exclude` list is formatted in flow style (e.g. `[ ... ]`) and contains internal newlines and trailing commas. The `yaml_edit` package (version 2.2.4) has a bug when appending to such lists via `appendToList`, which results in invalid YAML generation and triggers a `_YamlAssertionError` (tracked upstream in https://github.com/dart-lang/tools/issues/2532). To work around this, `AnalysisOptionsMigration` checks if the `exclude` list's style is `CollectionStyle.FLOW`. If it is, it updates the entire list using `editor.update` instead of appending. This converts the list to block style (which is preferred in `analysis_options.yaml`) and avoids the crash. Invalid types within the list are preserved for consistency with block-style migration. ## Related Issues Fixes https://github.com/flutter/flutter/issues/191060 Upstream issue: https://github.com/dart-lang/tools/issues/2532 ## Tests - Added `migrates and merges excludes when list is in flow style with newlines` to `packages/flutter_tools/test/general.shard/migrations/analysis_options_migration_test.dart`. --- .../analysis_options_migration.dart | 9 ++++++ .../analysis_options_migration_test.dart | 30 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/packages/flutter_tools/lib/src/migrations/analysis_options_migration.dart b/packages/flutter_tools/lib/src/migrations/analysis_options_migration.dart index 2fb42b6ea89f8..53a8231ed0006 100644 --- a/packages/flutter_tools/lib/src/migrations/analysis_options_migration.dart +++ b/packages/flutter_tools/lib/src/migrations/analysis_options_migration.dart @@ -87,6 +87,15 @@ class AnalysisOptionsMigration extends ProjectMigrator { final exclude = analyzer['exclude'] as Object?; if (exclude is! YamlList) { editor.update(['analyzer', 'exclude'], missingExcludes); + } else if (exclude.style == CollectionStyle.FLOW) { + // Workaround for https://github.com/dart-lang/tools/issues/2532. + // Appending to a multiline flow-style list with a trailing comma crashes YamlEditor. + // Instead, rewrite the entire exclude list as a block list. + final newExcludes = [ + ...exclude, + ...missingExcludes.where((String item) => !exclude.contains(item)), + ]; + editor.update(['analyzer', 'exclude'], newExcludes); } else { for (final missingExclude in missingExcludes) { if (!exclude.contains(missingExclude)) { diff --git a/packages/flutter_tools/test/general.shard/migrations/analysis_options_migration_test.dart b/packages/flutter_tools/test/general.shard/migrations/analysis_options_migration_test.dart index f4977033e73af..ed90920c37190 100644 --- a/packages/flutter_tools/test/general.shard/migrations/analysis_options_migration_test.dart +++ b/packages/flutter_tools/test/general.shard/migrations/analysis_options_migration_test.dart @@ -247,6 +247,36 @@ analyzer: expect(migratedContents, contains('linux/**')); }); + testWithoutContext( + 'migrates and merges excludes when list is in flow style with newlines', + () async { + final _TestContext context = _createTestContext(); + const analysisOptionsContents = ''' +analyzer: + exclude: + [ + foo/**, + build/**, + ] +'''; + + context.analysisOptionsFile.writeAsStringSync(analysisOptionsContents); + + final migration = AnalysisOptionsMigration(context.mockProject, context.testLogger); + await migration.migrate(); + + final String migratedContents = context.analysisOptionsFile.readAsStringSync(); + expect(migratedContents, contains('foo/**')); + expect(migratedContents, contains('build/**')); + expect(migratedContents, contains('android/**')); + expect(migratedContents, contains('ios/**')); + expect(migratedContents, contains('web/**')); + expect(migratedContents, contains('windows/**')); + expect(migratedContents, contains('macos/**')); + expect(migratedContents, contains('linux/**')); + }, + ); + testWithoutContext('skipped if exclusions are inherited via relative include', () async { final _TestContext context = _createTestContext(); const analysisOptionsContents = ''' From d525d4d19eb1fb40888031668690ea494e2b86e4 Mon Sep 17 00:00:00 2001 From: Ben Konyi Date: Fri, 21 Aug 2026 20:03:21 +0000 Subject: [PATCH 11/46] Refactor `FlutterDevice.connect` and VM service discovery (#191221) ## Description Refactors `FlutterDevice.connect` in `packages/flutter_tools` to accept a single resolved `Uri` instead of a `Stream`. - Replaces `FlutterDevice.vmServiceUris` with `Future? vmServiceUri`. - Eliminates stream subscription, `_isListeningForVmServiceUri`, and stream state management from `resident_runner.dart`. - Moves single VM service candidate resolution into `VMServiceDiscoveryForAttach.firstValidUri` and `AttachCommand._discoverVmService`. - Updates `attach.dart`, `run_cold.dart`, `run_hot.dart`, and associated test suites. ## Tests - `packages/flutter_tools/test/commands.shard/hermetic/attach_test.dart` - `packages/flutter_tools/test/general.shard/cold_test.dart` - `packages/flutter_tools/test/general.shard/device_vm_service_discovery_for_attach_test.dart` - `packages/flutter_tools/test/general.shard/resident_runner_test.dart` - `packages/flutter_tools/test/general.shard/resident_web_runner_test.dart` - `packages/flutter_tools/test/general.shard/run_hot_test.dart` - `packages/flutter_tools/test/general.shard/proxied_devices/proxied_devices_test.dart` --- .../lib/src/commands/attach.dart | 28 +- ...evice_vm_service_discovery_for_attach.dart | 10 + .../lib/src/resident_runner.dart | 346 ++++++++---------- packages/flutter_tools/lib/src/run_cold.dart | 2 +- .../commands.shard/hermetic/attach_test.dart | 22 +- .../test/general.shard/cold_test.dart | 12 +- .../test/general.shard/hot_shared.dart | 11 +- .../resident_runner_helpers.dart | 34 +- .../general.shard/resident_runner_test.dart | 10 +- .../resident_web_runner_test.dart | 7 +- 10 files changed, 223 insertions(+), 259 deletions(-) diff --git a/packages/flutter_tools/lib/src/commands/attach.dart b/packages/flutter_tools/lib/src/commands/attach.dart index 8c0c11c106c3d..440e094bb0bed 100644 --- a/packages/flutter_tools/lib/src/commands/attach.dart +++ b/packages/flutter_tools/lib/src/commands/attach.dart @@ -350,7 +350,8 @@ known, it can be explicitly provided to attach via the command-line, e.g. } Future _discoverVmServiceAndCreateResidentRunner({required Device device}) async { - final Stream vmServiceUri = _discoverVmService(device: device); + final Future vmServiceUri = _discoverVmService(device: device); + vmServiceUri.ignore(); final BuildInfo buildInfo = await getBuildInfo(); @@ -362,7 +363,7 @@ known, it can be explicitly provided to attach via the command-line, e.g. userIdentifier: userIdentifier, platform: _platform, ); - flutterDevice.vmServiceUris = vmServiceUri; + flutterDevice.vmServiceUri = vmServiceUri; final flutterDevices = [flutterDevice]; final debuggingOptions = DebuggingOptions.enabled( buildInfo, @@ -397,7 +398,7 @@ known, it can be explicitly provided to attach via the command-line, e.g. ); } - Stream _discoverVmService({required Device device}) { + Future _discoverVmService({required Device device}) async { final bool usesIpv6 = ipv6!; final String ipv6Loopback = InternetAddress.loopbackIPv6.address; final String ipv4Loopback = InternetAddress.loopbackIPv4.address; @@ -405,14 +406,12 @@ known, it can be explicitly provided to attach via the command-line, e.g. final bool isWirelessIOSDevice = (device is IOSDevice) && device.isWirelesslyConnected; if (!isWirelessIOSDevice && (debugPort != null || debugUri != null)) { - return Stream.fromFuture( - buildVMServiceUri( - device, - debugUri?.host ?? hostname, - debugPort ?? debugUri!.port, - hostVmservicePort, - debugUri?.path, - ), + return buildVMServiceUri( + device, + debugUri?.host ?? hostname, + debugPort ?? debugUri!.port, + hostVmservicePort, + debugUri?.path, ); } @@ -457,8 +456,11 @@ known, it can be explicitly provided to attach via the command-line, e.g. warningColor: TerminalColor.cyan, ); - // Stop the timer once we receive the first uri. - return streamWithCallbackOnFirstItem(vmServiceDiscovery.uris, discoveryStatus.stop); + try { + return await vmServiceDiscovery.firstValidUri(); + } finally { + discoveryStatus.stop(); + } } bool _isIOSDevice(Device device) { diff --git a/packages/flutter_tools/lib/src/device_vm_service_discovery_for_attach.dart b/packages/flutter_tools/lib/src/device_vm_service_discovery_for_attach.dart index ae469043d03ea..ab0379abcee70 100644 --- a/packages/flutter_tools/lib/src/device_vm_service_discovery_for_attach.dart +++ b/packages/flutter_tools/lib/src/device_vm_service_discovery_for_attach.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'package:async/async.dart'; +import 'base/common.dart'; import 'base/logger.dart'; import 'device.dart'; import 'device_port_forwarder.dart'; @@ -23,6 +24,15 @@ abstract class VMServiceDiscoveryForAttach { /// Port forwarding is only attempted when this is invoked, for each VM /// Service URI in the stream. Stream get uris; + + /// Find the first URI discovered for attach. + Future firstValidUri() async { + final List candidateUris = await uris.take(1).toList(); + if (candidateUris.isEmpty) { + throwToolExit('Failed to find VM Service URL.'); + } + return candidateUris.first; + } } /// An implementation of [VMServiceDiscoveryForAttach] that uses log scanning diff --git a/packages/flutter_tools/lib/src/resident_runner.dart b/packages/flutter_tools/lib/src/resident_runner.dart index 12d366f9f066c..7a4ec7ac079d1 100644 --- a/packages/flutter_tools/lib/src/resident_runner.dart +++ b/packages/flutter_tools/lib/src/resident_runner.dart @@ -105,15 +105,14 @@ class FlutterDevice { final DevelopmentShaderCompiler developmentShaderCompiler; DevFSWriter? devFSWriter; - Stream? vmServiceUris; + Future? vmServiceUri; FlutterVmService? vmService; DevFS? devFS; ApplicationPackage? package; StreamSubscription? _loggingSubscription; - bool? _isListeningForVmServiceUri; - /// Whether the stream [vmServiceUris] is still open. - bool get isWaitingForVmService => _isListeningForVmServiceUri ?? false; + /// Whether this device is waiting for a VM Service URI. + bool get isWaitingForVmService => vmService == null && vmServiceUri != null; /// If the [reloadSources] parameter is not null the 'reloadSources' service /// will be registered. @@ -125,202 +124,161 @@ class FlutterDevice { /// This ensures that the reload process follows the normal orchestration of /// the Flutter Tools and not just the VM internal service. Future connect({ + required Uri vmServiceUri, ReloadSources? reloadSources, Restart? restart, CompileExpression? compileExpression, PrintStructuredErrorLogMethod? printStructuredErrorLogMethod, required DebuggingOptions debuggingOptions, - int? hostVmServicePort, - }) { - final completer = Completer(); - late StreamSubscription subscription; - var isWaitingForVm = false; - - subscription = vmServiceUris!.listen( - (Uri? vmServiceUri) async { - // FYI, this message is used as a sentinel in tests. - globals.printTrace('Connecting to service protocol: $vmServiceUri'); - isWaitingForVm = true; - var existingDds = false; - FlutterVmService? service; - if (debuggingOptions.enableDds) { - void handleError(Exception e, StackTrace st) { + }) async { + this.vmServiceUri ??= Future.value(vmServiceUri); + // FYI, this message is used as a sentinel in tests. + globals.printTrace('Connecting to service protocol: $vmServiceUri'); + var existingDds = false; + FlutterVmService? service; + if (debuggingOptions.enableDds) { + const kMaxAttempts = 3; + for (var attempts = 1; attempts <= kMaxAttempts; ++attempts) { + // First check if the VM service is actually listening on vmServiceUri as + // this may not be the case when scraping logcat for URIs. If this URI is + // from an old application instance, we shouldn't try and start DDS. + try { + service = await connectToVmService(vmServiceUri, logger: globals.logger); + await service.dispose(); + break; + } on vm_service.RPCError catch (e) { + if (!e.isConnectionDisposedException) { globals.printTrace('Fail to connect to service protocol: $vmServiceUri: $e'); - if (!completer.isCompleted) { - completer.completeError('failed to connect to $vmServiceUri $e', st); - } + rethrow; } - - const kMaxAttempts = 3; - for (var attempts = 1; attempts <= kMaxAttempts; ++attempts) { - void handleVmServiceCheckException(Exception e) { - globals.printTrace('Fail to connect to service protocol: $vmServiceUri: $e'); - if (!completer.isCompleted && !_isListeningForVmServiceUri!) { - completer.completeError('failed to connect to $vmServiceUri $e'); - } - } - - // First check if the VM service is actually listening on vmServiceUri as - // this may not be the case when scraping logcat for URIs. If this URI is - // from an old application instance, we shouldn't try and start DDS. - try { - service = await connectToVmService(vmServiceUri!, logger: globals.logger); - await service.dispose(); - break; - } on vm_service.RPCError catch (e, st) { - if (!e.isConnectionDisposedException) { - handleVmServiceCheckException(e); - return; - } - // It's possible (but unlikely) that two DDS instances can try and start at the same - // time (e.g., a "flutter run" is initiated while an existing "flutter attach" is - // waiting for a target to attach to). This can lead to the initial VM service connection - // failing for one of the processes when the VM service disconnects it after the other - // instance successfully invoked the "_yieldControlToDDS" RPC. - // - // To handle this, we retry connecting to the VM service, which should successfully - // be redirected to the DDS instance. - // - // See https://github.com/flutter/flutter/issues/169265 for details. - if (attempts == kMaxAttempts) { - globals.printTrace( - 'Failed to make initial connection to VM Service (attempt $attempts of $kMaxAttempts).', - ); - handleError(e, st); - return; - } - // Exponential backoff. - final int backoffPeriod = (1 << (attempts - 1)) * 100; - globals.printTrace( - 'Failed to make initial connection to VM Service (attempt $attempts of $kMaxAttempts). ' - 'Retrying in ${backoffPeriod}ms...', - ); - await Future.delayed(Duration(milliseconds: backoffPeriod)); - } on Exception catch (e) { - handleVmServiceCheckException(e); - return; - } - } - - for (var attempts = 1; attempts <= kMaxAttempts; ++attempts) { - // This try block is meant to catch errors that occur during DDS startup - // (e.g., failure to bind to a port, failure to connect to the VM service, - // attaching to a VM service with existing clients, etc.). - try { - await device!.dds.startDartDevelopmentServiceFromDebuggingOptions( - vmServiceUri!, - debuggingOptions: debuggingOptions, - appName: - 'Kind: Flutter - Device: ${device!.displayName} - ' - 'Package: ${FlutterProject.current().manifest.appName}', - ); - break; - } on DartDevelopmentServiceException catch (e, st) { - if (e.errorCode == DartDevelopmentServiceException.existingDdsInstanceError) { - existingDds = true; - break; - } - // It's possible (but unlikely) that two DDS instances can try and start at the same - // time (e.g., a "flutter run" is initiated while an existing "flutter attach" is - // waiting for a target to attach to). This leads to DDS failing to initialize for - // one of the processes when the VM service disconnects it after the other instance - // successfully invoked the "_yieldControlToDDS" RPC. - // - // To handle this, we retry to start DDS after a short delay, which should result in - // an existingDdsInstanceError if the failure to start was due to a startup race. - // - // See https://github.com/flutter/flutter/issues/169265 for details. - if (attempts == kMaxAttempts) { - globals.printTrace('Failed to start DDS (attempt $attempts of $kMaxAttempts).'); - handleError(e, st); - return; - } - // Exponential backoff. - final int backoffPeriod = (1 << (attempts - 1)) * 100; - globals.printTrace( - 'Failed to start DDS (attempt $attempts of $kMaxAttempts). ' - 'Retrying in ${backoffPeriod}ms...', - ); - await Future.delayed(Duration(milliseconds: backoffPeriod)); - } on ToolExit { - rethrow; - } on Exception catch (e, st) { - handleError(e, st); - return; - } + // It's possible (but unlikely) that two DDS instances can try and start at the same + // time (e.g., a "flutter run" is initiated while an existing "flutter attach" is + // waiting for a target to attach to). This can lead to the initial VM service connection + // failing for one of the processes when the VM service disconnects it after the other + // instance successfully invoked the "_yieldControlToDDS" RPC. + // + // To handle this, we retry connecting to the VM service, which should successfully + // be redirected to the DDS instance. + // + // See https://github.com/flutter/flutter/issues/169265 for details. + if (attempts == kMaxAttempts) { + globals.printTrace( + 'Failed to make initial connection to VM Service (attempt $attempts of $kMaxAttempts).', + ); + globals.printTrace('Fail to connect to service protocol: $vmServiceUri: $e'); + throw Exception('failed to connect to $vmServiceUri $e'); } + // Exponential backoff. + final int backoffPeriod = (1 << (attempts - 1)) * 100; + globals.printTrace( + 'Failed to make initial connection to VM Service (attempt $attempts of $kMaxAttempts). ' + 'Retrying in ${backoffPeriod}ms...', + ); + await Future.delayed(Duration(milliseconds: backoffPeriod)); + } on Exception catch (e) { + globals.printTrace('Fail to connect to service protocol: $vmServiceUri: $e'); + rethrow; } - // This second try block handles cases where the VM service connection goes down - // before flutter_tools connects to DDS. The DDS `done` future completes when DDS - // shuts down, including after an error. If `done` completes before `connectToVmService`, - // something went wrong that caused DDS to shutdown early. + } + + for (var attempts = 1; attempts <= kMaxAttempts; ++attempts) { + // This try block is meant to catch errors that occur during DDS startup + // (e.g., failure to bind to a port, failure to connect to the VM service, + // attaching to a VM service with existing clients, etc.). try { - service = - await Future.any(>[ - connectToVmService( - debuggingOptions.enableDds - ? (device!.dds.uri ?? vmServiceUri!) - : vmServiceUri!, - reloadSources: reloadSources, - restart: restart, - compileExpression: compileExpression, - flutterProject: FlutterProject.current(), - printStructuredErrorLogMethod: printStructuredErrorLogMethod, - device: device, - logger: globals.logger, - ), - if (!existingDds) - device!.dds.done.whenComplete( - () => throw Exception('DDS shut down too early'), - ), - ]) - as FlutterVmService?; - } on Exception catch (exception) { - globals.printTrace('Fail to connect to service protocol: $vmServiceUri: $exception'); - if (!completer.isCompleted && !_isListeningForVmServiceUri!) { - completer.completeError('failed to connect to $vmServiceUri $exception'); + await device!.dds.startDartDevelopmentServiceFromDebuggingOptions( + vmServiceUri, + debuggingOptions: debuggingOptions, + appName: + 'Kind: Flutter - Device: ${device!.displayName} - ' + 'Package: ${FlutterProject.current().manifest.appName}', + ); + break; + } on DartDevelopmentServiceException catch (e) { + if (e.errorCode == DartDevelopmentServiceException.existingDdsInstanceError) { + existingDds = true; + break; } - return; - } - if (completer.isCompleted) { - return; - } - globals.printTrace('Successfully connected to service protocol: $vmServiceUri'); - - vmService = service; - if (debuggingOptions.enableDds && !existingDds) { - // Don't await this as service extensions won't return if the target - // isolate is paused on start. - unawaited(device!.dds.invokeServiceExtensions(this)); - } - if ((existingDds || !debuggingOptions.enableDds) && - debuggingOptions.devToolsServerAddress != null) { - // Don't await this as service extensions won't return if the target - // isolate is paused on start. - unawaited( - device!.dds.maybeCallDevToolsUriServiceExtension( - device: this, - uri: debuggingOptions.devToolsServerAddress, - ), + // It's possible (but unlikely) that two DDS instances can try and start at the same + // time (e.g., a "flutter run" is initiated while an existing "flutter attach" is + // waiting for a target to attach to). This leads to DDS failing to initialize for + // one of the processes when the VM service disconnects it after the other instance + // successfully invoked the "_yieldControlToDDS" RPC. + // + // To handle this, we retry to start DDS after a short delay, which should result in + // an existingDdsInstanceError if the failure to start was due to a startup race. + // + // See https://github.com/flutter/flutter/issues/169265 for details. + if (attempts == kMaxAttempts) { + globals.printTrace('Failed to start DDS (attempt $attempts of $kMaxAttempts).'); + globals.printTrace('Fail to connect to service protocol: $vmServiceUri: $e'); + throw Exception('failed to connect to $vmServiceUri $e'); + } + // Exponential backoff. + final int backoffPeriod = (1 << (attempts - 1)) * 100; + globals.printTrace( + 'Failed to start DDS (attempt $attempts of $kMaxAttempts). ' + 'Retrying in ${backoffPeriod}ms...', ); + await Future.delayed(Duration(milliseconds: backoffPeriod)); + } on ToolExit { + rethrow; + } on Exception catch (e) { + globals.printTrace('Fail to connect to service protocol: $vmServiceUri: $e'); + throw Exception('failed to connect to $vmServiceUri $e'); } + } + } + // This second try block handles cases where the VM service connection goes down + // before flutter_tools connects to DDS. The DDS `done` future completes when DDS + // shuts down, including after an error. If `done` completes before `connectToVmService`, + // something went wrong that caused DDS to shutdown early. + try { + service = + await Future.any(>[ + connectToVmService( + debuggingOptions.enableDds + ? (device!.dds.uri ?? vmServiceUri) + : vmServiceUri, + reloadSources: reloadSources, + restart: restart, + compileExpression: compileExpression, + flutterProject: FlutterProject.current(), + printStructuredErrorLogMethod: printStructuredErrorLogMethod, + device: device, + logger: globals.logger, + ), + if (!existingDds) + device!.dds.done.whenComplete( + () => throw Exception('DDS shut down too early'), + ), + ]) + as FlutterVmService?; + } on Exception catch (exception) { + globals.printTrace('Fail to connect to service protocol: $vmServiceUri: $exception'); + rethrow; + } + globals.printTrace('Successfully connected to service protocol: $vmServiceUri'); - await (await device!.getLogReader(app: package)).provideVmService(vmService!); - completer.complete(); - await subscription.cancel(); - }, - onError: (dynamic error) { - globals.printTrace('Fail to handle VM Service URI: $error'); - }, - onDone: () { - _isListeningForVmServiceUri = false; - if (!completer.isCompleted && !isWaitingForVm) { - completer.completeError(Exception('connection to device ended too early')); - } - }, - ); - _isListeningForVmServiceUri = true; - return completer.future; + vmService = service; + if (debuggingOptions.enableDds && !existingDds) { + // Don't await this as service extensions won't return if the target + // isolate is paused on start. + unawaited(device!.dds.invokeServiceExtensions(this)); + } + if ((existingDds || !debuggingOptions.enableDds) && + debuggingOptions.devToolsServerAddress != null) { + // Don't await this as service extensions won't return if the target + // isolate is paused on start. + unawaited( + device!.dds.maybeCallDevToolsUriServiceExtension( + device: this, + uri: debuggingOptions.devToolsServerAddress, + ), + ); + } + + await (await device!.getLogReader(app: package)).provideVmService(vmService!); } Future exitApps({ @@ -425,9 +383,7 @@ class FlutterDevice { return 2; } if (result.hasVmService) { - vmServiceUris = Stream.value(result.vmServiceUri).asBroadcastStream(); - } else { - vmServiceUris = const Stream.empty().asBroadcastStream(); + vmServiceUri = Future.value(result.vmServiceUri!); } return 0; } @@ -481,9 +437,7 @@ class FlutterDevice { return 2; } if (result.hasVmService) { - vmServiceUris = Stream.value(result.vmServiceUri).asBroadcastStream(); - } else { - vmServiceUris = const Stream.empty().asBroadcastStream(); + vmServiceUri = Future.value(result.vmServiceUri!); } return 0; } @@ -1049,7 +1003,7 @@ abstract class ResidentRunner extends ResidentHandlers { @override bool hotMode; - /// Returns true if every device is streaming vmService URIs. + /// Returns true if every device is waiting on a VM Service URI. bool get isWaitingForVmService { return flutterDevices.every((FlutterDevice? device) { return device!.isWaitingForVmService; @@ -1294,12 +1248,16 @@ abstract class ResidentRunner extends ResidentHandlers { continue; } try { + if (device.vmServiceUri == null) { + throw Exception('VM Service URI info not available.'); + } + final Uri vmServiceUri = await device.vmServiceUri!; await device.connect( + vmServiceUri: vmServiceUri, debuggingOptions: debuggingOptions, reloadSources: reloadSources, restart: restart, compileExpression: compileExpression, - hostVmServicePort: debuggingOptions.hostVmServicePort, printStructuredErrorLogMethod: printStructuredErrorLog, ); } catch (error) { diff --git a/packages/flutter_tools/lib/src/run_cold.dart b/packages/flutter_tools/lib/src/run_cold.dart index 88d41f080debd..864012508b7b0 100644 --- a/packages/flutter_tools/lib/src/run_cold.dart +++ b/packages/flutter_tools/lib/src/run_cold.dart @@ -80,7 +80,7 @@ class ColdRunner extends ResidentRunner { } final FlutterDevice flutterDevice = flutterDevices.first; - if (flutterDevice.vmServiceUris != null) { + if (flutterDevice.vmServiceUri != null) { final FlutterVmService? vmService = flutterDevice.vmService; final DartDevelopmentService dds = flutterDevice.device!.dds; // For now, only support one debugger connection. diff --git a/packages/flutter_tools/test/commands.shard/hermetic/attach_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/attach_test.dart index 1cc3967726777..d1256e42ca780 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/attach_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/attach_test.dart @@ -390,7 +390,7 @@ void main() { // Listen to the URI before checking port forwarder. Port forwarding // is done as a side effect when generating the uri. final FlutterDevice flutterDevice = hotRunnerFactory.devices.first; - final Uri? vmServiceUri = await flutterDevice.vmServiceUris?.first; + final Uri? vmServiceUri = await flutterDevice.vmServiceUri; expect(vmServiceUri.toString(), 'http://127.0.0.1:$hostPort/xyz/'); expect(portForwarder.forwardedPorts, >[ @@ -470,7 +470,7 @@ void main() { // Listen to the URI before checking port forwarder. Port forwarding // is done as a side effect when generating the uri. final FlutterDevice flutterDevice = hotRunnerFactory.devices.first; - final Uri? vmServiceUri = await flutterDevice.vmServiceUris?.first; + final Uri? vmServiceUri = await flutterDevice.vmServiceUri; expect(vmServiceUri.toString(), 'http://111.111.111.111:123/xyz/'); expect(portForwarder.forwardedPorts, isEmpty); @@ -555,7 +555,7 @@ void main() { // Listen to the URI before checking port forwarder. Port forwarding // is done as a side effect when generating the uri. final FlutterDevice flutterDevice = hotRunnerFactory.devices.first; - final Uri? vmServiceUri = await flutterDevice.vmServiceUris?.first; + final Uri? vmServiceUri = await flutterDevice.vmServiceUri; expect(vmServiceUri.toString(), 'http://111.111.111.111:123/xyz/'); expect(portForwarder.forwardedPorts, isEmpty); @@ -653,7 +653,7 @@ void main() { // Listen to the URI before checking port forwarder. Port forwarding // is done as a side effect when generating the uri. final FlutterDevice flutterDevice = hotRunnerFactory.devices.first; - final Uri? vmServiceUri = await flutterDevice.vmServiceUris?.first; + final Uri? vmServiceUri = await flutterDevice.vmServiceUri; expect(vmServiceUri.toString(), 'http://111.111.111.111:123/xyz/'); expect(portForwarder.forwardedPorts, isEmpty); @@ -1498,10 +1498,11 @@ void main() { testUsingContext( 'does not try to attach to a new target when the original application disappears', () async { - // Regression test for https://github.com/flutter/flutter/issues/156692. + final fakeLogReader = FakeDeviceLogReader(); + fakeLogReader.addLine('The Dart VM service is listening on http://127.0.0.1:1234'); final device = FakeAndroidDevice(id: '1') ..portForwarder = const NoOpDevicePortForwarder() - ..onGetLogReader = () => NoOpDeviceLogReader('test'); + ..onGetLogReader = () => fakeLogReader; final hotRunner = FakeHotRunner(); final hotRunnerFactory = FakeHotRunnerFactory()..hotRunner = hotRunner; var attachCount = 0; @@ -1511,12 +1512,9 @@ void main() { Completer? appStartedCompleter, bool enableDevTools, ) async { - // Mimic listening to the `vmServiceUris` stream for the FlutterDevice we're - // trying to attach to. Without the fix for - // https://github.com/flutter/flutter/issues/156692, calling `HotRunner.attach` - // multiple times would result in this stream being listened to again, causing a - // `StateError` to be thrown. - await hotRunner.flutterDevices.first.vmServiceUris!.toList(); + // Mimic awaiting the `vmServiceUri` future for the FlutterDevice we're + // trying to attach to. + await hotRunner.flutterDevices.first.vmServiceUri; attachCount++; return 0; }; diff --git a/packages/flutter_tools/test/general.shard/cold_test.dart b/packages/flutter_tools/test/general.shard/cold_test.dart index b64e4c1a55e69..12133a0d6168d 100644 --- a/packages/flutter_tools/test/general.shard/cold_test.dart +++ b/packages/flutter_tools/test/general.shard/cold_test.dart @@ -12,7 +12,6 @@ import 'package:flutter_tools/src/build_system/tools/shader_compiler.dart'; import 'package:flutter_tools/src/compile.dart'; import 'package:flutter_tools/src/devfs.dart'; import 'package:flutter_tools/src/device.dart'; -import 'package:flutter_tools/src/project.dart'; import 'package:flutter_tools/src/resident_runner.dart'; import 'package:flutter_tools/src/run_cold.dart'; import 'package:flutter_tools/src/tracing.dart'; @@ -39,6 +38,7 @@ void main() { 'Connection closed before full header was received, ' 'uri = http://127.0.0.1:63394/5ZmLv8A59xY=/ws', ), + vmServiceUri: Future.value(Uri.parse('http://127.0.0.1:63394/5ZmLv8A59xY=/ws')), ), ]; @@ -166,7 +166,7 @@ class FakeFlutterDevice extends Fake implements FlutterDevice { FakeFlutterDevice(this.device); @override - Stream get vmServiceUris => const Stream.empty(); + Future? get vmServiceUri => null; @override final Device device; @@ -258,26 +258,28 @@ class TestFlutterDevice extends FlutterDevice { required Device device, required this.exception, required ResidentCompiler generator, + Future? vmServiceUri, }) : super( targetPlatform: .unsupported, device, buildInfo: BuildInfo.debug, generator: generator, developmentShaderCompiler: const FakeShaderCompiler(), - ); + ) { + this.vmServiceUri = vmServiceUri; + } /// The exception to throw when the connect method is called. final Exception exception; @override Future connect({ + required Uri vmServiceUri, ReloadSources? reloadSources, Restart? restart, CompileExpression? compileExpression, - FlutterProject? flutterProject, PrintStructuredErrorLogMethod? printStructuredErrorLogMethod, required DebuggingOptions debuggingOptions, - int? hostVmServicePort, }) async { throw exception; } diff --git a/packages/flutter_tools/test/general.shard/hot_shared.dart b/packages/flutter_tools/test/general.shard/hot_shared.dart index 76a84984fbec7..e679eaeb4aa8f 100644 --- a/packages/flutter_tools/test/general.shard/hot_shared.dart +++ b/packages/flutter_tools/test/general.shard/hot_shared.dart @@ -10,7 +10,6 @@ import 'package:flutter_tools/src/build_system/tools/shader_compiler.dart'; import 'package:flutter_tools/src/compile.dart'; import 'package:flutter_tools/src/devfs.dart'; import 'package:flutter_tools/src/device.dart'; -import 'package:flutter_tools/src/project.dart'; import 'package:flutter_tools/src/resident_runner.dart'; import 'package:flutter_tools/src/run_hot.dart'; import 'package:flutter_tools/src/vmservice.dart'; @@ -146,28 +145,28 @@ class TestFlutterDevice extends FlutterDevice { required Device device, required this.exception, required ResidentCompiler generator, + Future? vmServiceUri, }) : super( device, targetPlatform: TargetPlatform.unsupported, buildInfo: BuildInfo.debug, generator: generator, developmentShaderCompiler: const FakeShaderCompiler(), - ); + ) { + this.vmServiceUri = vmServiceUri; + } /// The exception to throw when the connect method is called. final Exception exception; @override Future connect({ + required Uri vmServiceUri, ReloadSources? reloadSources, Restart? restart, CompileExpression? compileExpression, - FlutterProject? flutterProject, PrintStructuredErrorLogMethod? printStructuredErrorLogMethod, required DebuggingOptions debuggingOptions, - int? hostVmServicePort, - bool? ipv6 = false, - bool enableDevTools = false, }) async { throw exception; } diff --git a/packages/flutter_tools/test/general.shard/resident_runner_helpers.dart b/packages/flutter_tools/test/general.shard/resident_runner_helpers.dart index 21aa7c7090044..f3ecb09fca719 100644 --- a/packages/flutter_tools/test/general.shard/resident_runner_helpers.dart +++ b/packages/flutter_tools/test/general.shard/resident_runner_helpers.dart @@ -14,7 +14,6 @@ import 'package:flutter_tools/src/compile.dart'; import 'package:flutter_tools/src/devfs.dart'; import 'package:flutter_tools/src/device.dart'; import 'package:flutter_tools/src/device_port_forwarder.dart'; -import 'package:flutter_tools/src/project.dart'; import 'package:flutter_tools/src/resident_runner.dart'; import 'package:flutter_tools/src/run_cold.dart'; import 'package:flutter_tools/src/run_hot.dart'; @@ -173,19 +172,15 @@ class FakeDartDevelopmentServiceException implements DartDevelopmentServiceExcep } class TestFlutterDevice extends FlutterDevice { - TestFlutterDevice(super.device, {Stream? vmServiceUris}) - : _vmServiceUris = vmServiceUris, - super( + TestFlutterDevice(super.device, {Future? vmServiceUri}) + : super( generator: FakeResidentCompiler(), targetPlatform: .unsupported, buildInfo: BuildInfo.debug, developmentShaderCompiler: const FakeShaderCompiler(), - ); - - final Stream? _vmServiceUris; - - @override - Stream get vmServiceUris => _vmServiceUris!; + ) { + this.vmServiceUri = vmServiceUri; + } } class ThrowingForwardingFileSystem extends ForwardingFileSystem { @@ -223,7 +218,7 @@ class FakeFlutterDevice extends Fake implements FlutterDevice { TargetPlatform targetPlatform = TargetPlatform.android; @override - Stream get vmServiceUris => Stream.value(testUri); + Future? get vmServiceUri => testUri != null ? Future.value(testUri!) : null; @override FlutterVmService? get vmService => vmServiceHost?.call()?.vmService; @@ -262,14 +257,12 @@ class FakeFlutterDevice extends Fake implements FlutterDevice { @override Future connect({ + required Uri vmServiceUri, ReloadSources? reloadSources, Restart? restart, CompileExpression? compileExpression, - FlutterProject? flutterProject, PrintStructuredErrorLogMethod? printStructuredErrorLogMethod, required DebuggingOptions debuggingOptions, - int? hostVmServicePort, - bool? ipv6 = false, }) async { if (connectError != null) { throw connectError!; @@ -308,24 +301,25 @@ class FakeDelegateFlutterDevice extends FlutterDevice { super.device, BuildInfo buildInfo, ResidentCompiler residentCompiler, - this.fakeDevFS, - ) : super( + this.fakeDevFS, { + Future? vmServiceUri, + }) : super( targetPlatform: .unsupported, buildInfo: buildInfo, generator: residentCompiler, developmentShaderCompiler: const FakeShaderCompiler(), - ); + ) { + this.vmServiceUri = vmServiceUri ?? Future.value(testUri); + } @override Future connect({ + required Uri vmServiceUri, ReloadSources? reloadSources, Restart? restart, CompileExpression? compileExpression, - FlutterProject? flutterProject, PrintStructuredErrorLogMethod? printStructuredErrorLogMethod, required DebuggingOptions debuggingOptions, - int? hostVmServicePort, - bool? ipv6 = false, }) async {} final DevFS fakeDevFS; diff --git a/packages/flutter_tools/test/general.shard/resident_runner_test.dart b/packages/flutter_tools/test/general.shard/resident_runner_test.dart index 1564f472becb1..9a26631327d44 100644 --- a/packages/flutter_tools/test/general.shard/resident_runner_test.dart +++ b/packages/flutter_tools/test/general.shard/resident_runner_test.dart @@ -1935,12 +1935,15 @@ flutter: ddsUri: Uri.parse('http://localhost/existingDdsInField'), ); }; - final flutterDevice = TestFlutterDevice(device, vmServiceUris: Stream.value(testUri)); + final flutterDevice = TestFlutterDevice(device, vmServiceUri: Future.value(testUri)); final done = Completer(); unawaited( runZonedGuarded( () => flutterDevice - .connect(debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug)) + .connect( + vmServiceUri: testUri, + debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug), + ) .then((_) => done.complete()), (_, _) => done.complete(), ), @@ -2009,8 +2012,9 @@ flutter: done.complete(); return FakeDartDevelopmentServiceLauncher(uri: remoteVmServiceUri); }; - final flutterDevice = TestFlutterDevice(device, vmServiceUris: Stream.value(testUri)); + final flutterDevice = TestFlutterDevice(device, vmServiceUri: Future.value(testUri)); await flutterDevice.connect( + vmServiceUri: testUri, debuggingOptions: DebuggingOptions.enabled( BuildInfo.debug, disableServiceAuthCodes: true, diff --git a/packages/flutter_tools/test/general.shard/resident_web_runner_test.dart b/packages/flutter_tools/test/general.shard/resident_web_runner_test.dart index 5cada0e7f1779..12e4ff0110c78 100644 --- a/packages/flutter_tools/test/general.shard/resident_web_runner_test.dart +++ b/packages/flutter_tools/test/general.shard/resident_web_runner_test.dart @@ -2649,7 +2649,7 @@ class FakeFlutterDevice extends Fake implements FlutterDevice { ResidentCompiler? generator; @override - Stream get vmServiceUris => Stream.value(testUri); + Future? get vmServiceUri => testUri != null ? Future.value(testUri!) : null; @override DevelopmentShaderCompiler get developmentShaderCompiler => const FakeShaderCompiler(); @@ -2681,15 +2681,12 @@ class FakeFlutterDevice extends Fake implements FlutterDevice { @override Future connect({ + required Uri vmServiceUri, ReloadSources? reloadSources, Restart? restart, CompileExpression? compileExpression, - FlutterProject? flutterProject, PrintStructuredErrorLogMethod? printStructuredErrorLogMethod, required DebuggingOptions debuggingOptions, - int? hostVmServicePort, - bool? ipv6 = false, - bool enableDevTools = false, }) async {} @override From 9df0f58d84d1f3614880d5e471dea48e6f152c14 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Fri, 21 Aug 2026 21:26:24 +0000 Subject: [PATCH 12/46] Roll Skia from f6900c5b8439 to 0c37868737fa (2 revisions) (#191504) https://skia.googlesource.com/skia.git/+log/f6900c5b8439..0c37868737fa 2026-08-21 recipe-mega-autoroller@chops-service-accounts.iam.gserviceaccount.com Roll recipe dependencies (trivial). 2026-08-21 thomsmit@google.com [graphite] Enable SkSLBench for Graphite If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/skia-flutter-autoroll Please CC awolff@google.com,kjlubick@google.com,robertphillips@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Skia: https://bugs.chromium.org/p/skia/issues/entry To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPS b/DEPS index 2368e2b5177fb..ebe7ed61d4cfd 100644 --- a/DEPS +++ b/DEPS @@ -15,7 +15,7 @@ vars = { 'skia_git': 'https://skia.googlesource.com', 'llvm_git': 'https://llvm.googlesource.com', 'dart_ai_rev': '9c96bfe5f091c9451eff5b59c9bffeb2e806b875', - 'skia_revision': 'f6900c5b8439132de9ad98b56ea67a65430a7dbc', + 'skia_revision': '0c37868737fa11cc6ee2858eeb9f0f0637f7b2d3', # Do not download the Emscripten SDK by default. # This prevents us from downloading the Emscripten toolchain for builds From 7c0cceaea984294d4845b32fddd5b2912cf0594e Mon Sep 17 00:00:00 2001 From: Ben Konyi Date: Fri, 21 Aug 2026 21:37:08 +0000 Subject: [PATCH 13/46] [flutter_tools] Restrict WebAssetServer source resolution to source map extensions (#191501) ## Description Restricts `_resolveDartFile` in `WebAssetServer` to only resolve files with source map extensions (`.dart`, `.map`) from the project root, package, and SDK paths, aligning behavior with `ReleaseAssetServer`. Previously, `WebAssetServer._resolveDartFile` attempted to resolve any relative path directly against `fileSystem.currentDirectory`, which caused raw project files (such as `/assets/...`) to be served directly with HTTP 200 when requested by debug web runtime asset loaders, masking invalid asset key references that failed on release web and native platforms. ## Issues Fixed Fixes #84346 ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [AI contribution guidelines] and understand my responsibilities, or I am not using AI tools. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [AI contribution guidelines]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --- .../lib/src/isolated/web_asset_server.dart | 104 +++++++------ .../web/web_asset_server_test.dart | 138 ++++++++++++++++++ 2 files changed, 194 insertions(+), 48 deletions(-) diff --git a/packages/flutter_tools/lib/src/isolated/web_asset_server.dart b/packages/flutter_tools/lib/src/isolated/web_asset_server.dart index 640925b965f51..b6f1ee676897d 100644 --- a/packages/flutter_tools/lib/src/isolated/web_asset_server.dart +++ b/packages/flutter_tools/lib/src/isolated/web_asset_server.dart @@ -526,7 +526,7 @@ class WebAssetServer implements AssetReader { // Try and resolve the path relative to the built asset directory. if (!file.existsSync()) { final Uri potential = fileSystem - .directory(getAssetBuildDirectory()) + .directory(getAssetBuildDirectory(null, fileSystem)) .uri .resolve(requestPath.replaceFirst('assets/', '')); file = fileSystem.file(potential); @@ -692,7 +692,11 @@ _flutter.buildConfig = ${jsonEncode(buildConfig)}; ); } - // Attempt to resolve `path` to a dart file. + /// File extensions that may legitimately be requested from the project and + /// Flutter SDK roots for source-map resolution. + static const _sourceMapExtensions = {'.dart', '.map'}; + + /// Attempts to resolve [path] to a dart file. File _resolveDartFile(String path) { // Return the actual file objects so that local engine changes are automatically picked up. switch (path) { @@ -700,59 +704,63 @@ _flutter.buildConfig = ${jsonEncode(buildConfig)}; return _resolveDartSdkJsFile; case 'dart_sdk.js.map': return _resolveDartSdkJsMapFile; - } - // This is the special generated entrypoint. - if (path == 'web_entrypoint.dart') { - return entrypointCacheDirectory.childFile('web_entrypoint.dart'); - } - - // If this is a dart file, it must be on the local file system and is - // likely coming from a source map request. The tool doesn't currently - // consider the case of Dart files as assets. - final File dartFile = fileSystem.file(fileSystem.currentDirectory.uri.resolve(path)); - if (dartFile.existsSync()) { - return dartFile; - } + case 'web_entrypoint.dart': + return entrypointCacheDirectory.childFile('web_entrypoint.dart'); + } + + final String extension = fileSystem.path.extension(path); + if (_sourceMapExtensions.contains(extension)) { + // If this is a dart file, it must be on the local file system and is + // likely coming from a source map request. The tool doesn't currently + // consider the case of Dart files as assets. + final File dartFile = fileSystem.file(fileSystem.currentDirectory.uri.resolve(path)); + if (dartFile.existsSync()) { + return dartFile; + } - final List segments = path.split('/'); - if (segments.first.isEmpty) { - segments.removeAt(0); - } + final List segments = path.split('/'); + if (segments.first.isEmpty) { + segments.removeAt(0); + } - // The file might have been a package file which is signaled by a - // `/packages//` request. - if (segments.first == 'packages') { - final Uri? filePath = _packages.resolve( - Uri(scheme: 'package', pathSegments: segments.skip(1)), - ); - if (filePath != null) { - final File packageFile = fileSystem.file(filePath); - if (packageFile.existsSync()) { - return packageFile; + // The file might have been a package file which is signaled by a + // `/packages//` request. + if (segments.first == 'packages') { + final Uri? filePath = _packages.resolve( + Uri(scheme: 'package', pathSegments: segments.skip(1)), + ); + if (filePath != null) { + final File packageFile = fileSystem.file(filePath); + if (packageFile.existsSync()) { + return packageFile; + } } } - } - // Otherwise it must be a Dart SDK source or a Flutter Web SDK source. - final Directory dartSdkParent = fileSystem - .directory( - globals.artifacts!.getArtifactPath( - Artifact.engineDartSdkPath, - platform: TargetPlatform.web_javascript, - ), - ) - .parent; - final File dartSdkFile = fileSystem.file(dartSdkParent.uri.resolve(path)); - if (dartSdkFile.existsSync()) { - return dartSdkFile; - } + // Otherwise it must be a Dart SDK source or a Flutter Web SDK source. + final Directory dartSdkParent = fileSystem + .directory( + globals.artifacts!.getArtifactPath( + Artifact.engineDartSdkPath, + platform: TargetPlatform.web_javascript, + ), + ) + .parent; + final File dartSdkFile = fileSystem.file(dartSdkParent.uri.resolve(path)); + if (dartSdkFile.existsSync()) { + return dartSdkFile; + } - final Directory flutterWebSdk = fileSystem.directory( - globals.artifacts!.getHostArtifact(HostArtifact.flutterWebSdk), - ); - final File webSdkFile = fileSystem.file(flutterWebSdk.uri.resolve(path)); + final Directory flutterWebSdk = fileSystem.directory( + globals.artifacts!.getHostArtifact(HostArtifact.flutterWebSdk), + ); + final File webSdkFile = fileSystem.file(flutterWebSdk.uri.resolve(path)); + if (webSdkFile.existsSync()) { + return webSdkFile; + } + } - return webSdkFile; + return fileSystem.currentDirectory.childFile('.non_existent_file'); } File get _resolveDartSdkJsFile { diff --git a/packages/flutter_tools/test/general.shard/web/web_asset_server_test.dart b/packages/flutter_tools/test/general.shard/web/web_asset_server_test.dart index 8c3d968e75292..5335bb29ac0aa 100644 --- a/packages/flutter_tools/test/general.shard/web/web_asset_server_test.dart +++ b/packages/flutter_tools/test/general.shard/web/web_asset_server_test.dart @@ -4,6 +4,7 @@ import 'package:dwds/dwds.dart'; import 'package:file/memory.dart'; +import 'package:flutter_tools/src/artifacts.dart'; import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/base/io.dart'; import 'package:flutter_tools/src/base/logger.dart'; @@ -17,6 +18,7 @@ import 'package:flutter_tools/src/web/web_constants.dart'; import 'package:shelf/shelf.dart'; import '../../src/common.dart'; +import '../../src/context.dart'; const kTransparentImage = [ 0x89, @@ -509,5 +511,141 @@ void main() { expect(server.basePath, isEmpty); }); + + testUsingContext( + 'serves assets with exact key and does not serve non-source project files as assets', + () async { + final WebAssetServer server = await WebAssetServer.start( + null, + null, + false, + false, + false, + BuildInfo.debug, + false, + const DartDevelopmentServiceConfiguration(enable: false), + Uri.base, + null, + crossOriginIsolation: false, + webDevServerConfig: const WebDevServerConfig(host: 'localhost'), + webRenderer: WebRendererMode.canvaskit, + isWasm: false, + useLocalCanvasKit: false, + testMode: true, + fileSystem: fileSystem, + logger: BufferLogger.test(), + platform: platform, + ); + + // Project source file exists in assets/ directory. + fileSystem.file('assets/my_asset.txt') + ..createSync(recursive: true) + ..writeAsStringSync('project file'); + + // Built asset exists in build/flutter_assets/assets/my_asset.txt. + fileSystem.file('build/flutter_assets/assets/my_asset.txt') + ..createSync(recursive: true) + ..writeAsStringSync('built asset'); + + // Correct key 'assets/my_asset.txt' (URL /assets/assets/my_asset.txt) succeeds. + final Response validResponse = await server.handleRequest( + Request('GET', Uri.parse('http://localhost:8080/assets/assets/my_asset.txt')), + ); + expect(validResponse.statusCode, HttpStatus.ok); + expect(await validResponse.readAsString(), 'built asset'); + + // Incorrect key 'my_asset.txt' (URL /assets/my_asset.txt) must return 404. + final Response invalidResponse = await server.handleRequest( + Request('GET', Uri.parse('http://localhost:8080/assets/my_asset.txt')), + ); + expect(invalidResponse.statusCode, HttpStatus.notFound); + + // Legitimate source files (e.g. lib/main.dart) are served for debugging. + fileSystem.file('lib/main.dart').writeAsStringSync('void main() {}'); + final Response dartSourceResponse = await server.handleRequest( + Request('GET', Uri.parse('http://localhost:8080/lib/main.dart')), + ); + expect(dartSourceResponse.statusCode, HttpStatus.ok); + expect(await dartSourceResponse.readAsString(), 'void main() {}'); + }, + overrides: { + Artifacts: () => Artifacts.test(), + FileSystem: () => fileSystem, + ProcessManager: () => FakeProcessManager.any(), + }, + ); + + group('on Windows', () { + late FileSystem windowsFileSystem; + late Platform windowsPlatform; + + setUp(() { + windowsFileSystem = MemoryFileSystem.test(style: FileSystemStyle.windows); + windowsPlatform = FakePlatform( + operatingSystem: 'windows', + environment: {'HOME': r'C:\Users\test'}, + ); + windowsFileSystem.file('lib/main.dart').createSync(recursive: true); + windowsFileSystem.file('web/index.html') + ..createSync(recursive: true) + ..writeAsStringSync('hello'); + }); + + testUsingContext( + 'serves assets with exact key on Windows filesystem', + () async { + final WebAssetServer server = await WebAssetServer.start( + null, + null, + false, + false, + false, + BuildInfo.debug, + false, + const DartDevelopmentServiceConfiguration(enable: false), + Uri.base, + null, + crossOriginIsolation: false, + webDevServerConfig: const WebDevServerConfig(host: 'localhost'), + webRenderer: WebRendererMode.canvaskit, + isWasm: false, + useLocalCanvasKit: false, + testMode: true, + fileSystem: windowsFileSystem, + logger: BufferLogger.test(), + platform: windowsPlatform, + ); + + // Project source file exists in assets/ directory. + windowsFileSystem.file(r'assets\my_asset.txt') + ..createSync(recursive: true) + ..writeAsStringSync('project file'); + + // Built asset exists in build/flutter_assets/assets/my_asset.txt. + windowsFileSystem.file(r'build\flutter_assets\assets\my_asset.txt') + ..createSync(recursive: true) + ..writeAsStringSync('built asset'); + + // Correct key 'assets/my_asset.txt' (URL /assets/assets/my_asset.txt) succeeds. + final Response validResponse = await server.handleRequest( + Request('GET', Uri.parse('http://localhost:8080/assets/assets/my_asset.txt')), + ); + expect(validResponse.statusCode, HttpStatus.ok); + expect(await validResponse.readAsString(), 'built asset'); + + // Incorrect key 'my_asset.txt' (URL /assets/my_asset.txt) must return 404. + final Response invalidResponse = await server.handleRequest( + Request('GET', Uri.parse('http://localhost:8080/assets/my_asset.txt')), + ); + expect(invalidResponse.statusCode, HttpStatus.notFound); + }, + overrides: { + Artifacts: () => Artifacts.test(), + FileSystem: () => windowsFileSystem, + Platform: () => windowsPlatform, + ProcessManager: () => FakeProcessManager.any(), + }, + ); + }); }); } From 0cb32fd1625d0782fc3162754f73da90f14205c0 Mon Sep 17 00:00:00 2001 From: gaaclarke <30870216+gaaclarke@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:49:27 +0000 Subject: [PATCH 14/46] Fixes windows gallery benchmarks by forcing mobile layout (#191507) The tests were failing because the driver was assuming that the app was in mobile layout mode. To fix this we've forced new_gallery to run in mobile layout when running tests. ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [AI contribution guidelines] and understand my responsibilities, or I am not using AI tools. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. If this change needs to override an active code freeze, provide a comment explaining why. The code freeze workflow can be overridden by code reviewers. See pinned issues for any active code freezes with guidance. **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [AI contribution guidelines]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --- .../new_gallery/lib/data/gallery_options.dart | 6 ++++++ .../new_gallery/lib/layout/adaptive.dart | 12 +++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/dev/integration_tests/new_gallery/lib/data/gallery_options.dart b/dev/integration_tests/new_gallery/lib/data/gallery_options.dart index 2325c03994d37..382a259bacca1 100644 --- a/dev/integration_tests/new_gallery/lib/data/gallery_options.dart +++ b/dev/integration_tests/new_gallery/lib/data/gallery_options.dart @@ -143,6 +143,12 @@ class GalleryOptions { isTestMode, ); + static GalleryOptions? maybeOf(BuildContext context) { + final _ModelBindingScope? scope = context + .dependOnInheritedWidgetOfExactType<_ModelBindingScope>(); + return scope?.modelBindingState.currentModel; + } + static GalleryOptions of(BuildContext context) { final _ModelBindingScope scope = context .dependOnInheritedWidgetOfExactType<_ModelBindingScope>()!; diff --git a/dev/integration_tests/new_gallery/lib/layout/adaptive.dart b/dev/integration_tests/new_gallery/lib/layout/adaptive.dart index 87b3a53ba6868..5947b4d89eeb7 100644 --- a/dev/integration_tests/new_gallery/lib/layout/adaptive.dart +++ b/dev/integration_tests/new_gallery/lib/layout/adaptive.dart @@ -5,6 +5,8 @@ import 'package:adaptive_breakpoints/adaptive_breakpoints.dart'; import 'package:flutter/material.dart'; +import '../data/gallery_options.dart'; + /// The maximum width taken up by each item on the home screen. const double maxHomeItemWidth = 1400.0; @@ -15,11 +17,19 @@ const double maxHomeItemWidth = 1400.0; /// where only part of the display is available to said widgets. /// /// Used to build adaptive and responsive layouts. -bool isDisplayDesktop(BuildContext context) => getWindowType(context) >= AdaptiveWindowType.medium; +bool isDisplayDesktop(BuildContext context) { + if (GalleryOptions.maybeOf(context)?.isTestMode ?? false) { + return false; + } + return getWindowType(context) >= AdaptiveWindowType.medium; +} /// Returns boolean value whether the window is considered medium size. /// /// Used to build adaptive and responsive layouts. bool isDisplaySmallDesktop(BuildContext context) { + if (GalleryOptions.maybeOf(context)?.isTestMode ?? false) { + return false; + } return getWindowType(context) == AdaptiveWindowType.medium; } From 6391d3926de23d7d273381a1a5ed56647ff469ab Mon Sep 17 00:00:00 2001 From: Caroline Liu <10456171+caroqliu@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:54:32 +0000 Subject: [PATCH 15/46] Revert "[input] Migrate fuchsia.ui.pointerinjector to TouchSource (#190855) (#191509) This reverts commit 7099814fba851cfee875dc0aa63994b20e415a51. See b/550157001 --- .../shell/platform/fuchsia/flutter/BUILD.gn | 4 + .../shell/platform/fuchsia/flutter/engine.cc | 13 + .../fuchsia/flutter/meta/common.shard.cml | 1 + .../platform/fuchsia/flutter/platform_view.cc | 23 +- .../platform/fuchsia/flutter/platform_view.h | 3 + .../flutter/pointer_injector_delegate.cc | 290 +++++++ .../flutter/pointer_injector_delegate.h | 190 +++++ .../pointer_injector_delegate_unittest.cc | 731 ++++++++++++++++++ .../fuchsia/flutter/tests/fakes/BUILD.gn | 14 + .../tests/fakes/mock_injector_registry.h | 93 +++ .../tests/integration/mouse-input/BUILD.gn | 1 + .../mouse-input/meta/mouse-input-test.cml | 1 + .../tests/integration/text-input/BUILD.gn | 1 + .../text-input/meta/text-input-test.cml | 1 + .../tests/integration/touch-input/BUILD.gn | 1 + .../touch-input/meta/touch-input-test.cml | 1 + .../integration/utils/portable_ui_test.cc | 3 +- .../integration/utils/portable_ui_test.h | 2 + .../flutter/tests/platform_view_unittest.cc | 8 + 19 files changed, 1379 insertions(+), 2 deletions(-) create mode 100644 engine/src/flutter/shell/platform/fuchsia/flutter/pointer_injector_delegate.cc create mode 100644 engine/src/flutter/shell/platform/fuchsia/flutter/pointer_injector_delegate.h create mode 100644 engine/src/flutter/shell/platform/fuchsia/flutter/pointer_injector_delegate_unittest.cc create mode 100644 engine/src/flutter/shell/platform/fuchsia/flutter/tests/fakes/mock_injector_registry.h diff --git a/engine/src/flutter/shell/platform/fuchsia/flutter/BUILD.gn b/engine/src/flutter/shell/platform/fuchsia/flutter/BUILD.gn index 88f4b32eaee81..d4a052244b77f 100644 --- a/engine/src/flutter/shell/platform/fuchsia/flutter/BUILD.gn +++ b/engine/src/flutter/shell/platform/fuchsia/flutter/BUILD.gn @@ -85,6 +85,8 @@ template("runner_sources") { "platform_view.h", "pointer_delegate.cc", "pointer_delegate.h", + "pointer_injector_delegate.cc", + "pointer_injector_delegate.h", "program_metadata.h", "rtree.cc", "rtree.h", @@ -153,6 +155,7 @@ template("runner_sources") { "${fuchsia_sdk}/fidl/fuchsia.ui.input", "${fuchsia_sdk}/fidl/fuchsia.ui.input3", "${fuchsia_sdk}/fidl/fuchsia.ui.pointer", + "${fuchsia_sdk}/fidl/fuchsia.ui.pointerinjector", "${fuchsia_sdk}/fidl/fuchsia.ui.test.input", "${fuchsia_sdk}/fidl/fuchsia.ui.views", "${fuchsia_sdk}/pkg/inspect", @@ -454,6 +457,7 @@ if (enable_unittests) { "fuchsia_intl_unittest.cc", "keyboard_unittest.cc", "pointer_delegate_unittests.cc", + "pointer_injector_delegate_unittest.cc", "rtree_unittests.cc", "tests/engine_unittests.cc", "tests/external_view_embedder_unittests.cc", diff --git a/engine/src/flutter/shell/platform/fuchsia/flutter/engine.cc b/engine/src/flutter/shell/platform/fuchsia/flutter/engine.cc index 93ccdeabcd7f3..5995f41704527 100644 --- a/engine/src/flutter/shell/platform/fuchsia/flutter/engine.cc +++ b/engine/src/flutter/shell/platform/fuchsia/flutter/engine.cc @@ -305,6 +305,17 @@ void Engine::Initialize( << "fuchsia::ui::input3::Keyboard connection failed: " << zx_status_get_string(keyboard_status); + // Connect to Pointerinjector service. + fuchsia::ui::pointerinjector::RegistryHandle pointerinjector_registry; + zx_status_t pointerinjector_registry_status = + runner_services->Connect( + pointerinjector_registry.NewRequest()); + if (pointerinjector_registry_status != ZX_OK) { + FML_LOG(WARNING) + << "fuchsia::ui::pointerinjector::Registry connection failed: " + << zx_status_get_string(pointerinjector_registry_status); + } + // Make clones of the `ViewRef` before sending it to various places. fuchsia::ui::views::ViewRef platform_view_ref; view_ref_pair.second.Clone(&platform_view_ref); @@ -458,6 +469,7 @@ void Engine::Initialize( view_ref_focused = std::move(view_ref_focused), touch_source = std::move(touch_source), mouse_source = std::move(mouse_source), + pointerinjector_registry = std::move(pointerinjector_registry), on_session_listener_error_callback = std::move(on_session_listener_error_callback), on_enable_wireframe_callback = @@ -519,6 +531,7 @@ void Engine::Initialize( std::move(keyboard), std::move(touch_source), std::move(mouse_source), std::move(focuser), std::move(view_ref_focused), std::move(parent_viewport_watcher), + std::move(pointerinjector_registry), std::move(on_enable_wireframe_callback), std::move(on_create_view_callback), std::move(on_update_view_callback), diff --git a/engine/src/flutter/shell/platform/fuchsia/flutter/meta/common.shard.cml b/engine/src/flutter/shell/platform/fuchsia/flutter/meta/common.shard.cml index 4fb7b75bf6785..ff2a2b6e4b423 100644 --- a/engine/src/flutter/shell/platform/fuchsia/flutter/meta/common.shard.cml +++ b/engine/src/flutter/shell/platform/fuchsia/flutter/meta/common.shard.cml @@ -46,6 +46,7 @@ "fuchsia.ui.composition.Flatland", "fuchsia.ui.input.ImeService", "fuchsia.ui.input3.Keyboard", + "fuchsia.ui.pointerinjector.Registry", "fuchsia.vulkan.loader.Loader", // Copied from vulkan/client.shard.cml. ], }, diff --git a/engine/src/flutter/shell/platform/fuchsia/flutter/platform_view.cc b/engine/src/flutter/shell/platform/fuchsia/flutter/platform_view.cc index 292cdb3b931b4..8a015cda9ca7d 100644 --- a/engine/src/flutter/shell/platform/fuchsia/flutter/platform_view.cc +++ b/engine/src/flutter/shell/platform/fuchsia/flutter/platform_view.cc @@ -24,6 +24,7 @@ #include "third_party/rapidjson/include/rapidjson/writer.h" #include "flutter/fml/make_copyable.h" +#include "flutter/shell/platform/fuchsia/flutter/pointer_injector_delegate.h" #include "flutter/shell/platform/fuchsia/flutter/text_delegate.h" #include "flutter/shell/platform/fuchsia/flutter/vsync_waiter.h" #include "flutter/shell/platform/fuchsia/runtime/dart/utils/inlines.h" @@ -67,6 +68,7 @@ PlatformView::PlatformView( fuchsia::ui::views::ViewRefFocusedHandle view_ref_focused, fuchsia::ui::composition::ParentViewportWatcherHandle parent_viewport_watcher, + fuchsia::ui::pointerinjector::RegistryHandle pointerinjector_registry, OnEnableWireframeCallback wireframe_enabled_callback, OnCreateViewCallback on_create_view_callback, OnUpdateViewCallback on_update_view_callback, @@ -102,6 +104,9 @@ PlatformView::PlatformView( dart_application_svc_(dart_application_svc), parent_viewport_watcher_(parent_viewport_watcher.Bind()), weak_factory_(this) { + fuchsia::ui::views::ViewRef view_ref_clone; + fidl::Clone(view_ref, &view_ref_clone); + text_delegate_ = std::make_unique( std::move(view_ref), std::move(ime_service), std::move(keyboard), @@ -158,6 +163,10 @@ PlatformView::PlatformView( weak->DispatchPointerDataPacket(std::move(packet)); }); + // Configure the pointer injector delegate. + pointer_injector_delegate_ = std::make_unique( + std::move(pointerinjector_registry), std::move(view_ref_clone)); + // This is only used by the integration tests. if (dart_application_svc) { // Connect to TouchInputListener @@ -409,8 +418,12 @@ void PlatformView::OnChildViewViewRef(uint64_t content_id, fuchsia::ui::views::ViewRef view_ref) { FML_CHECK(child_view_info_.count(content_id) == 1); + fuchsia::ui::views::ViewRef view_ref_clone; + fidl::Clone(view_ref, &view_ref_clone); + focus_delegate_->OnChildViewViewRef(view_id, std::move(view_ref)); + pointer_injector_delegate_->OnCreateView(view_id, std::move(view_ref_clone)); OnChildViewConnected(content_id); } @@ -444,7 +457,7 @@ void PlatformView::OnCreateView(ViewCallback on_view_created, watcher_handle.Bind(); FML_CHECK(child_view_watcher); - child_view_watcher.set_error_handler([weak, content_id]( + child_view_watcher.set_error_handler([weak, view_id, content_id]( zx_status_t status) { FML_LOG(WARNING) << "Child disconnected. ChildViewWatcher status: " << status; @@ -456,6 +469,9 @@ void PlatformView::OnCreateView(ViewCallback on_view_created, return; } + // Disconnected views cannot listen to pointer events. + weak->pointer_injector_delegate_->OnDestroyView(view_id); + weak->OnChildViewDisconnected(content_id.value); }); @@ -501,6 +517,7 @@ void PlatformView::OnDisposeView(int64_t view_id_raw) { weak->OnChildViewDisconnected(content_id.value); weak->child_view_info_.erase(content_id.value); weak->focus_delegate_->OnDisposeChildView(view_id_raw); + weak->pointer_injector_delegate_->OnDestroyView(view_id_raw); }); }; on_destroy_view_callback_(view_id_raw, std::move(on_view_unbound)); @@ -884,6 +901,10 @@ bool PlatformView::HandleFlutterPlatformViewsChannelPlatformMessage( } else if (method.rfind("View.focus", 0) == 0) { return focus_delegate_->HandlePlatformMessage(document, message->response()); + } else if (method.rfind(PointerInjectorDelegate::kPointerInjectorMethodPrefix, + 0) == 0) { + return pointer_injector_delegate_->HandlePlatformMessage( + document, message->response()); } else { FML_LOG(ERROR) << "Unknown " << message->channel() << " method " << method; } diff --git a/engine/src/flutter/shell/platform/fuchsia/flutter/platform_view.h b/engine/src/flutter/shell/platform/fuchsia/flutter/platform_view.h index a687ce2707027..360184de88263 100644 --- a/engine/src/flutter/shell/platform/fuchsia/flutter/platform_view.h +++ b/engine/src/flutter/shell/platform/fuchsia/flutter/platform_view.h @@ -33,6 +33,7 @@ #include "flutter/shell/platform/fuchsia/flutter/focus_delegate.h" #include "flutter/shell/platform/fuchsia/flutter/keyboard.h" #include "flutter/shell/platform/fuchsia/flutter/pointer_delegate.h" +#include "flutter/shell/platform/fuchsia/flutter/pointer_injector_delegate.h" #include "flutter/shell/platform/fuchsia/flutter/text_delegate.h" #include "flutter/shell/platform/fuchsia/flutter/vsync_waiter.h" @@ -82,6 +83,7 @@ class PlatformView : public flutter::PlatformView { fuchsia::ui::views::ViewRefFocusedHandle view_ref_focused, fuchsia::ui::composition::ParentViewportWatcherHandle parent_viewport_watcher, + fuchsia::ui::pointerinjector::RegistryHandle pointerinjector_registry, OnEnableWireframeCallback wireframe_enabled_callback, OnCreateViewCallback on_create_view_callback, OnUpdateViewCallback on_update_view_callback, @@ -196,6 +198,7 @@ class PlatformView : public flutter::PlatformView { std::shared_ptr focus_delegate_; std::shared_ptr pointer_delegate_; + std::unique_ptr pointer_injector_delegate_; // Text delegate is responsible for handling keyboard input and text editing. std::unique_ptr text_delegate_; diff --git a/engine/src/flutter/shell/platform/fuchsia/flutter/pointer_injector_delegate.cc b/engine/src/flutter/shell/platform/fuchsia/flutter/pointer_injector_delegate.cc new file mode 100644 index 0000000000000..729de929cee0f --- /dev/null +++ b/engine/src/flutter/shell/platform/fuchsia/flutter/pointer_injector_delegate.cc @@ -0,0 +1,290 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "flutter/shell/platform/fuchsia/flutter/pointer_injector_delegate.h" + +#include "flutter/fml/logging.h" + +namespace flutter_runner { + +using fup_Config = fuchsia::ui::pointerinjector::Config; +using fup_Context = fuchsia::ui::pointerinjector::Context; +using fup_Data = fuchsia::ui::pointerinjector::Data; +using fup_DeviceType = fuchsia::ui::pointerinjector::DeviceType; +using fup_DispatchPolicy = fuchsia::ui::pointerinjector::DispatchPolicy; +using fup_Event = fuchsia::ui::pointerinjector::Event; +using fup_EventPhase = fuchsia::ui::pointerinjector::EventPhase; +using fup_PointerSample = fuchsia::ui::pointerinjector::PointerSample; +using fup_Target = fuchsia::ui::pointerinjector::Target; +using fup_Viewport = fuchsia::ui::pointerinjector::Viewport; +using fuv_ViewRef = fuchsia::ui::views::ViewRef; +const auto fup_MAX_INJECT = fuchsia::ui::pointerinjector::MAX_INJECT; + +namespace { + +// clang-format off + static constexpr std::array kIdentityMatrix = { + 1, 0, 0, // column one + 0, 1, 0, // column two + 0, 0, 1, // column three + }; +// clang-format on + +} // namespace + +bool PointerInjectorDelegate::HandlePlatformMessage( + const rapidjson::Document& request, + fml::RefPtr response) { + if (!registry_->is_bound()) { + FML_LOG(WARNING) + << "Lost connection to fuchsia.ui.pointerinjector.Registry"; + return false; + } + + auto method = request.FindMember("method"); + if (method == request.MemberEnd() || !method->value.IsString()) { + FML_LOG(ERROR) << "No method found in platform message."; + return false; + } + + if (method->value != kPointerInjectorMethodPrefix) { + FML_LOG(ERROR) << "Unexpected platform message method, expected " + "View.pointerinjector.inject."; + return false; + } + + auto args_it = request.FindMember("args"); + if (args_it == request.MemberEnd() || !args_it->value.IsObject()) { + FML_LOG(ERROR) << "No arguments found in platform message's method"; + return false; + } + + const auto& args = args_it->value; + + auto view_id = args.FindMember("viewId"); + if (!view_id->value.IsUint64()) { + FML_LOG(ERROR) << "Argument 'viewId' is not a uint64"; + return false; + } + + auto id = view_id->value.GetUint64(); + if (valid_views_.count(id) == 0) { + // A child view can get destroyed bottom-up, so the parent view may continue + // injecting until all view state processing "catches up". Until then, it's + // okay to accept a request to inject into a view that no longer exists. + // Doing so avoids log pollution regarding "MissingPluginException". + Complete(std::move(response), "[0]"); + return true; + } + + auto phase = args.FindMember("phase"); + if (!phase->value.IsInt()) { + FML_LOG(ERROR) << "Argument 'phase' is not a int"; + return false; + } + + auto pointer_x = args.FindMember("x"); + if (!pointer_x->value.IsFloat() && !pointer_x->value.IsInt()) { + FML_LOG(ERROR) << "Argument 'Pointer.X' is not a float"; + return false; + } + + auto pointer_y = args.FindMember("y"); + if (!pointer_y->value.IsFloat() && !pointer_y->value.IsInt()) { + FML_LOG(ERROR) << "Argument 'Pointer.Y' is not a float"; + return false; + } + + auto pointer_id = args.FindMember("pointerId"); + if (!pointer_id->value.IsUint()) { + FML_LOG(ERROR) << "Argument 'pointerId' is not a uint32"; + return false; + } + + auto trace_flow_id = args.FindMember("traceFlowId"); + if (!trace_flow_id->value.IsInt()) { + FML_LOG(ERROR) << "Argument 'traceFlowId' is not a int"; + return false; + } + + auto width = args.FindMember("logicalWidth"); + if (!width->value.IsFloat() && !width->value.IsInt()) { + FML_LOG(ERROR) << "Argument 'logicalWidth' is not a float"; + return false; + } + + auto height = args.FindMember("logicalHeight"); + if (!height->value.IsFloat() && !height->value.IsInt()) { + FML_LOG(ERROR) << "Argument 'logicalHeight' is not a float"; + return false; + } + + auto timestamp = args.FindMember("timestamp"); + if (!timestamp->value.IsInt() && !timestamp->value.IsUint64()) { + FML_LOG(ERROR) << "Argument 'timestamp' is not a int"; + return false; + } + + PointerInjectorRequest event = { + .x = pointer_x->value.GetFloat(), + .y = pointer_y->value.GetFloat(), + .pointer_id = pointer_id->value.GetUint(), + .phase = static_cast(phase->value.GetInt()), + .trace_flow_id = trace_flow_id->value.GetUint64(), + .logical_size = {width->value.GetFloat(), height->value.GetFloat()}, + .timestamp = timestamp->value.GetInt()}; + + // Inject the pointer event if the view has been created. + valid_views_.at(id).InjectEvent(std::move(event)); + Complete(std::move(response), "[0]"); + return true; +} + +void PointerInjectorDelegate::OnCreateView( + uint64_t view_id, + std::optional view_ref) { + FML_CHECK(valid_views_.count(view_id) == 0); + + auto [_, success] = valid_views_.try_emplace( + view_id, registry_, host_view_ref_, std::move(view_ref)); + + FML_CHECK(success); +} + +fup_Event PointerInjectorDelegate::ExtractPointerEvent( + PointerInjectorRequest request) { + fup_Event event; + event.set_timestamp(request.timestamp); + event.set_trace_flow_id(request.trace_flow_id); + + fup_PointerSample pointer_sample; + pointer_sample.set_pointer_id(request.pointer_id); + pointer_sample.set_phase(request.phase); + pointer_sample.set_position_in_viewport({request.x, request.y}); + + fup_Data data; + data.set_pointer_sample(std::move(pointer_sample)); + + event.set_data(std::move(data)); + return event; +} + +void PointerInjectorDelegate::Complete( + fml::RefPtr response, + std::string value) { + if (response) { + response->Complete(std::make_unique( + std::vector(value.begin(), value.end()))); + } +} + +void PointerInjectorDelegate::PointerInjectorEndpoint::InjectEvent( + PointerInjectorRequest request) { + if (!registered_) { + RegisterInjector(request); + } + + auto event = ExtractPointerEvent(std::move(request)); + + // Add the event to |injector_events_| and dispatch it to the view. + EnqueueEvent(std::move(event)); + + DispatchPendingEvents(); +} + +void PointerInjectorDelegate::PointerInjectorEndpoint::DispatchPendingEvents() { + // Return if there is already a |fuchsia.ui.pointerinjector.Device.Inject| + // call in flight. The new pointer events will be dispatched once the + // in-progress call terminates. + if (injection_in_flight_) { + return; + } + + // Dispatch the events present in |injector_events_|. Note that we recursively + // call |DispatchPendingEvents| in the callback passed to the + // |f.u.p.Device.Inject| call. This ensures that there is only one + // |f.u.p.Device.Inject| call at a time. If a new pointer event comes when + // there is a |f.u.p.Device.Inject| call in progress, it gets buffered in + // |injector_events_| and is picked up later. + if (!injector_events_.empty()) { + auto events = std::move(injector_events_.front()); + injector_events_.pop(); + injection_in_flight_ = true; + + FML_CHECK(device_.is_bound()); + FML_CHECK(events.size() <= fup_MAX_INJECT); + + device_->Inject(std::move(events), [weak = weak_factory_.GetWeakPtr()] { + if (!weak) { + FML_LOG(WARNING) << "Use after free attempted."; + return; + } + weak->injection_in_flight_ = false; + weak->DispatchPendingEvents(); + }); + } +} + +void PointerInjectorDelegate::PointerInjectorEndpoint::EnqueueEvent( + fup_Event event) { + // Add |event| in |injector_events_| keeping in mind that the vector size does + // not exceed |fup_MAX_INJECT|. + if (!injector_events_.empty() && + injector_events_.back().size() < fup_MAX_INJECT) { + injector_events_.back().push_back(std::move(event)); + } else { + std::vector vec; + vec.reserve(fup_MAX_INJECT); + vec.push_back(std::move(event)); + injector_events_.push(std::move(vec)); + } +} + +void PointerInjectorDelegate::PointerInjectorEndpoint::RegisterInjector( + const PointerInjectorRequest& request) { + if (registered_) { + return; + } + + fup_Config config; + config.set_device_id(1); + config.set_device_type(fup_DeviceType::TOUCH); + config.set_dispatch_policy(fup_DispatchPolicy::EXCLUSIVE_TARGET); + + fup_Context context; + fuv_ViewRef context_clone; + fidl::Clone(*host_view_ref_, &context_clone); + context.set_view(std::move(context_clone)); + config.set_context(std::move(context)); + + FML_CHECK(view_ref_.has_value()); + fup_Target target; + fuv_ViewRef target_clone; + + fidl::Clone(*view_ref_, &target_clone); + target.set_view(std::move(target_clone)); + config.set_target(std::move(target)); + + fup_Viewport viewport; + viewport.set_viewport_to_context_transform(kIdentityMatrix); + std::array, 2> extents{ + {/*min*/ {0, 0}, + /*max*/ {request.logical_size[0], request.logical_size[1]}}}; + viewport.set_extents(std::move(extents)); + config.set_viewport(std::move(viewport)); + + FML_CHECK(registry_->is_bound()); + + (*registry_)->Register(std::move(config), device_.NewRequest(), [] {}); + + registered_ = true; +} + +void PointerInjectorDelegate::PointerInjectorEndpoint::Reset() { + injection_in_flight_ = false; + registered_ = false; + injector_events_ = {}; +} + +} // namespace flutter_runner diff --git a/engine/src/flutter/shell/platform/fuchsia/flutter/pointer_injector_delegate.h b/engine/src/flutter/shell/platform/fuchsia/flutter/pointer_injector_delegate.h new file mode 100644 index 0000000000000..0acb76051de93 --- /dev/null +++ b/engine/src/flutter/shell/platform/fuchsia/flutter/pointer_injector_delegate.h @@ -0,0 +1,190 @@ +// 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. + +#ifndef FLUTTER_SHELL_PLATFORM_FUCHSIA_FLUTTER_POINTER_INJECTOR_DELEGATE_H_ +#define FLUTTER_SHELL_PLATFORM_FUCHSIA_FLUTTER_POINTER_INJECTOR_DELEGATE_H_ + +#include +#include + +#include +#include +#include + +#include "flutter/fml/macros.h" +#include "flutter/fml/memory/weak_ptr.h" +#include "flutter/lib/ui/window/platform_message.h" +#include "third_party/rapidjson/include/rapidjson/document.h" + +namespace flutter_runner { + +// This class is responsible for handling the platform messages related to +// pointer events and managing the lifecycle of +// |fuchsia.ui.pointerinjector.Device| client side endpoint for embedded views. +class PointerInjectorDelegate { + public: + static constexpr auto kPointerInjectorMethodPrefix = + "View.pointerinjector.inject"; + + PointerInjectorDelegate(fuchsia::ui::pointerinjector::RegistryHandle registry, + fuchsia::ui::views::ViewRef host_view_ref) + : registry_(std::make_shared( + registry.Bind())), + host_view_ref_(std::make_shared( + std::move(host_view_ref))) {} + + // Handles the following pointer event related platform message requests: + // View.Pointerinjector.inject + // - Attempts to dispatch a pointer event to the given viewRef. Completes + // with [0] when the pointer event is sent to the given viewRef. + bool HandlePlatformMessage( + const rapidjson::Document& request, + fml::RefPtr response); + + // Adds an endpoint for |view_id| in |valid_views_| for lifecycle management. + // Called in |PlatformView::OnChildViewViewRef()|. + void OnCreateView( + uint64_t view_id, + std::optional view_ref = std::nullopt); + + // Closes the |fuchsia.ui.pointerinjector.Device| channel for |view_id| and + // cleans up resources. + void OnDestroyView(uint64_t view_id) { valid_views_.erase(view_id); } + + private: + using ViewId = int64_t; + + struct PointerInjectorRequest { + // The position of the pointer event in viewport's coordinate system. + float x = 0.f, y = 0.f; + + // |fuchsia.ui.pointerinjector.PointerSample.pointer_id|. + uint32_t pointer_id = 0; + + // |fuchsia.ui.pointerinjector.PointerSample.phase|. + fuchsia::ui::pointerinjector::EventPhase phase = + fuchsia::ui::pointerinjector::EventPhase::ADD; + + // |fuchsia.ui.pointerinjector.Event.trace_flow_id|. + uint64_t trace_flow_id = 0; + + // Logical size of the view's coordinate system. + std::array logical_size = {0.f, 0.f}; + + // |fuchsia.ui.pointerinjector.Event.timestamp|. + zx_time_t timestamp = 0; + }; + + // This class is responsible for dispatching pointer events to a view by first + // registering the injector device using + // |fuchsia.ui.pointerinjector.Registry.Register| and then injecting the + // pointer event using |fuchsia.ui.pointerinjector.Device.Inject|. + class PointerInjectorEndpoint { + public: + PointerInjectorEndpoint( + std::shared_ptr registry, + std::shared_ptr host_view_ref, + std::optional view_ref) + : registry_(std::move(registry)), + host_view_ref_(std::move(host_view_ref)), + view_ref_(std::move(view_ref)), + weak_factory_(this) { + // Try to re-register the |device_| if the |device_| gets closed due to + // some error. + device_.set_error_handler( + [weak = weak_factory_.GetWeakPtr()](auto status) { + FML_LOG(WARNING) + << "fuchsia.ui.pointerinjector.Device closed " << status; + if (!weak) { + return; + } + + // Clear all the stale pointer events in |injector_events_| and + // reset the state of |weak| so that any future calls do not inject + // any stale pointer events. + weak->Reset(); + }); + } + + // Registers |device_| if it has not been registered and calls + // |DispatchPendingEvents()| to dispatch |request| to the view. + void InjectEvent(PointerInjectorRequest request); + + private: + // Registers with the pointer injector service. + // + // Sets |registered_| to true immediately after submitting the registration + // request. This means that the registration request may still be in-flight + // on the server side when the function returns. Events can safely be + // injected into the channel while registration is pending ("feed forward"). + void RegisterInjector(const PointerInjectorRequest& request); + + // Recursively calls |fuchsia.ui.pointerinjector.Device.Inject| to dispatch + // the pointer events in |injector_events_| to the view. + void DispatchPendingEvents(); + + void EnqueueEvent(fuchsia::ui::pointerinjector::Event event); + + // Resets |registered_|, |injection_in_flight_| and |injector_events_| so + // that |device_| can be re-registered and future calls to + // |fuchsia.ui.pointerinjector.Device.Inject| do not include any stale + // pointer events. + void Reset(); + + // Set to true if there is a |fuchsia.ui.pointerinjector.Device.Inject| call + // in progress. If true, the |fuchsia.ui.pointerinjector.Event| is buffered + // in |injector_events_|. + bool injection_in_flight_ = false; + + // Set to true if |device_| has been registered using + // |fuchsia.ui.pointerinjector.Registry.Register|. False otherwise. + bool registered_ = false; + + std::shared_ptr registry_; + + // ViewRef for the main flutter app launching the embedded child views. + std::shared_ptr host_view_ref_; + + // ViewRef for a flatland view. + // Set in |OnCreateView|. + std::optional view_ref_; + + fuchsia::ui::pointerinjector::DevicePtr device_; + + // A queue containing all the pending |fuchsia.ui.pointerinjector.Event|s + // which have to be dispatched to the view. + // Note: The size of a vector inside |injector_events_| should not exceed + // |fuchsia.ui.pointerinjector.MAX_INJECT|. + std::queue> + injector_events_; + + fml::WeakPtrFactory + weak_factory_; // Must be the last member. + + FML_DISALLOW_COPY_AND_ASSIGN(PointerInjectorEndpoint); + }; + + void Complete(fml::RefPtr response, + std::string value); + + // Generates a |fuchsia.ui.pointerinjector.Event| from |request| by extracting + // information like timestamp, trace flow id and pointer sample from + // |request|. + static fuchsia::ui::pointerinjector::Event ExtractPointerEvent( + PointerInjectorRequest request); + + // A map of valid views keyed by its view id. A view can receive pointer + // events only if it is present in |valid_views_|. + std::unordered_map valid_views_; + + std::shared_ptr registry_; + + // ViewRef for the main flutter app launching the embedded child views. + std::shared_ptr host_view_ref_; + + FML_DISALLOW_COPY_AND_ASSIGN(PointerInjectorDelegate); +}; + +} // namespace flutter_runner +#endif // FLUTTER_SHELL_PLATFORM_FUCHSIA_FLUTTER_POINTER_INJECTOR_DELEGATE_H_ diff --git a/engine/src/flutter/shell/platform/fuchsia/flutter/pointer_injector_delegate_unittest.cc b/engine/src/flutter/shell/platform/fuchsia/flutter/pointer_injector_delegate_unittest.cc new file mode 100644 index 0000000000000..7227a38cbc07b --- /dev/null +++ b/engine/src/flutter/shell/platform/fuchsia/flutter/pointer_injector_delegate_unittest.cc @@ -0,0 +1,731 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "flutter/shell/platform/fuchsia/flutter/pointer_injector_delegate.h" +#include "flutter/shell/platform/fuchsia/flutter/tests/fakes/mock_injector_registry.h" +#include "flutter/shell/platform/fuchsia/flutter/tests/fakes/platform_message.h" + +namespace flutter_runner::testing { + +using fup_DeviceType = fuchsia::ui::pointerinjector::DeviceType; +using fup_DispatchPolicy = fuchsia::ui::pointerinjector::DispatchPolicy; +using fup_EventPhase = fuchsia::ui::pointerinjector::EventPhase; +using fup_RegistryHandle = fuchsia::ui::pointerinjector::RegistryHandle; +using fuv_ViewRef = fuchsia::ui::views::ViewRef; + +namespace { + +// clang-format off + static constexpr std::array kIdentityMatrix = { + 1, 0, 0, // column one + 0, 1, 0, // column two + 0, 0, 1, // column three + }; +// clang-format on + +rapidjson::Document ParsePlatformMessage(std::string json) { + rapidjson::Document document; + document.Parse(json); + if (document.HasParseError() || !document.IsObject()) { + FML_LOG(ERROR) << "Could not parse document"; + return rapidjson::Document(); + } + return document; +} + +zx_koid_t ExtractKoid(const zx::object_base& object) { + zx_info_handle_basic_t info{}; + if (object.get_info(ZX_INFO_HANDLE_BASIC, &info, sizeof(info), nullptr, + nullptr) != ZX_OK) { + return ZX_KOID_INVALID; // no info + } + + return info.koid; +} + +zx_koid_t ExtractKoid(const fuv_ViewRef& view_ref) { + return ExtractKoid(view_ref.reference); +} + +class PlatformMessageBuilder { + public: + PlatformMessageBuilder& SetViewId(uint64_t view_id) { + view_id_ = view_id; + return *this; + } + + PlatformMessageBuilder& SetPointerX(float x) { + pointer_x_ = x; + return *this; + } + + PlatformMessageBuilder& SetPointerY(float y) { + pointer_y_ = y; + return *this; + } + + PlatformMessageBuilder& SetPhase(int phase) { + phase_ = phase; + return *this; + } + + PlatformMessageBuilder& SetPointerId(int pointer_id) { + pointer_id_ = pointer_id; + return *this; + } + + PlatformMessageBuilder& SetTraceFlowId(int trace_flow_id) { + trace_flow_id_ = trace_flow_id; + return *this; + } + + PlatformMessageBuilder& SetLogicalWidth(float width) { + width_ = width; + return *this; + } + + PlatformMessageBuilder& SetLogicalHeight(float height) { + height_ = height; + return *this; + } + + PlatformMessageBuilder& SetTimestamp(int timestamp) { + timestamp_ = timestamp; + return *this; + } + + rapidjson::Document Build() { + std::ostringstream message; + message << "{" << " \"method\":\"" + << PointerInjectorDelegate::kPointerInjectorMethodPrefix << "\"," + << " \"args\": {" << " \"viewId\":" << view_id_ << "," + << " \"x\":" << pointer_x_ << "," + << " \"y\":" << pointer_y_ << "," + << " \"phase\":" << phase_ << "," + << " \"pointerId\":" << pointer_id_ << "," + << " \"traceFlowId\":" << trace_flow_id_ << "," + << " \"viewRef\":" << view_ref_.reference.get() << "," + << " \"logicalWidth\":" << width_ << "," + << " \"logicalHeight\":" << height_ << "," + << " \"timestamp\":" << timestamp_ << " }" << "}"; + return ParsePlatformMessage(message.str()); + } + + private: + uint64_t view_id_ = 0; + float pointer_x_ = 0.f, pointer_y_ = 0.f; + int phase_ = 1, pointer_id_ = 0, trace_flow_id_ = 0; + fuv_ViewRef view_ref_; + float width_ = 0.f, height_ = 0.f; + int timestamp_ = 0; +}; + +} // namespace + +class PointerInjectorDelegateTest : public ::testing::Test, + public ::testing::WithParamInterface { + protected: + PointerInjectorDelegateTest() + : loop_(&kAsyncLoopConfigAttachToCurrentThread) {} + + // TODO(fxbug.dev/104285): Replace the RunLoop methods with the one provided + // by the sdk. + void RunLoopUntilIdle() { loop_.RunUntilIdle(); } + + bool RunGivenLoopWithTimeout(async::Loop* loop, zx::duration timeout) { + // This cannot be a local variable because the delayed task below can + // execute after this function returns. + auto canceled = std::make_shared(false); + bool timed_out = false; + async::PostDelayedTask( + loop->dispatcher(), + [loop, canceled, &timed_out] { + if (*canceled) { + return; + } + timed_out = true; + loop->Quit(); + }, + timeout); + loop->Run(); + loop->ResetQuit(); + + if (!timed_out) { + *canceled = true; + } + return timed_out; + } + + bool RunLoopWithTimeoutOrUntil(fit::function condition, + zx::duration timeout, + zx::duration step) { + const zx::time timeout_deadline = zx::deadline_after(timeout); + + while (zx::clock::get_monotonic() < timeout_deadline && + loop_.GetState() == ASYNC_LOOP_RUNNABLE) { + if (condition()) { + loop_.ResetQuit(); + return true; + } + + if (step == zx::duration::infinite()) { + // Performs a single unit of work, possibly blocking until there is work + // to do or the timeout deadline arrives. + loop_.Run(timeout_deadline, true); + } else { + // Performs work until the step deadline arrives. + RunGivenLoopWithTimeout(&loop_, step); + } + } + + loop_.ResetQuit(); + return condition(); + } + + void RunLoopUntil(fit::function condition, + zx::duration step = zx::msec(10)) { + RunLoopWithTimeoutOrUntil(std::move(condition), zx::duration::infinite(), + step); + } + + void SetUp() override { + fuchsia::ui::views::ViewRefControl view_ref_control; + fuchsia::ui::views::ViewRef view_ref; + auto status = zx::eventpair::create( + /*options*/ 0u, &view_ref_control.reference, &view_ref.reference); + ASSERT_EQ(status, ZX_OK); + view_ref_control.reference.replace( + ZX_DEFAULT_EVENTPAIR_RIGHTS & (~ZX_RIGHT_DUPLICATE), + &view_ref_control.reference); + view_ref.reference.replace(ZX_RIGHTS_BASIC, &view_ref.reference); + + host_view_ref_ = std::move(view_ref); + + fup_RegistryHandle registry; + registry_ = std::make_unique(registry.NewRequest()); + + fuv_ViewRef host_view_ref_clone; + fidl::Clone(host_view_ref_, &host_view_ref_clone); + + pointer_injector_delegate_ = std::make_unique( + std::move(registry), std::move(host_view_ref_clone)); + } + + void CreateView(uint64_t view_id, + std::optional view_ref = std::nullopt) { + fuv_ViewRef ref; + if (view_ref.has_value()) { + ref = std::move(*view_ref); + } else { + fuchsia::ui::views::ViewRefControl view_ref_control; + fuchsia::ui::views::ViewRef view_ref; + auto status = zx::eventpair::create( + /*options*/ 0u, &view_ref_control.reference, &view_ref.reference); + ASSERT_EQ(status, ZX_OK); + view_ref_control.reference.replace( + ZX_DEFAULT_EVENTPAIR_RIGHTS & (~ZX_RIGHT_DUPLICATE), + &view_ref_control.reference); + view_ref.reference.replace(ZX_RIGHTS_BASIC, &view_ref.reference); + + ref = std::move(view_ref); + } + pointer_injector_delegate_->OnCreateView(view_id, std::move(ref)); + } + + std::unique_ptr pointer_injector_delegate_; + std::unique_ptr registry_; + fuv_ViewRef host_view_ref_; + + private: + async::Loop loop_; +}; + +TEST_P(PointerInjectorDelegateTest, IncorrectPlatformMessage_ShouldFail) { + const uint64_t view_id = 1; + + // Create a view. + CreateView(view_id); + + // A platform message in incorrect JSON format should fail. + { + auto response = FakePlatformMessageResponse::Create(); + + EXPECT_FALSE(pointer_injector_delegate_->HandlePlatformMessage( + ParsePlatformMessage("{Incorrect Json}"), response)); + } + + // |PointerInjectorDelegate| only handles "View.Pointerinjector.inject" + // platform messages. + { + auto response = FakePlatformMessageResponse::Create(); + + EXPECT_FALSE(pointer_injector_delegate_->HandlePlatformMessage( + ParsePlatformMessage("{\"method\":\"View.focus.getCurrent\"}"), + response)); + } + + // A platform message with no args should fail. + { + auto response = FakePlatformMessageResponse::Create(); + + EXPECT_FALSE(pointer_injector_delegate_->HandlePlatformMessage( + ParsePlatformMessage("{\"method\":\"View.Pointerinjector.inject\"}"), + response)); + } +} + +TEST_P(PointerInjectorDelegateTest, ViewsReceiveInjectedEvents) { + const uint64_t num_events = 150; + + // Inject |num_events| platform messages for view 1. + { + const uint64_t view_id = 1; + + CreateView(view_id); + + fuchsia::ui::views::ViewRefControl view_ref_control; + fuchsia::ui::views::ViewRef view_ref; + auto status = zx::eventpair::create( + /*options*/ 0u, &view_ref_control.reference, &view_ref.reference); + ASSERT_EQ(status, ZX_OK); + view_ref_control.reference.replace( + ZX_DEFAULT_EVENTPAIR_RIGHTS & (~ZX_RIGHT_DUPLICATE), + &view_ref_control.reference); + view_ref.reference.replace(ZX_RIGHTS_BASIC, &view_ref.reference); + + for (size_t i = 0; i < num_events; i++) { + auto response = FakePlatformMessageResponse::Create(); + + EXPECT_TRUE(pointer_injector_delegate_->HandlePlatformMessage( + PlatformMessageBuilder().SetViewId(view_id).Build(), response)); + + response->ExpectCompleted("[0]"); + } + } + + // Inject |num_events| platform messages for view 2. + { + const uint64_t view_id = 2; + + CreateView(view_id); + + fuchsia::ui::views::ViewRefControl view_ref_control; + fuchsia::ui::views::ViewRef view_ref; + auto status = zx::eventpair::create( + /*options*/ 0u, &view_ref_control.reference, &view_ref.reference); + ASSERT_EQ(status, ZX_OK); + view_ref_control.reference.replace( + ZX_DEFAULT_EVENTPAIR_RIGHTS & (~ZX_RIGHT_DUPLICATE), + &view_ref_control.reference); + view_ref.reference.replace(ZX_RIGHTS_BASIC, &view_ref.reference); + + for (size_t i = 0; i < num_events; i++) { + auto response = FakePlatformMessageResponse::Create(); + + EXPECT_TRUE(pointer_injector_delegate_->HandlePlatformMessage( + PlatformMessageBuilder().SetViewId(view_id).Build(), response)); + + response->ExpectCompleted("[0]"); + } + } + + // The mock Pointerinjector registry server receives |num_events| pointer + // events from |f.u.p.Device.Inject| calls for each view. + RunLoopUntil( + [this] { return registry_->num_events_received() == 2 * num_events; }); + + // The mock Pointerinjector registry server receives a + // |f.u.p.Registry.Register| call for each view. + EXPECT_TRUE(registry_->num_register_calls() == 2); +} + +TEST_P(PointerInjectorDelegateTest, + ViewsDontReceivePointerEventsBeforeCreation) { + const uint64_t num_events = 150; + const uint64_t view_id_1 = 1; + + // Inject |num_events| platform messages for |view_id_1|. + { + fuchsia::ui::views::ViewRefControl view_ref_control; + fuchsia::ui::views::ViewRef view_ref; + auto status = zx::eventpair::create( + /*options*/ 0u, &view_ref_control.reference, &view_ref.reference); + ASSERT_EQ(status, ZX_OK); + view_ref_control.reference.replace( + ZX_DEFAULT_EVENTPAIR_RIGHTS & (~ZX_RIGHT_DUPLICATE), + &view_ref_control.reference); + view_ref.reference.replace(ZX_RIGHTS_BASIC, &view_ref.reference); + + for (size_t i = 0; i < num_events; i++) { + auto response = FakePlatformMessageResponse::Create(); + + // The platform message is *silently* accepted for non-existent views, in + // order to cleanly handle the lifecycle case where the child view is + // forcibly killed. By doing so, products avoid "MissingPluginException" + // log spam. + EXPECT_TRUE(pointer_injector_delegate_->HandlePlatformMessage( + PlatformMessageBuilder().SetViewId(view_id_1).Build(), response)); + } + } + + const uint64_t view_id_2 = 2; + + // Inject |num_events| platform messages for |view_id_2|. + { + fuchsia::ui::views::ViewRefControl view_ref_control; + fuchsia::ui::views::ViewRef view_ref; + auto status = zx::eventpair::create( + /*options*/ 0u, &view_ref_control.reference, &view_ref.reference); + ASSERT_EQ(status, ZX_OK); + view_ref_control.reference.replace( + ZX_DEFAULT_EVENTPAIR_RIGHTS & (~ZX_RIGHT_DUPLICATE), + &view_ref_control.reference); + view_ref.reference.replace(ZX_RIGHTS_BASIC, &view_ref.reference); + + for (size_t i = 0; i < num_events; i++) { + auto response = FakePlatformMessageResponse::Create(); + + // The platform message is *silently* accepted for non-existent views, in + // order to cleanly handle the lifecycle case where the child view is + // forcibly killed. By doing so, products avoid "MissingPluginException" + // log spam. + EXPECT_TRUE(pointer_injector_delegate_->HandlePlatformMessage( + PlatformMessageBuilder().SetViewId(view_id_2).Build(), response)); + } + } + + RunLoopUntilIdle(); + + // The views do not receive any pointer events till they get created. + EXPECT_TRUE(registry_->num_events_received() == 0); +} + +// PointerInjectorDelegate should generate a correct |f.u.p.Config| from a +// platform message. +TEST_P(PointerInjectorDelegateTest, ValidRegistrationConfigTest) { + const uint64_t view_id = 1; + + const float x = 2.f, y = 2.f, width = 5.f, height = 5.f; + const int phase = 2, pointer_id = 5, trace_flow_id = 5, timestamp = 10; + + auto response = FakePlatformMessageResponse::Create(); + + fuchsia::ui::views::ViewRefControl view_ref_control; + fuchsia::ui::views::ViewRef view_ref; + auto status = zx::eventpair::create( + /*options*/ 0u, &view_ref_control.reference, &view_ref.reference); + ZX_ASSERT(status == ZX_OK); + view_ref_control.reference.replace( + ZX_DEFAULT_EVENTPAIR_RIGHTS & (~ZX_RIGHT_DUPLICATE), + &view_ref_control.reference); + view_ref.reference.replace(ZX_RIGHTS_BASIC, &view_ref.reference); + + // Create the view. + fuv_ViewRef view_ref_clone; + fidl::Clone(view_ref, &view_ref_clone); + CreateView(view_id, std::move(view_ref_clone)); + + // Inject a platform message. + EXPECT_TRUE(pointer_injector_delegate_->HandlePlatformMessage( + PlatformMessageBuilder() + .SetViewId(view_id) + .SetPointerX(x) + .SetPointerY(y) + .SetPhase(phase) + .SetPointerId(pointer_id) + .SetTraceFlowId(trace_flow_id) + .SetLogicalWidth(width) + .SetLogicalHeight(height) + .SetTimestamp(timestamp) + .Build(), + response)); + + response->ExpectCompleted("[0]"); + + // The mock Pointerinjector registry server receives a pointer event from + // |f.u.p.Device.Inject| call for the view. + RunLoopUntil([this] { return registry_->num_events_received() == 1; }); + + // The mock Pointerinjector registry server receives a + // |f.u.p.Registry.Register| call for the view. + ASSERT_TRUE(registry_->num_register_calls() == 1); + + const auto& config = registry_->config(); + + ASSERT_TRUE(config.has_device_id()); + EXPECT_EQ(config.device_id(), 1u); + + ASSERT_TRUE(config.has_device_type()); + EXPECT_EQ(config.device_type(), fup_DeviceType::TOUCH); + + ASSERT_TRUE(config.has_dispatch_policy()); + EXPECT_EQ(config.dispatch_policy(), fup_DispatchPolicy::EXCLUSIVE_TARGET); + + ASSERT_TRUE(config.has_context()); + ASSERT_TRUE(config.context().is_view()); + EXPECT_EQ(ExtractKoid(config.context().view()), ExtractKoid(host_view_ref_)); + + ASSERT_TRUE(config.has_target()); + ASSERT_TRUE(config.target().is_view()); + EXPECT_EQ(ExtractKoid(config.target().view()), ExtractKoid(view_ref)); + + ASSERT_TRUE(config.has_viewport()); + ASSERT_TRUE(config.viewport().has_viewport_to_context_transform()); + EXPECT_EQ(config.viewport().viewport_to_context_transform(), kIdentityMatrix); + + std::array, 2> extents{{{0, 0}, {width, height}}}; + ASSERT_TRUE(config.viewport().has_extents()); + EXPECT_EQ(config.viewport().extents(), extents); +} + +// PointerInjectorDelegate generates a correct f.u.p.Event from the platform +// message. +TEST_P(PointerInjectorDelegateTest, ValidPointerEventTest) { + const uint64_t view_id = 1; + + const float x = 2.f, y = 2.f, width = 5.f, height = 5.f; + const int phase = 2, pointer_id = 5, trace_flow_id = 5, timestamp = 10; + + auto response = FakePlatformMessageResponse::Create(); + + fuchsia::ui::views::ViewRefControl view_ref_control; + fuchsia::ui::views::ViewRef view_ref; + auto status = zx::eventpair::create( + /*options*/ 0u, &view_ref_control.reference, &view_ref.reference); + ZX_ASSERT(status == ZX_OK); + view_ref_control.reference.replace( + ZX_DEFAULT_EVENTPAIR_RIGHTS & (~ZX_RIGHT_DUPLICATE), + &view_ref_control.reference); + view_ref.reference.replace(ZX_RIGHTS_BASIC, &view_ref.reference); + + // Create the view. + fuv_ViewRef view_ref_clone; + fidl::Clone(view_ref, &view_ref_clone); + CreateView(view_id, std::move(view_ref_clone)); + + // Inject a platform message. + EXPECT_TRUE(pointer_injector_delegate_->HandlePlatformMessage( + PlatformMessageBuilder() + .SetViewId(view_id) + .SetPointerX(x) + .SetPointerY(y) + .SetPhase(phase) + .SetPointerId(pointer_id) + .SetTraceFlowId(trace_flow_id) + .SetLogicalWidth(width) + .SetLogicalHeight(height) + .SetTimestamp(timestamp) + .Build(), + response)); + + response->ExpectCompleted("[0]"); + + // The mock Pointerinjector registry server receives a pointer event from + // |f.u.p.Device.Inject| call for the view. + RunLoopUntil([this] { return registry_->num_events_received() == 1; }); + + // The mock Pointerinjector registry server receives a + // |f.u.p.Registry.Register| call for the view. + ASSERT_TRUE(registry_->num_register_calls() == 1); + + const auto& events = registry_->events(); + + ASSERT_EQ(events.size(), 1u); + + const auto& event = events[0]; + + ASSERT_TRUE(event.has_timestamp()); + EXPECT_EQ(event.timestamp(), timestamp); + + ASSERT_TRUE(event.has_trace_flow_id()); + EXPECT_EQ(event.trace_flow_id(), static_cast(trace_flow_id)); + + ASSERT_TRUE(event.has_data()); + ASSERT_TRUE(event.data().is_pointer_sample()); + + const auto& pointer_sample = event.data().pointer_sample(); + + ASSERT_TRUE(pointer_sample.has_pointer_id()); + ASSERT_TRUE(pointer_sample.has_phase()); + ASSERT_TRUE(pointer_sample.has_position_in_viewport()); + EXPECT_EQ(pointer_sample.pointer_id(), static_cast(pointer_id)); + EXPECT_EQ(pointer_sample.phase(), static_cast(phase)); + EXPECT_THAT(pointer_sample.position_in_viewport(), + ::testing::ElementsAre(x, y)); +} + +TEST_P(PointerInjectorDelegateTest, DestroyedViewsDontGetPointerEvents) { + const uint64_t view_id = 1, num_events = 150; + + fuchsia::ui::views::ViewRefControl view_ref_control; + fuchsia::ui::views::ViewRef view_ref; + auto status = zx::eventpair::create( + /*options*/ 0u, &view_ref_control.reference, &view_ref.reference); + ZX_ASSERT(status == ZX_OK); + view_ref_control.reference.replace( + ZX_DEFAULT_EVENTPAIR_RIGHTS & (~ZX_RIGHT_DUPLICATE), + &view_ref_control.reference); + view_ref.reference.replace(ZX_RIGHTS_BASIC, &view_ref.reference); + + // Create the view. + CreateView(view_id); + + // Inject |num_events| platform messages. + for (size_t i = 0; i < num_events; i++) { + auto response = FakePlatformMessageResponse::Create(); + + EXPECT_TRUE(pointer_injector_delegate_->HandlePlatformMessage( + PlatformMessageBuilder().SetViewId(view_id).Build(), response)); + + response->ExpectCompleted("[0]"); + } + + // Destroy the view. + pointer_injector_delegate_->OnDestroyView(view_id); + + // The view does not receive |num_events| pointer events as it gets destroyed + // before all the pointer events could be dispatched. + const zx::duration timeout = zx::sec(1), step = zx::msec(10); + EXPECT_FALSE(RunLoopWithTimeoutOrUntil( + [this] { return registry_->num_events_received() == num_events; }, + timeout, step)); + + EXPECT_LT(registry_->num_events_received(), num_events); +} + +TEST_P(PointerInjectorDelegateTest, ViewsGetPointerEventsInFIFO) { + const uint64_t view_id = 1, num_events = 150; + + fuchsia::ui::views::ViewRefControl view_ref_control; + fuchsia::ui::views::ViewRef view_ref; + auto status = zx::eventpair::create( + /*options*/ 0u, &view_ref_control.reference, &view_ref.reference); + ZX_ASSERT(status == ZX_OK); + view_ref_control.reference.replace( + ZX_DEFAULT_EVENTPAIR_RIGHTS & (~ZX_RIGHT_DUPLICATE), + &view_ref_control.reference); + view_ref.reference.replace(ZX_RIGHTS_BASIC, &view_ref.reference); + + // Create the view. + CreateView(view_id); + + // Inject |num_events| platform messages. + for (size_t i = 0; i < num_events; i++) { + auto response = FakePlatformMessageResponse::Create(); + + EXPECT_TRUE(pointer_injector_delegate_->HandlePlatformMessage( + PlatformMessageBuilder() + .SetViewId(view_id) + .SetPointerId(static_cast(i)) + .Build(), + response)); + + response->ExpectCompleted("[0]"); + } + + // The mock Pointerinjector registry server receives |num_events| pointer + // events from |f.u.p.Device.Inject| call for the view. + RunLoopUntil( + [this] { return registry_->num_events_received() == num_events; }); + + // The mock Pointerinjector registry server receives a + // |f.u.p.Registry.Register| call for the view. + ASSERT_TRUE(registry_->num_register_calls() == 1); + + auto& events = registry_->events(); + + // The view should receive the pointer events in a FIFO order. As we injected + // platform messages with an increasing |pointer_id|, the received pointer + // events should also have the |pointer_id| in an increasing order. + for (size_t i = 0; i < events.size() - 1; i++) { + ASSERT_TRUE(events[i].has_data()); + ASSERT_TRUE(events[i + 1].has_data()); + ASSERT_TRUE(events[i].data().is_pointer_sample()); + ASSERT_TRUE(events[i + 1].data().is_pointer_sample()); + + const auto& pointer_sample_1 = events[i].data().pointer_sample(); + const auto& pointer_sample_2 = events[i + 1].data().pointer_sample(); + + ASSERT_TRUE(pointer_sample_1.has_pointer_id()); + ASSERT_TRUE(pointer_sample_2.has_pointer_id()); + + EXPECT_TRUE(pointer_sample_1.pointer_id() < pointer_sample_2.pointer_id()); + } +} + +TEST_P(PointerInjectorDelegateTest, DeviceRetriesRegisterWhenClosed) { + const uint64_t view_id = 1; + const int pointer_id = 1; + fuchsia::ui::views::ViewRefControl view_ref_control; + fuchsia::ui::views::ViewRef view_ref; + auto status = zx::eventpair::create( + /*options*/ 0u, &view_ref_control.reference, &view_ref.reference); + ZX_ASSERT(status == ZX_OK); + view_ref_control.reference.replace( + ZX_DEFAULT_EVENTPAIR_RIGHTS & (~ZX_RIGHT_DUPLICATE), + &view_ref_control.reference); + view_ref.reference.replace(ZX_RIGHTS_BASIC, &view_ref.reference); + + auto response = FakePlatformMessageResponse::Create(); + auto response_2 = FakePlatformMessageResponse::Create(); + + // Create the view. + fuv_ViewRef view_ref_clone; + fidl::Clone(view_ref, &view_ref_clone); + CreateView(view_id, std::move(view_ref_clone)); + + EXPECT_TRUE(pointer_injector_delegate_->HandlePlatformMessage( + PlatformMessageBuilder() + .SetViewId(view_id) + .SetPointerId(pointer_id) + .Build(), + response)); + + response->ExpectCompleted("[0]"); + + // The mock Pointerinjector registry server receives a pointer event from + // |f.u.p.Device.Inject| call for the view. + RunLoopUntil([this] { return registry_->num_events_received() == 1; }); + + // The mock Pointerinjector registry server receives a + // |f.u.p.Registry.Register| call for the view. + ASSERT_TRUE(registry_->num_register_calls() == 1); + + // Close the device channel. + registry_->ClearBindings(); + RunLoopUntilIdle(); + + EXPECT_TRUE(pointer_injector_delegate_->HandlePlatformMessage( + PlatformMessageBuilder() + .SetViewId(view_id) + .SetPointerId(pointer_id) + .Build(), + response_2)); + + response_2->ExpectCompleted("[0]"); + + // The mock Pointerinjector registry server receives a pointer event from + // |f.u.p.Device.Inject| call for the view. + RunLoopUntil([this] { return registry_->num_events_received() == 2; }); + + // The device tries to register again as the channel got closed. + ASSERT_TRUE(registry_->num_register_calls() == 2); +} + +INSTANTIATE_TEST_SUITE_P(PointerInjectorDelegateParameterizedTest, + PointerInjectorDelegateTest, + ::testing::Bool()); + +} // namespace flutter_runner::testing diff --git a/engine/src/flutter/shell/platform/fuchsia/flutter/tests/fakes/BUILD.gn b/engine/src/flutter/shell/platform/fuchsia/flutter/tests/fakes/BUILD.gn index 0c610bd7f5e05..e47a8d3cbcec8 100644 --- a/engine/src/flutter/shell/platform/fuchsia/flutter/tests/fakes/BUILD.gn +++ b/engine/src/flutter/shell/platform/fuchsia/flutter/tests/fakes/BUILD.gn @@ -11,6 +11,7 @@ group("fakes") { public_deps = [ ":focus", + ":pointer", "scenic", ] } @@ -32,3 +33,16 @@ source_set("focus") { "//flutter/third_party/rapidjson", ] } + +source_set("pointer") { + testonly = true + + sources = [ "mock_injector_registry.h" ] + + deps = [ + "${fuchsia_sdk}/pkg/sys_cpp_testing", + "//flutter/lib/ui", + "//flutter/testing", + "//flutter/third_party/rapidjson", + ] +} diff --git a/engine/src/flutter/shell/platform/fuchsia/flutter/tests/fakes/mock_injector_registry.h b/engine/src/flutter/shell/platform/fuchsia/flutter/tests/fakes/mock_injector_registry.h new file mode 100644 index 0000000000000..37f7d478ed328 --- /dev/null +++ b/engine/src/flutter/shell/platform/fuchsia/flutter/tests/fakes/mock_injector_registry.h @@ -0,0 +1,93 @@ +// 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. + +#ifndef FLUTTER_SHELL_PLATFORM_FUCHSIA_FLUTTER_TESTS_FAKES_MOCK_INJECTOR_REGISTRY_H_ +#define FLUTTER_SHELL_PLATFORM_FUCHSIA_FLUTTER_TESTS_FAKES_MOCK_INJECTOR_REGISTRY_H_ + +#include +#include + +#include + +namespace flutter_runner::testing { + +// A test stub to act as the protocol server. A test can control what is sent +// back by this server implementation, via the ScheduleCallback call. +class MockInjectorRegistry : public fuchsia::ui::pointerinjector::Registry, + public fuchsia::ui::pointerinjector::Device { + public: + explicit MockInjectorRegistry( + fidl::InterfaceRequest registry) + : registry_(this, std::move(registry)) {} + + // |fuchsia.ui.pointerinjector.Registry.Register|. + void Register( + fuchsia::ui::pointerinjector::Config config, + fidl::InterfaceRequest injector, + RegisterCallback callback) override { + num_register_calls_++; + const uint32_t id = next_id_++; + + auto [it, success] = bindings_.try_emplace(id, this, std::move(injector)); + + it->second.set_error_handler( + [this, id](zx_status_t status) { bindings_.erase(id); }); + + config_ = std::move(config); + + callback(); + } + + // |fuchsia.ui.pointerinjector.Device.Inject|. + void Inject(std::vector events, + InjectCallback callback) override { + num_events_received_ += events.size(); + + for (auto& event : events) { + events_.push_back(std::move(event)); + } + + callback(); + } + + void ClearBindings() { bindings_.clear(); } + + // Returns the |fuchsia::ui::pointerinjector::Config| received in the last + // |Register(...)| call. + const fuchsia::ui::pointerinjector::Config& config() const { return config_; } + + // Returns all the |fuchsia::ui::pointerinjector::Event|s received from the + // |Inject(...)| calls. + const std::vector& events() const { + return events_; + } + + uint32_t num_register_calls() { return num_register_calls_; } + + size_t num_registered() { return bindings_.size(); } + + uint32_t num_events_received() const { return num_events_received_; } + + private: + uint32_t next_id_ = 0; + + uint32_t num_events_received_ = 0; + + uint32_t num_register_calls_ = 0; + + fuchsia::ui::pointerinjector::Config config_; + + std::vector events_; + + std::unordered_map> + bindings_; + + fidl::Binding registry_; + + FML_DISALLOW_COPY_AND_ASSIGN(MockInjectorRegistry); +}; +} // namespace flutter_runner::testing + +#endif // FLUTTER_SHELL_PLATFORM_FUCHSIA_FLUTTER_TESTS_FAKES_MOCK_INJECTOR_REGISTRY_H_ diff --git a/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/mouse-input/BUILD.gn b/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/mouse-input/BUILD.gn index ab65ec5ebfd2c..56961c5a6c6c6 100644 --- a/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/mouse-input/BUILD.gn +++ b/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/mouse-input/BUILD.gn @@ -36,6 +36,7 @@ executable("mouse-input-test-bin") { "${fuchsia_sdk}/fidl/fuchsia.ui.app", "${fuchsia_sdk}/fidl/fuchsia.ui.display.singleton", "${fuchsia_sdk}/fidl/fuchsia.ui.input", + "${fuchsia_sdk}/fidl/fuchsia.ui.pointerinjector", "${fuchsia_sdk}/fidl/fuchsia.ui.test.input", "${fuchsia_sdk}/fidl/fuchsia.ui.test.scene", "${fuchsia_sdk}/fidl/fuchsia.web", diff --git a/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/mouse-input/meta/mouse-input-test.cml b/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/mouse-input/meta/mouse-input-test.cml index d1e30f9ed857d..37969799c2934 100644 --- a/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/mouse-input/meta/mouse-input-test.cml +++ b/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/mouse-input/meta/mouse-input-test.cml @@ -44,6 +44,7 @@ "fuchsia.ui.test.input.MouseInputListener", "fuchsia.intl.PropertyProvider", "fuchsia.posix.socket.Provider", + "fuchsia.ui.pointerinjector.Registry", ], from: "parent", to: "#realm_builder", diff --git a/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/text-input/BUILD.gn b/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/text-input/BUILD.gn index 35563e4073d8a..3098f25eb7753 100644 --- a/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/text-input/BUILD.gn +++ b/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/text-input/BUILD.gn @@ -33,6 +33,7 @@ executable("text-input-test-bin") { "${fuchsia_sdk}/fidl/fuchsia.ui.app", "${fuchsia_sdk}/fidl/fuchsia.ui.display.singleton", "${fuchsia_sdk}/fidl/fuchsia.ui.input", + "${fuchsia_sdk}/fidl/fuchsia.ui.pointerinjector", "${fuchsia_sdk}/fidl/fuchsia.ui.test.input", "${fuchsia_sdk}/fidl/fuchsia.ui.test.scene", "${fuchsia_sdk}/pkg/async", diff --git a/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/text-input/meta/text-input-test.cml b/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/text-input/meta/text-input-test.cml index 9fee0df1b1bfc..d30c089a43592 100644 --- a/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/text-input/meta/text-input-test.cml +++ b/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/text-input/meta/text-input-test.cml @@ -41,6 +41,7 @@ "fuchsia.ui.input3.Keyboard", "fuchsia.intl.PropertyProvider", "fuchsia.posix.socket.Provider", + "fuchsia.ui.pointerinjector.Registry", "fuchsia.fonts.Provider", "fuchsia.feedback.CrashReportingProductRegister", "fuchsia.settings.Keyboard", diff --git a/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/touch-input/BUILD.gn b/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/touch-input/BUILD.gn index daf8a4b42d311..56d7fb311c053 100644 --- a/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/touch-input/BUILD.gn +++ b/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/touch-input/BUILD.gn @@ -36,6 +36,7 @@ executable("touch-input-test-bin") { "${fuchsia_sdk}/fidl/fuchsia.ui.app", "${fuchsia_sdk}/fidl/fuchsia.ui.display.singleton", "${fuchsia_sdk}/fidl/fuchsia.ui.input", + "${fuchsia_sdk}/fidl/fuchsia.ui.pointerinjector", "${fuchsia_sdk}/fidl/fuchsia.ui.test.input", "${fuchsia_sdk}/fidl/fuchsia.ui.test.scene", "${fuchsia_sdk}/fidl/fuchsia.web", diff --git a/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/touch-input/meta/touch-input-test.cml b/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/touch-input/meta/touch-input-test.cml index 70736f3362272..420899efd9b38 100644 --- a/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/touch-input/meta/touch-input-test.cml +++ b/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/touch-input/meta/touch-input-test.cml @@ -42,6 +42,7 @@ "fuchsia.ui.test.input.TouchInputListener", "fuchsia.intl.PropertyProvider", "fuchsia.posix.socket.Provider", + "fuchsia.ui.pointerinjector.Registry", ], from: "parent", to: "#realm_builder", diff --git a/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/utils/portable_ui_test.cc b/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/utils/portable_ui_test.cc index 3110e392ed3f6..5a5a239cfb91c 100644 --- a/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/utils/portable_ui_test.cc +++ b/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/utils/portable_ui_test.cc @@ -100,7 +100,8 @@ void PortableUITest::SetUpRealmBase() { Protocol{fuchsia::ui::composition::Flatland::Name_}, Protocol{fuchsia::ui::test::input::Registry::Name_}, Protocol{fuchsia::ui::test::scene::Controller::Name_}, - Protocol{fuchsia::ui::display::singleton::Info::Name_}}, + Protocol{fuchsia::ui::display::singleton::Info::Name_}, + Protocol{kPointerInjectorRegistryName}}, .source = kTestUIStackRef, .targets = {ParentRef(), kFlutterJitRunnerRef}}); diff --git a/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/utils/portable_ui_test.h b/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/utils/portable_ui_test.h index 070d8097cd39e..0078b3f60e4cc 100644 --- a/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/utils/portable_ui_test.h +++ b/engine/src/flutter/shell/platform/fuchsia/flutter/tests/integration/utils/portable_ui_test.h @@ -29,6 +29,8 @@ class PortableUITest : public ::loop_fixture::RealLoop { "fuchsia.vulkan.loader.Loader"; static constexpr auto kPosixSocketProviderName = "fuchsia.posix.socket.Provider"; + static constexpr auto kPointerInjectorRegistryName = + "fuchsia.ui.pointerinjector.Registry"; // The naming and references used by Realm Builder static constexpr auto kTestUIStack = "ui"; diff --git a/engine/src/flutter/shell/platform/fuchsia/flutter/tests/platform_view_unittest.cc b/engine/src/flutter/shell/platform/fuchsia/flutter/tests/platform_view_unittest.cc index 462e82ee632db..d6e5ecd73b6ad 100644 --- a/engine/src/flutter/shell/platform/fuchsia/flutter/tests/platform_view_unittest.cc +++ b/engine/src/flutter/shell/platform/fuchsia/flutter/tests/platform_view_unittest.cc @@ -420,6 +420,12 @@ class PlatformViewBuilder { return *this; } + PlatformViewBuilder& SetPointerInjectorRegistry( + fuchsia::ui::pointerinjector::RegistryHandle pointerinjector_registry) { + pointerinjector_registry_ = std::move(pointerinjector_registry); + return *this; + } + PlatformViewBuilder& SetEnableWireframeCallback( OnEnableWireframeCallback callback) { wireframe_enabled_callback_ = std::move(callback); @@ -470,6 +476,7 @@ class PlatformViewBuilder { std::move(keyboard_), std::move(touch_source_), std::move(mouse_source_), std::move(focuser_), std::move(view_ref_focused_), std::move(parent_viewport_watcher_), + std::move(pointerinjector_registry_), std::move(wireframe_enabled_callback_), std::move(on_create_view_callback_), std::move(on_update_view_callback_), @@ -497,6 +504,7 @@ class PlatformViewBuilder { fuchsia::ui::pointer::MouseSourceHandle mouse_source_; fuchsia::ui::views::ViewRefFocusedHandle view_ref_focused_; fuchsia::ui::views::FocuserHandle focuser_; + fuchsia::ui::pointerinjector::RegistryHandle pointerinjector_registry_; fit::closure on_session_listener_error_callback_; OnEnableWireframeCallback wireframe_enabled_callback_; fuchsia::ui::composition::ParentViewportWatcherHandle From a3191edce5603002acec464659276b46839e968f Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Sat, 22 Aug 2026 01:03:19 +0000 Subject: [PATCH 16/46] Roll Skia from 0c37868737fa to 666a9b3d5cf0 (2 revisions) (#191518) https://skia.googlesource.com/skia.git/+log/0c37868737fa..666a9b3d5cf0 2026-08-21 recipe-mega-autoroller@chops-service-accounts.iam.gserviceaccount.com Roll recipe dependencies (trivial). 2026-08-21 skia-autoroll@skia-public.iam.gserviceaccount.com Roll skcms from 8c2404b3b73f to 9c10699e8884 (1 revision) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/skia-flutter-autoroll Please CC awolff@google.com,kjlubick@google.com,robertphillips@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Skia: https://bugs.chromium.org/p/skia/issues/entry To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPS b/DEPS index ebe7ed61d4cfd..3e0859dcd6bcb 100644 --- a/DEPS +++ b/DEPS @@ -15,7 +15,7 @@ vars = { 'skia_git': 'https://skia.googlesource.com', 'llvm_git': 'https://llvm.googlesource.com', 'dart_ai_rev': '9c96bfe5f091c9451eff5b59c9bffeb2e806b875', - 'skia_revision': '0c37868737fa11cc6ee2858eeb9f0f0637f7b2d3', + 'skia_revision': '666a9b3d5cf04d26a36635a71b97e06b4f715794', # Do not download the Emscripten SDK by default. # This prevents us from downloading the Emscripten toolchain for builds From 915812c58564ffd1312ea233e9c31f2544103840 Mon Sep 17 00:00:00 2001 From: Ben Konyi Date: Sat, 22 Aug 2026 02:18:02 +0000 Subject: [PATCH 17/46] [flutter_tools] Add tests for negative lookahead regex in test runner and batch entrypoints (#191438) ## Description Fixes https://github.com/flutter/flutter/issues/84270 The root cause of #84270 (delayed variable expansion in batch scripts stripping `!` from CLI arguments like `^(?!Golden).+`) was previously addressed in #106861, which removed `SETLOCAL ENABLEDELAYEDEXPANSION` from `bin/flutter.bat`, `bin/dart.bat`, and `bin/internal/shared.bat`. However, #84270 was left open without dedicated regression tests. This PR: 1. Adds a hermetic unit test in `test_test.dart` verifying that `TestCommand` preserves and forwards negative lookahead regexes (`^(?!Golden).+`) to `FlutterTestRunner`. 2. Adds a case-insensitive static test in `script_test.dart` asserting that Windows entrypoint batch scripts do not enable delayed expansion. 3. Unskips and modernizes the integration test in `variable_expansion_windows_test.dart` verifying that `bin/dart.bat` preserves lookahead regexes on Windows. ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [ ] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] All existing and new tests are passing. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ --- .../commands.shard/hermetic/test_test.dart | 22 +++++++++++ .../commands.shard/permeable/script_test.dart | 20 ++++++++++ .../variable_expansion_windows_test.dart | 37 +++++++++---------- 3 files changed, 59 insertions(+), 20 deletions(-) diff --git a/packages/flutter_tools/test/commands.shard/hermetic/test_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/test_test.dart index 7cb5f8def4e36..34eb105316114 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/test_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/test_test.dart @@ -1757,6 +1757,24 @@ resolution: workspace ProcessManager: () => FakeProcessManager.any(), }, ); + + testUsingContext( + 'passes regular expression lookahead in --name argument to test runner', + () async { + final testRunner = FakeFlutterTestRunner(0); + + final testCommand = TestCommand(testRunner: testRunner); + final CommandRunner commandRunner = createTestCommandRunner(testCommand); + + await commandRunner.run(const ['test', '--name', r'^(?!Golden).+', '--no-pub']); + + expect(testRunner.lastNames, [r'^(?!Golden).+']); + }, + overrides: { + FileSystem: () => fs, + ProcessManager: () => FakeProcessManager.any(), + }, + ); } class FakeFlutterTestRunner implements FlutterTestRunner { @@ -1771,6 +1789,8 @@ class FakeFlutterTestRunner implements FlutterTestRunner { int? lastConcurrency; TestWatcher? lastTestWatcher; FakeVmServiceHost? fakeVmServiceHost; + List lastNames = const []; + List lastPlainNames = const []; @override Future runTests( @@ -1817,6 +1837,8 @@ class FakeFlutterTestRunner implements FlutterTestRunner { lastReporterOption = reporter; lastConcurrency = concurrency; lastTestWatcher = watcher; + lastNames = names; + lastPlainNames = plainNames; if (leastRunTime != null) { await Future.delayed(leastRunTime!); diff --git a/packages/flutter_tools/test/commands.shard/permeable/script_test.dart b/packages/flutter_tools/test/commands.shard/permeable/script_test.dart index 93b1048e5fb44..a592bb103c456 100644 --- a/packages/flutter_tools/test/commands.shard/permeable/script_test.dart +++ b/packages/flutter_tools/test/commands.shard/permeable/script_test.dart @@ -30,4 +30,24 @@ void main() { }, skip: !Platform.isWindows, // [intended] relies on Windows's cmd.exe ); + + testUsingContext('windows entrypoint batch scripts do not enable delayed expansion', () { + final String flutterRoot = getFlutterRoot(); + final File flutterBat = globals.fs.file( + globals.fs.path.join(flutterRoot, 'bin', 'flutter.bat'), + ); + final File dartBat = globals.fs.file(globals.fs.path.join(flutterRoot, 'bin', 'dart.bat')); + final File sharedBat = globals.fs.file( + globals.fs.path.join(flutterRoot, 'bin', 'internal', 'shared.bat'), + ); + + expect(flutterBat.existsSync(), isTrue); + expect(dartBat.existsSync(), isTrue); + expect(sharedBat.existsSync(), isTrue); + + final delayedExpansionPattern = RegExp(r'ENABLEDELAYEDEXPANSION', caseSensitive: false); + expect(flutterBat.readAsStringSync(), isNot(contains(delayedExpansionPattern))); + expect(dartBat.readAsStringSync(), isNot(contains(delayedExpansionPattern))); + expect(sharedBat.readAsStringSync(), isNot(contains(delayedExpansionPattern))); + }); } diff --git a/packages/flutter_tools/test/integration.shard/variable_expansion_windows_test.dart b/packages/flutter_tools/test/integration.shard/variable_expansion_windows_test.dart index d479c99a31662..6e0016d4d645f 100644 --- a/packages/flutter_tools/test/integration.shard/variable_expansion_windows_test.dart +++ b/packages/flutter_tools/test/integration.shard/variable_expansion_windows_test.dart @@ -9,24 +9,21 @@ import 'test_utils.dart'; void main() { // Regression test for https://github.com/flutter/flutter/issues/84270 . - testWithoutContext( - 'dart command will expand variables on windows', - () async { - final ProcessResult result = await processManager.run([ - fileSystem.path.join(getFlutterRoot(), 'bin', 'dart'), - fileSystem.path.join( - getFlutterRoot(), - 'packages', - 'flutter_tools', - 'test', - 'integration.shard', - 'variable_expansion_windows.dart', - ), - '"^(?!Golden).+"', - ]); - expect(result.stdout, contains('args: ["(?!Golden).+"]')); - }, - // https://github.com/flutter/flutter/issues/87934 - skip: 'Reverted in https://github.com/flutter/flutter/pull/86000', - ); + testWithoutContext('dart command will not expand variables on windows', () async { + final ProcessResult result = await processManager.run([ + fileSystem.path.join(getFlutterRoot(), 'bin', 'dart.bat'), + fileSystem.path.join( + getFlutterRoot(), + 'packages', + 'flutter_tools', + 'test', + 'integration.shard', + 'variable_expansion_windows.dart', + ), + '"^(?!Golden).+"', + ]); + expect(result.exitCode, 0, reason: 'Process failed with stderr: ${result.stderr}'); + expect(result.stdout, contains('(?!Golden)')); + expect(result.stdout, isNot(contains('(?Golden)'))); + }, skip: !platform.isWindows); } From f1374f5c2916920126116518a59adbd6867ce4a8 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Sat, 22 Aug 2026 03:29:25 +0000 Subject: [PATCH 18/46] Roll Fuchsia Test Scripts from KaOq3EE4qJ9fnaaaK... to 0iCv10IlKfiilEBOU... (#191524) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/fuchsia-test-scripts-flutter Please CC awolff@google.com,chrome-fuchsia-engprod@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPS b/DEPS index 3e0859dcd6bcb..f44774b6c7db2 100644 --- a/DEPS +++ b/DEPS @@ -199,7 +199,7 @@ vars = { # The version / instance id of the cipd:chromium/fuchsia/test-scripts which # will be used altogether with fuchsia-sdk to setup the build / test # environment. - 'fuchsia_test_scripts_version': 'KaOq3EE4qJ9fnaaaKznmHAZs9wyn-LdK_h52KEQv2KgC', + 'fuchsia_test_scripts_version': '0iCv10IlKfiilEBOU6W-UM1oPJ8_wGduU0i6cYYDo28C', # The version / instance id of the cipd:chromium/fuchsia/gn-sdk which will be # used altogether with fuchsia-sdk to generate gn based build rules. From 68d6be5f9b8526f2a35468d11d3082e34856de98 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Sat, 22 Aug 2026 04:47:17 +0000 Subject: [PATCH 19/46] Roll Skia from 666a9b3d5cf0 to ad2106c0bb64 (3 revisions) (#191527) https://skia.googlesource.com/skia.git/+log/666a9b3d5cf0..ad2106c0bb64 2026-08-22 skia-autoroll@skia-public.iam.gserviceaccount.com Roll vulkan-deps from 3cc0a5d1905e to 993a4d05ca61 (6 revisions) 2026-08-22 recipe-mega-autoroller@chops-service-accounts.iam.gserviceaccount.com Roll recipe dependencies (trivial). 2026-08-22 skia-autoroll@skia-public.iam.gserviceaccount.com Manual roll Dawn from 4bd029589224 to 15f7ba37a9ac (9 revisions) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/skia-flutter-autoroll Please CC awolff@google.com,kjlubick@google.com,robertphillips@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Skia: https://bugs.chromium.org/p/skia/issues/entry To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPS b/DEPS index f44774b6c7db2..ce3118ee3985f 100644 --- a/DEPS +++ b/DEPS @@ -15,7 +15,7 @@ vars = { 'skia_git': 'https://skia.googlesource.com', 'llvm_git': 'https://llvm.googlesource.com', 'dart_ai_rev': '9c96bfe5f091c9451eff5b59c9bffeb2e806b875', - 'skia_revision': '666a9b3d5cf04d26a36635a71b97e06b4f715794', + 'skia_revision': 'ad2106c0bb64105101f9feb1e552797aa7e7f1a3', # Do not download the Emscripten SDK by default. # This prevents us from downloading the Emscripten toolchain for builds From 65c9a8dc60bc7bdc7d1656840c5a4bffa2e05185 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Sat, 22 Aug 2026 13:08:20 +0000 Subject: [PATCH 20/46] Roll ICU from d578f2e8b7bd to 8cc91d9b6ab9 (1 revision) (#191542) https://chromium.googlesource.com/chromium/deps/icu.git/+log/d578f2e8b7bd..8cc91d9b6ab9 2026-08-12 manishearth@google.com [ICU] Exclude unused nfkc_scf.nrm from ICU data bundle If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/icu-sdk-flutter Please CC awolff@google.com,fuchsia-ui-discuss@google.com on the revert to ensure that a human is aware of the problem. To file a bug in ICU: https://github.com/unicode-org/icu To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPS b/DEPS index ce3118ee3985f..b2fca81bcfc63 100644 --- a/DEPS +++ b/DEPS @@ -292,7 +292,7 @@ deps = { Var('chromium_git') + '/external/github.com/google/flatbuffers' + '@' + '067bfdbde9b10c1beb5d6b02d67ae9db8b96f736', 'engine/src/flutter/third_party/icu': - Var('chromium_git') + '/chromium/deps/icu.git' + '@' + 'd578f2e8b7bd5938e21cfb6bf15c079e0aa5b738', + Var('chromium_git') + '/chromium/deps/icu.git' + '@' + '8cc91d9b6ab9991802fd208ee03a69714fd0251c', 'engine/src/flutter/third_party/gtest-parallel': Var('chromium_git') + '/external/github.com/google/gtest-parallel' + '@' + '38191e2733d7cbaeaef6a3f1a942ddeb38a2ad14', From 83a64de9515da813b82374fb31dc71c2f1fd84e6 Mon Sep 17 00:00:00 2001 From: Rusino Date: Sat, 22 Aug 2026 19:13:39 +0000 Subject: [PATCH 21/46] [WebParagraph] Fixing minor problem with getBoxesForRange (#191419) Adjusting width to max with extra spaces. Tests are updated (working for SkParagraph for comparison). --- .../lib/src/engine/web_paragraph/layout.dart | 13 +- .../paragraph_get_boxes_test.dart | 161 +++++++++--------- 2 files changed, 88 insertions(+), 86 deletions(-) diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart index ab5f271d84a95..37671b65fc7e9 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart @@ -606,9 +606,14 @@ class TextLayout { ); } + if (result.isEmpty) { + // We didn't find any intersections between the range and the line's visual blocks + continue; + } + if (boxWidthStyle == ui.BoxWidthStyle.max && lineIndex < lines.length - 1) { // Add whitespaces box left/right for all the lines except the last one - if ((result.first.left - 0).abs() > epsilon) { + if (result.first.left > epsilon) { result.insert( 0, ui.TextBox.fromLTRBD( @@ -620,13 +625,13 @@ class TextLayout { ), ); } - if ((result.last.right - paragraph.maxLineWidthWithTrailingSpaces).abs() > epsilon) { + if (paragraph.maxLineWidthWithTrailingSpaces - result.last.right > epsilon) { result.add( ui.TextBox.fromLTRBD( result.last.right, - result.first.top, + result.last.top, paragraph.maxLineWidthWithTrailingSpaces, - result.first.bottom, + result.last.bottom, paragraph.paragraphStyle.textDirection, ), ); diff --git a/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_get_boxes_test.dart b/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_get_boxes_test.dart index 323a1761d39bd..78b4c0ffde6ab 100644 --- a/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_get_boxes_test.dart +++ b/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_get_boxes_test.dart @@ -2,12 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:math' as math; - import 'package:test/bootstrap/browser.dart'; import 'package:test/test.dart'; -import 'package:ui/src/engine.dart'; -import 'package:ui/src/engine/web_paragraph/paragraph.dart'; import 'package:ui/ui.dart' as ui; import '../common/test_initialization.dart'; @@ -20,51 +16,51 @@ Future testMain() async { setUpUnitTests(); test('Paragraph getBoxesForRange 1 Infinity line', () { - final paragraphStyle = WebParagraphStyle(fontFamily: 'Arial', fontSize: 20); + const text = + 'World domination is such an ugly phrase - I prefer to call it world optimisation.'; + final paragraphStyle = ui.ParagraphStyle(fontFamily: 'Arial', fontSize: 20); - final builder = WebParagraphBuilder(paragraphStyle); - builder.addText( - 'World domination is such an ugly phrase - I prefer to call it world optimisation.', - ); - final WebParagraph paragraph = builder.build(); + final builder = ui.ParagraphBuilder(paragraphStyle); + builder.addText(text); + final ui.Paragraph paragraph = builder.build(); paragraph.layout(const ui.ParagraphConstraints(width: double.infinity)); final List rects1 = paragraph.getBoxesForRange( 0, - paragraph.text.length, + text.length, boxHeightStyle: ui.BoxHeightStyle.max, boxWidthStyle: ui.BoxWidthStyle.max, ); expect(rects1.length, 1); - expect(rects1.first.toRect().height, paragraph.height); + expect(rects1.first.toRect().height >= paragraph.height, true); expect(rects1.first.toRect().width, paragraph.longestLine); final List rects2 = paragraph.getBoxesForRange( 0, - paragraph.text.length, + text.length, // boxHeightStyle: ui.BoxHeightStyle.tight, // boxWidthStyle: ui.BoxWidthStyle.tight, ); expect(rects2.length, 1); - expect(rects2.first.toRect().height, paragraph.height); + expect(rects2.first.toRect().height >= paragraph.height, true); expect(rects2.first.toRect().width, paragraph.longestLine); }); test('Paragraph getBoxesForRange multiple lines', () { - final paragraphStyle = WebParagraphStyle(fontFamily: 'Roboto', fontSize: 50); + const text = + 'World domination is such an ugly phrase - I prefer to call it world optimisation.'; + final paragraphStyle = ui.ParagraphStyle(fontFamily: 'Roboto', fontSize: 50); - final builder = WebParagraphBuilder(paragraphStyle); - builder.addText( - 'World domination is such an ugly phrase - I prefer to call it world optimisation. ', - ); - final WebParagraph paragraph = builder.build(); + final builder = ui.ParagraphBuilder(paragraphStyle); + builder.addText(text); + final ui.Paragraph paragraph = builder.build(); paragraph.layout(const ui.ParagraphConstraints(width: 500)); - expect(paragraph.lines.length, 4); + expect(paragraph.numberOfLines, 4); { final List rects = paragraph.getBoxesForRange( 0, - paragraph.text.length, + text.length, boxHeightStyle: ui.BoxHeightStyle.max, boxWidthStyle: ui.BoxWidthStyle.max, ); @@ -79,25 +75,31 @@ Future testMain() async { final double width34 = rects[3].toRect().width + rects[4].toRect().width; final double width5 = rects[5].toRect().width; - expect(height, paragraph.height); - expect(width01 <= paragraph.maxLineWidthWithTrailingSpaces, true); - expect(width2 <= paragraph.maxLineWidthWithTrailingSpaces, true); - expect(width34 <= paragraph.maxLineWidthWithTrailingSpaces, true); - expect(width5 <= paragraph.maxLineWidthWithTrailingSpaces, true); - expect( - paragraph.maxLineWidthWithTrailingSpaces, - math.max( - math.max(rects[0].toRect().width, rects[2].toRect().width), - math.max(rects[3].toRect().width, rects[5].toRect().width), - ), - ); + expect(height <= paragraph.height, true); + + // All lines except the last one adjusted by the longest line + expect(width01 > 500, true); + expect(width2 > 500, true); + expect(width34 > 500, true); + expect(width5 < 500, true); + + // Rectangles for whitespaces adjustments added correctly + + // Vertically + expect(rects[0].toRect().top, rects[1].toRect().top); + expect(rects[3].toRect().top, rects[4].toRect().top); + // Horizontally + expect(width01, width2); + expect(width01, width34); + + expect(paragraph.longestLine < 500, true); } // TODO(jlavrova): apparently, event BoxWidthStyle.tight takes in account trailing spaces. { final List rects = paragraph.getBoxesForRange( 0, - paragraph.text.length, + text.length, // boxHeightStyle: ui.BoxHeightStyle.tight, // boxWidthStyle: ui.BoxWidthStyle.tight, ); @@ -112,51 +114,45 @@ Future testMain() async { final double width3 = rects[2].toRect().width; final double width4 = rects[3].toRect().width; - expect(height, paragraph.height); - expect(width1 <= paragraph.maxLineWidthWithTrailingSpaces, true); - expect(width2 <= paragraph.maxLineWidthWithTrailingSpaces, true); - expect(width3 <= paragraph.maxLineWidthWithTrailingSpaces, true); - expect(width4 <= paragraph.maxLineWidthWithTrailingSpaces, true); - expect( - paragraph.maxLineWidthWithTrailingSpaces, - math.max( - math.max(rects[0].toRect().width, rects[1].toRect().width), - math.max(rects[2].toRect().width, rects[3].toRect().width), - ), - ); + expect(height <= paragraph.height, true); + expect(width1 < 500, true); + expect(width2 > 500, true); + expect(width3 < 500, true); + expect(width4 < 500, true); + expect(paragraph.longestLine < 500, true); } }); test('Paragraph getBoxesForRange includeLineSpacing multiple lines', () { - final paragraphStyle = WebParagraphStyle(fontFamily: 'Roboto', fontSize: 40); - final heightStyle = WebTextStyle(fontFamily: 'Roboto', fontSize: 40, height: 2.0); - final builder = WebParagraphBuilder(paragraphStyle); + const text = + 'World domination is such an ugly phrase - I prefer to call it world optimisation.'; + final paragraphStyle = ui.ParagraphStyle(fontFamily: 'Roboto', fontSize: 40); + final heightStyle = ui.TextStyle(fontFamily: 'Roboto', fontSize: 40, height: 2.0); + final builder = ui.ParagraphBuilder(paragraphStyle); builder.pushStyle(heightStyle); - builder.addText( - 'World domination is such an ugly phrase - I prefer to call it world optimisation. ', - ); - final WebParagraph paragraph = builder.build(); + builder.addText(text); + final ui.Paragraph paragraph = builder.build(); paragraph.layout(const ui.ParagraphConstraints(width: 500)); const EPSILON = 0.001; final List rectsTop = paragraph.getBoxesForRange( 0, - paragraph.text.length, + text.length, boxHeightStyle: ui.BoxHeightStyle.includeLineSpacingTop, //boxWidthStyle: ui.BoxWidthStyle.tight, ); final List rectsBottom = paragraph.getBoxesForRange( 0, - paragraph.text.length, + text.length, boxHeightStyle: ui.BoxHeightStyle.includeLineSpacingBottom, //boxWidthStyle: ui.BoxWidthStyle.tight, ); final List rectsMiddle = paragraph.getBoxesForRange( 0, - paragraph.text.length, + text.length, boxHeightStyle: ui.BoxHeightStyle.includeLineSpacingMiddle, //boxWidthStyle: ui.BoxWidthStyle.tight, ); @@ -185,11 +181,12 @@ Future testMain() async { }); test('Paragraph getBoxesForRange 1 finite line', () { - final paragraphStyle = WebParagraphStyle(fontFamily: 'Arial', fontSize: 20); + const text = 'Username'; + final paragraphStyle = ui.ParagraphStyle(fontFamily: 'Arial', fontSize: 20); - final builder = WebParagraphBuilder(paragraphStyle); - builder.addText('Username'); - final WebParagraph paragraph = builder.build(); + final builder = ui.ParagraphBuilder(paragraphStyle); + builder.addText(text); + final ui.Paragraph paragraph = builder.build(); paragraph.layout(const ui.ParagraphConstraints(width: 93)); final List rects1 = paragraph.getBoxesForRange( @@ -200,25 +197,25 @@ Future testMain() async { ); expect(rects1.length, 1); expect(rects1.first.toRect().width < paragraph.longestLine, true); - expect(rects1.first.toRect().height, paragraph.height); + expect(rects1.first.toRect().height >= paragraph.height, true); final List rects2 = paragraph.getBoxesForRange( 0, - paragraph.text.length, + text.length, // boxHeightStyle: ui.BoxHeightStyle.tight, // boxWidthStyle: ui.BoxWidthStyle.tight, ); expect(rects2.length, 1); expect(rects2.first.toRect().width, paragraph.longestLine); - expect(rects2.first.toRect().height, paragraph.height); + expect(rects2.first.toRect().height >= paragraph.height, true); }); test('getBoxesForRange returns correct positions for text selection', () { - final paragraphStyle = WebParagraphStyle(fontFamily: 'Arial', fontSize: 20); + final paragraphStyle = ui.ParagraphStyle(fontFamily: 'Arial', fontSize: 20); const text = 'Hello World'; - final builder = WebParagraphBuilder(paragraphStyle); + final builder = ui.ParagraphBuilder(paragraphStyle); builder.addText(text); - final WebParagraph paragraph = builder.build(); + final ui.Paragraph paragraph = builder.build(); paragraph.layout(const ui.ParagraphConstraints(width: double.infinity)); // Get boxes for a range @@ -227,18 +224,18 @@ Future testMain() async { expect(boxes.isNotEmpty, true); for (final box in boxes) { expect(box.left >= 0, true); - expect(box.top >= 0, true); + expect(box.top <= 0, true); expect(box.right > box.left, true); expect(box.bottom > box.top, true); } }); test('getBoxesForRange handles full text range', () { - final paragraphStyle = WebParagraphStyle(fontFamily: 'Arial', fontSize: 20); + final paragraphStyle = ui.ParagraphStyle(fontFamily: 'Arial', fontSize: 20); const text = 'Hello World'; - final builder = WebParagraphBuilder(paragraphStyle); + final builder = ui.ParagraphBuilder(paragraphStyle); builder.addText(text); - final WebParagraph paragraph = builder.build(); + final ui.Paragraph paragraph = builder.build(); paragraph.layout(const ui.ParagraphConstraints(width: double.infinity)); final List boxes = paragraph.getBoxesForRange(0, text.length); @@ -247,15 +244,15 @@ Future testMain() async { }); test('getBoxesForRange handles RTL text correctly', () { - final paragraphStyle = WebParagraphStyle( + final paragraphStyle = ui.ParagraphStyle( fontFamily: 'Arial', fontSize: 20, textDirection: ui.TextDirection.rtl, ); - final builder = WebParagraphBuilder(paragraphStyle); + final builder = ui.ParagraphBuilder(paragraphStyle); builder.addText('مرحبا'); // Arabic (RTL) - final WebParagraph paragraph = builder.build(); - paragraph.layout(const ui.ParagraphConstraints(width: double.infinity)); + final ui.Paragraph paragraph = builder.build(); + paragraph.layout(const ui.ParagraphConstraints(width: 500)); final List boxes = paragraph.getBoxesForRange(0, 'مرحبا'.length); @@ -267,11 +264,11 @@ Future testMain() async { }); test('getBoxesForRange handles empty range', () { - final paragraphStyle = WebParagraphStyle(fontFamily: 'Arial', fontSize: 20); + final paragraphStyle = ui.ParagraphStyle(fontFamily: 'Arial', fontSize: 20); const text = 'Hello World'; - final builder = WebParagraphBuilder(paragraphStyle); + final builder = ui.ParagraphBuilder(paragraphStyle); builder.addText(text); - final WebParagraph paragraph = builder.build(); + final ui.Paragraph paragraph = builder.build(); paragraph.layout(const ui.ParagraphConstraints(width: double.infinity)); final List boxes = paragraph.getBoxesForRange(5, 5); @@ -281,15 +278,15 @@ Future testMain() async { }); test('getBoxesForRange handles multiline text with strut style', () { - final paragraphStyle = WebParagraphStyle( + final paragraphStyle = ui.ParagraphStyle( fontFamily: 'Arial', fontSize: 20, strutStyle: ui.StrutStyle(fontSize: 20), ); const text = 'Hello \nWorld'; - final builder = WebParagraphBuilder(paragraphStyle); + final builder = ui.ParagraphBuilder(paragraphStyle); builder.addText(text); - final WebParagraph paragraph = builder.build(); + final ui.Paragraph paragraph = builder.build(); paragraph.layout(const ui.ParagraphConstraints(width: double.infinity)); final List boxes1 = paragraph.getBoxesForRange( @@ -305,6 +302,6 @@ Future testMain() async { expect(boxes1.isNotEmpty && boxes1.length == 1, true); expect(boxes2.isNotEmpty && boxes2.length == 1, true); - expect(boxes1.first.toRect().bottom, boxes2.first.toRect().top); + expect(boxes1.first.toRect().bottom >= boxes2.first.toRect().top, true); }); } From 4ebf37fe7df0a130ba5bee17315b98f905c10b34 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Sat, 22 Aug 2026 20:55:48 +0000 Subject: [PATCH 22/46] Roll Fuchsia Linux SDK from ic6GjOSn-KN508XyK... to Cqs-NyeELd60hqv1e... (#191547) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/fuchsia-linux-sdk-flutter Please CC awolff@google.com,zra@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPS b/DEPS index b2fca81bcfc63..f4a6cb1436bff 100644 --- a/DEPS +++ b/DEPS @@ -830,7 +830,7 @@ deps = { 'packages': [ { 'package': 'fuchsia/sdk/core/linux-amd64', - 'version': 'ic6GjOSn-KN508XyKYH2OKCY_ohOptmnefcGEpegTG4C' + 'version': 'Cqs-NyeELd60hqv1egYGg5tY2BYemRmtmKW2eysoIZQC' } ], 'condition': 'download_fuchsia_deps and not download_fuchsia_sdk', From 59150b3628a0fadfafa6ef44aa175841e2e6d193 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Sun, 23 Aug 2026 15:00:41 +0000 Subject: [PATCH 23/46] Roll Skia from ad2106c0bb64 to a1825062f832 (1 revision) (#191555) https://skia.googlesource.com/skia.git/+log/ad2106c0bb64..a1825062f832 2026-08-23 skia-autoroll@skia-public.iam.gserviceaccount.com Roll SKP CIPD package from 573 to 574 If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/skia-flutter-autoroll Please CC awolff@google.com,fmalita@google.com,kjlubick@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Skia: https://bugs.chromium.org/p/skia/issues/entry To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPS b/DEPS index f4a6cb1436bff..769fec284f7fe 100644 --- a/DEPS +++ b/DEPS @@ -15,7 +15,7 @@ vars = { 'skia_git': 'https://skia.googlesource.com', 'llvm_git': 'https://llvm.googlesource.com', 'dart_ai_rev': '9c96bfe5f091c9451eff5b59c9bffeb2e806b875', - 'skia_revision': 'ad2106c0bb64105101f9feb1e552797aa7e7f1a3', + 'skia_revision': 'a1825062f83266f646d525d3592ad6eb391ec2dd', # Do not download the Emscripten SDK by default. # This prevents us from downloading the Emscripten toolchain for builds From 64e1cfee3d56bd66aef02b460714e3e7fbde456e Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Sun, 23 Aug 2026 20:16:23 +0000 Subject: [PATCH 24/46] Roll Skia from a1825062f832 to 886302c7c68e (1 revision) (#191559) https://skia.googlesource.com/skia.git/+log/a1825062f832..886302c7c68e 2026-08-23 skia-autoroll@skia-public.iam.gserviceaccount.com Roll vulkan-deps from 993a4d05ca61 to 0e2916f1c990 (1 revision) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/skia-flutter-autoroll Please CC awolff@google.com,fmalita@google.com,kjlubick@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Skia: https://bugs.chromium.org/p/skia/issues/entry To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPS b/DEPS index 769fec284f7fe..6d70141a7ddff 100644 --- a/DEPS +++ b/DEPS @@ -15,7 +15,7 @@ vars = { 'skia_git': 'https://skia.googlesource.com', 'llvm_git': 'https://llvm.googlesource.com', 'dart_ai_rev': '9c96bfe5f091c9451eff5b59c9bffeb2e806b875', - 'skia_revision': 'a1825062f83266f646d525d3592ad6eb391ec2dd', + 'skia_revision': '886302c7c68ed1d35f793b9c99cfdb57ba80e30a', # Do not download the Emscripten SDK by default. # This prevents us from downloading the Emscripten toolchain for builds From 1f2af336db8ad78eb07aabf3b8425c0a6bc10812 Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Sun, 23 Aug 2026 21:34:23 +0000 Subject: [PATCH 25/46] Remove Material/Cupertino references in error messages (#191305) This removes references to Material and Cupertino widgets in framework error messages, instead using the generic Widgets library versions. Fixes https://github.com/flutter/flutter/issues/190947 --- packages/flutter/lib/src/widgets/debug.dart | 6 +++--- packages/flutter/lib/src/widgets/focus_traversal.dart | 4 ++-- packages/flutter/lib/src/widgets/navigator.dart | 2 +- packages/flutter/lib/src/widgets/overlay.dart | 4 ++-- packages/flutter/lib/src/widgets/restoration.dart | 4 ++-- packages/flutter/lib/src/widgets/shared_app_data.dart | 4 ++-- packages/flutter/test/widgets/navigator_test.dart | 5 +++-- .../flutter/test/widgets/overlay_portal_test.dart | 2 +- packages/flutter/test/widgets/overlay_test.dart | 11 ++++------- packages/flutter/test/widgets/stack_test.dart | 2 +- 10 files changed, 21 insertions(+), 23 deletions(-) diff --git a/packages/flutter/lib/src/widgets/debug.dart b/packages/flutter/lib/src/widgets/debug.dart index 9e252ed494b94..232cb1d868d07 100644 --- a/packages/flutter/lib/src/widgets/debug.dart +++ b/packages/flutter/lib/src/widgets/debug.dart @@ -408,8 +408,8 @@ bool debugCheckHasDirectionality( ), context.describeOwnershipChain('The ownership chain for the affected widget is'), ErrorHint( - 'Typically, the Directionality widget is introduced by the MaterialApp ' - 'or WidgetsApp widget at the top of your application widget tree. It ' + 'Typically, the Directionality widget is introduced by the WidgetsApp ' + 'widget at the top of your application widget tree. It ' 'determines the ambient reading direction and is used, for example, to ' 'determine how to lay out text, how to interpret "start" and "end" ' 'values, and to resolve EdgeInsetsDirectional, ' @@ -544,7 +544,7 @@ bool debugCheckHasOverlay(BuildContext context) { ErrorHint( 'To introduce an Overlay widget, you can either directly ' 'include one, or use a widget that contains an Overlay itself, ' - 'such as a Navigator, WidgetApp, MaterialApp, or CupertinoApp.', + 'such as a Navigator or WidgetsApp.', ), ...context.describeMissingAncestor(expectedAncestorType: Overlay), ]); diff --git a/packages/flutter/lib/src/widgets/focus_traversal.dart b/packages/flutter/lib/src/widgets/focus_traversal.dart index 5e4458f9124ee..5d2e4a2d3b51d 100644 --- a/packages/flutter/lib/src/widgets/focus_traversal.dart +++ b/packages/flutter/lib/src/widgets/focus_traversal.dart @@ -2223,8 +2223,8 @@ class FocusTraversalGroup extends StatefulWidget { 'FocusTraversalGroup.of() was called with a context that does not contain a ' 'Focus or FocusScope widget, or there was no FocusTraversalPolicy in effect.\n' 'This can happen if there is not a FocusTraversalGroup that defines the policy, ' - 'or if the context comes from a widget that is above the WidgetsApp, MaterialApp, ' - 'or CupertinoApp widget (those widgets introduce an implicit default policy) \n' + 'or if the context comes from a widget that is above the WidgetsApp ' + 'widget (which introduces an implicit default policy)\n' 'The context used was:\n' ' $context', ); diff --git a/packages/flutter/lib/src/widgets/navigator.dart b/packages/flutter/lib/src/widgets/navigator.dart index 5d5a54c9dc3fe..edd6fe06ec142 100644 --- a/packages/flutter/lib/src/widgets/navigator.dart +++ b/packages/flutter/lib/src/widgets/navigator.dart @@ -655,7 +655,7 @@ abstract class Route extends _RoutePlaceholder { 'This usually happens when the type provided to Navigator.$methodName() ' 'is not a subtype of the type expected by the Route (e.g. DialogRoute), ' 'or when a generic type is explicitly provided to a route creation method ' - '(such as showDialog()) but the popped value does not match this type.', + '(such as showRawDialog()) but the popped value does not match this type.', ), DiagnosticsProperty>('The route was', this), DiagnosticsProperty('The provided result was', result), diff --git a/packages/flutter/lib/src/widgets/overlay.dart b/packages/flutter/lib/src/widgets/overlay.dart index 4494f82f4b750..7586afa9cc332 100644 --- a/packages/flutter/lib/src/widgets/overlay.dart +++ b/packages/flutter/lib/src/widgets/overlay.dart @@ -595,7 +595,7 @@ class Overlay extends StatefulWidget { '${debugRequiredFor?.runtimeType ?? 'Some'} widgets require an Overlay widget ancestor for correct operation.', ), ErrorHint( - 'The most common way to add an Overlay to an application is to include a MaterialApp, CupertinoApp or Navigator widget in the runApp() call.', + 'The most common way to add an Overlay to an application is to include a WidgetsApp or Navigator widget in the runApp() call.', ), if (debugRequiredFor != null) DiagnosticsProperty( @@ -2320,7 +2320,7 @@ class _RenderTheaterMarker extends InheritedWidget { ErrorHint( 'To introduce an Overlay widget, you can either directly ' 'include one, or use a widget that contains an Overlay itself, ' - 'such as a Navigator, WidgetApp, MaterialApp, or CupertinoApp.', + 'such as a Navigator or WidgetsApp.', ), ...context.describeMissingAncestor(expectedAncestorType: Overlay), ]); diff --git a/packages/flutter/lib/src/widgets/restoration.dart b/packages/flutter/lib/src/widgets/restoration.dart index 3d1f58c8d05e7..89ef41b32c013 100644 --- a/packages/flutter/lib/src/widgets/restoration.dart +++ b/packages/flutter/lib/src/widgets/restoration.dart @@ -119,8 +119,8 @@ class RestorationScope extends StatefulWidget { ), ErrorHint( 'State restoration must be enabled for a RestorationScope to exist. ' - 'This can be done by passing a restorationScopeId to MaterialApp, ' - 'CupertinoApp, or WidgetsApp at the root of the widget tree or by ' + 'This can be done by passing a restorationScopeId to WidgetsApp ' + 'at the root of the widget tree or by ' 'wrapping the widget tree in a RootRestorationScope.', ), ]); diff --git a/packages/flutter/lib/src/widgets/shared_app_data.dart b/packages/flutter/lib/src/widgets/shared_app_data.dart index c5f29d0c79797..2383cebabb052 100644 --- a/packages/flutter/lib/src/widgets/shared_app_data.dart +++ b/packages/flutter/lib/src/widgets/shared_app_data.dart @@ -152,8 +152,8 @@ class SharedAppData extends StatefulWidget { ), context.describeOwnershipChain('The ownership chain for the affected widget is'), ErrorHint( - 'Typically, the SharedAppData widget is introduced by the MaterialApp ' - 'or WidgetsApp widget at the top of your application widget tree. It ' + 'Typically, the SharedAppData widget is introduced by the WidgetsApp ' + 'widget at the top of your application widget tree. It ' 'provides a key/value map of data that is shared with the entire ' 'application.', ), diff --git a/packages/flutter/test/widgets/navigator_test.dart b/packages/flutter/test/widgets/navigator_test.dart index c0f8280c2f6f9..adb5217ca7587 100644 --- a/packages/flutter/test/widgets/navigator_test.dart +++ b/packages/flutter/test/widgets/navigator_test.dart @@ -6466,7 +6466,7 @@ void main() { ' This usually happens when the type provided to Navigator.pop() is\n' ' not a subtype of the type expected by the Route (e.g.\n' ' DialogRoute), or when a generic type is explicitly provided\n' - ' to a route creation method (such as showDialog()) but the\n' + ' to a route creation method (such as showRawDialog()) but the\n' ' popped value does not match this type.\n' ' The route was: PageRouteBuilder(RouteSettings(none, null),\n' ' animation: AnimationController#00000(⏭ 1.000; paused; for\n' @@ -6531,7 +6531,8 @@ void main() { ' Navigator.maybePop() is not a subtype of the type expected by the\n' ' Route (e.g. DialogRoute), or when a generic type is\n' ' explicitly provided to a route creation method (such as\n' - ' showDialog()) but the popped value does not match this type.\n' + ' showRawDialog()) but the popped value does not match this\n' + ' type.\n' ' The route was: PageRouteBuilder(RouteSettings(none, null),\n' ' animation: AnimationController#00000(⏭ 1.000; paused; for\n' ' PageRouteBuilder))\n' diff --git a/packages/flutter/test/widgets/overlay_portal_test.dart b/packages/flutter/test/widgets/overlay_portal_test.dart index ce1bedb7fd52b..04f2152e7912f 100644 --- a/packages/flutter/test/widgets/overlay_portal_test.dart +++ b/packages/flutter/test/widgets/overlay_portal_test.dart @@ -1015,7 +1015,7 @@ void main() { 'OverlayPortal widgets require an Overlay widget ancestor.\n' 'An overlay lets widgets float on top of other widget children.\n' 'To introduce an Overlay widget, you can either directly include one, or use a widget ' - 'that contains an Overlay itself, such as a Navigator, WidgetApp, MaterialApp, or CupertinoApp.\n' + 'that contains an Overlay itself, such as a Navigator or WidgetsApp.\n' 'The specific widget that could not find a Overlay ancestor was:\n', ), ); diff --git a/packages/flutter/test/widgets/overlay_test.dart b/packages/flutter/test/widgets/overlay_test.dart index 6257a1c079213..1eccce1c87f0b 100644 --- a/packages/flutter/test/widgets/overlay_test.dart +++ b/packages/flutter/test/widgets/overlay_test.dart @@ -883,8 +883,7 @@ void main() { error.diagnostics[2].toStringDeep(), equalsIgnoringHashCodes( 'The most common way to add an Overlay to an application is to\n' - 'include a MaterialApp, CupertinoApp or Navigator widget in the\n' - 'runApp() call.\n', + 'include a WidgetsApp or Navigator widget in the runApp() call.\n', ), ); expect(error.diagnostics[3], isA>()); @@ -898,8 +897,7 @@ void main() { ' Container widgets require an Overlay widget ancestor for correct\n' ' operation.\n' ' The most common way to add an Overlay to an application is to\n' - ' include a MaterialApp, CupertinoApp or Navigator widget in the\n' - ' runApp() call.\n' + ' include a WidgetsApp or Navigator widget in the runApp() call.\n' ' The specific widget that failed to find an overlay was:\n' ' Container\n' ' The context from which that widget was searching for an overlay\n' @@ -1675,8 +1673,7 @@ void main() { ' Some widgets require an Overlay widget ancestor for correct\n' ' operation.\n' ' The most common way to add an Overlay to an application is to\n' - ' include a MaterialApp, CupertinoApp or Navigator widget in the\n' - ' runApp() call.\n' + ' include a WidgetsApp or Navigator widget in the runApp() call.\n' ' The context from which that widget was searching for an overlay\n' ' was:\n' ' Builder\n', @@ -1728,7 +1725,7 @@ void main() { ' An overlay lets widgets float on top of other widget children.\n' ' To introduce an Overlay widget, you can either directly include\n' ' one, or use a widget that contains an Overlay itself, such as a\n' - ' Navigator, WidgetApp, MaterialApp, or CupertinoApp.\n' + ' Navigator or WidgetsApp.\n' ' The specific widget that could not find a Overlay ancestor was:\n' ' Builder\n' ' The ancestors of this widget were:\n' diff --git a/packages/flutter/test/widgets/stack_test.dart b/packages/flutter/test/widgets/stack_test.dart index 6e4391a86a668..91455e4609047 100644 --- a/packages/flutter/test/widgets/stack_test.dart +++ b/packages/flutter/test/widgets/stack_test.dart @@ -746,7 +746,7 @@ void main() { exception, endsWith( '_ViewScope ← ⋯"\n' // End of ownership chain. - 'Typically, the Directionality widget is introduced by the MaterialApp or WidgetsApp widget at the ' + 'Typically, the Directionality widget is introduced by the WidgetsApp widget at the ' 'top of your application widget tree. It determines the ambient reading direction and is used, for ' 'example, to determine how to lay out text, how to interpret "start" and "end" values, and to resolve ' 'EdgeInsetsDirectional, AlignmentDirectional, and other *Directional objects.\n' From 5f97c7f836bbb52822ee70e84ca46ff9fe654618 Mon Sep 17 00:00:00 2001 From: gaaclarke <30870216+gaaclarke@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:16:47 +0000 Subject: [PATCH 26/46] Fixes MSAA on the linux embedder. (#191379) fixes https://github.com/flutter/flutter/issues/191171 This is the linux equivalent of the windows change: https://github.com/flutter/flutter/pull/190256 On desktop we normally need to have an explicit resolution step for MSAA to work, this adds that explicit step. On linux we were already blitting from this framebuffer so we didn't need to provide an explicit resolve step, it already existed. I also removed code about using bgra. This was impeding our usage of MSAA render buffers, on linux I don't think there is a reason we have to use BGRA. ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [AI contribution guidelines] and understand my responsibilities, or I am not using AI tools. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. If this change needs to override an active code freeze, provide a comment explaining why. The code freeze workflow can be overridden by code reviewers. See pinned issues for any active code freezes with guidance. **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [AI contribution guidelines]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --- .../src/flutter/shell/platform/linux/BUILD.gn | 1 + .../flutter/shell/platform/linux/fl_engine.cc | 12 +- .../shell/platform/linux/fl_engine_test.cc | 122 ++++++++++++++ .../shell/platform/linux/fl_framebuffer.cc | 150 ++++++++++++++--- .../shell/platform/linux/fl_framebuffer.h | 29 ++++ .../platform/linux/fl_framebuffer_test.cc | 153 ++++++++++++++++++ .../shell/platform/linux/fl_opengl_frame.cc | 5 +- .../platform/linux/fl_opengl_frame_test.cc | 135 ++++++++++++++++ .../platform/linux/testing/mock_epoxy.cc | 93 ++++++++++- .../shell/platform/linux/testing/mock_epoxy.h | 51 ++++++ 10 files changed, 715 insertions(+), 36 deletions(-) create mode 100644 engine/src/flutter/shell/platform/linux/fl_opengl_frame_test.cc diff --git a/engine/src/flutter/shell/platform/linux/BUILD.gn b/engine/src/flutter/shell/platform/linux/BUILD.gn index e8098a71d4eb2..ee30fbcc123c8 100644 --- a/engine/src/flutter/shell/platform/linux/BUILD.gn +++ b/engine/src/flutter/shell/platform/linux/BUILD.gn @@ -255,6 +255,7 @@ executable("flutter_linux_unittests") { "fl_method_codec_test.cc", "fl_method_response_test.cc", "fl_mouse_cursor_handler_test.cc", + "fl_opengl_frame_test.cc", "fl_pixel_buffer_texture_test.cc", "fl_platform_channel_test.cc", "fl_platform_handler_test.cc", diff --git a/engine/src/flutter/shell/platform/linux/fl_engine.cc b/engine/src/flutter/shell/platform/linux/fl_engine.cc index f88d08cb05f47..e7e175bd18740 100644 --- a/engine/src/flutter/shell/platform/linux/fl_engine.cc +++ b/engine/src/flutter/shell/platform/linux/fl_engine.cc @@ -287,15 +287,16 @@ static bool create_opengl_backing_store( return false; } - GLint sized_format = GL_RGBA8; GLint general_format = GL_RGBA; + GLint sized_format = GL_RGBA8; if (epoxy_has_gl_extension("GL_EXT_texture_format_BGRA8888")) { - sized_format = GL_BGRA8_EXT; general_format = GL_BGRA_EXT; + sized_format = GL_BGRA8_EXT; } - FlFramebuffer* framebuffer = fl_framebuffer_new( - general_format, config->size.width, config->size.height, FALSE); + FlFramebuffer* framebuffer = fl_framebuffer_new_multisample( + general_format, config->size.width, config->size.height, + fl_dart_project_get_enable_impeller(self->project)); if (!framebuffer) { g_warning("Failed to create backing store"); return false; @@ -306,7 +307,8 @@ static bool create_opengl_backing_store( backing_store_out->open_gl.framebuffer.user_data = framebuffer; backing_store_out->open_gl.framebuffer.name = fl_framebuffer_get_id(framebuffer); - backing_store_out->open_gl.framebuffer.target = sized_format; + backing_store_out->open_gl.framebuffer.target = + fl_framebuffer_get_texture_id(framebuffer) != 0 ? sized_format : GL_RGBA8; backing_store_out->open_gl.framebuffer.destruction_callback = [](void* p) { // Backing store destroyed in fl_compositor_opengl_collect_backing_store(), // set on FlutterCompositor.collect_backing_store_callback during engine diff --git a/engine/src/flutter/shell/platform/linux/fl_engine_test.cc b/engine/src/flutter/shell/platform/linux/fl_engine_test.cc index dd2528e248223..990cd1f253e0c 100644 --- a/engine/src/flutter/shell/platform/linux/fl_engine_test.cc +++ b/engine/src/flutter/shell/platform/linux/fl_engine_test.cc @@ -8,9 +8,11 @@ #include "flutter/shell/platform/embedder/test_utils/proc_table_replacement.h" #include "flutter/shell/platform/linux/fl_engine_private.h" +#include "flutter/shell/platform/linux/fl_framebuffer.h" #include "flutter/shell/platform/linux/public/flutter_linux/fl_engine.h" #include "flutter/shell/platform/linux/public/flutter_linux/fl_json_message_codec.h" #include "flutter/shell/platform/linux/public/flutter_linux/fl_string_codec.h" +#include "flutter/shell/platform/linux/testing/mock_epoxy.h" #include "flutter/shell/platform/linux/testing/mock_renderable.h" // MOCK_ENGINE_PROC is leaky by design @@ -1060,6 +1062,126 @@ TEST_F(FlEngineTest, EnableFlutterGpu) { EXPECT_TRUE(called); } +namespace { + +// Helper to start the engine with Impeller enabled, create an OpenGL backing +// store, verify its target and texture presence, and collect it. +void test_impeller_backing_store(FlEngine* engine, + FlDartProject* project, + uint32_t expected_target, + bool expect_texture) { + FlutterCompositor compositor = {}; + fl_engine_get_embedder_api(engine)->Initialize = MOCK_ENGINE_PROC( + Initialize, + ([&compositor](size_t version, const FlutterRendererConfig* config, + const FlutterProjectArgs* args, void* user_data, + FLUTTER_API_SYMBOL(FlutterEngine) * engine_out) { + if (args->compositor != nullptr) { + compositor = *args->compositor; + } + return kSuccess; + })); + fl_engine_get_embedder_api(engine)->RunInitialized = + MOCK_ENGINE_PROC(RunInitialized, ([](auto engine) { return kSuccess; })); + + fl_dart_project_set_enable_impeller(project, TRUE); + + g_autoptr(GError) error = nullptr; + EXPECT_TRUE(fl_engine_start(engine, &error)); + EXPECT_EQ(error, nullptr); + ASSERT_NE(compositor.create_backing_store_callback, nullptr); + + FlutterBackingStoreConfig config = { + .struct_size = sizeof(FlutterBackingStoreConfig), + .size = {.width = 800.0, .height = 600.0}, + }; + FlutterBackingStore backing_store = {}; + EXPECT_TRUE(compositor.create_backing_store_callback(&config, &backing_store, + compositor.user_data)); + EXPECT_EQ(backing_store.type, kFlutterBackingStoreTypeOpenGL); + EXPECT_EQ(backing_store.open_gl.type, kFlutterOpenGLTargetTypeFramebuffer); + EXPECT_EQ(backing_store.open_gl.framebuffer.target, expected_target); + + FlFramebuffer* fb = + FL_FRAMEBUFFER(backing_store.open_gl.framebuffer.user_data); + EXPECT_NE(fb, nullptr); + if (expect_texture) { + EXPECT_NE(fl_framebuffer_get_texture_id(fb), 0u); + } else { + EXPECT_EQ(fl_framebuffer_get_texture_id(fb), 0u); + } + + EXPECT_TRUE(compositor.collect_backing_store_callback(&backing_store, + compositor.user_data)); +} + +} // namespace + +TEST_F(FlEngineTest, CreateOpenGLBackingStoreWithImpellerMSAA) { + ::testing::NiceMock epoxy; + ON_CALL(epoxy, epoxy_gl_version).WillByDefault(::testing::Return(30)); + ON_CALL(epoxy, epoxy_has_gl_extension(::testing::_)) + .WillByDefault(::testing::Return(false)); + ON_CALL(epoxy, glGetIntegerv(GL_MAX_SAMPLES, ::testing::_)) + .WillByDefault(::testing::SetArgPointee<1>(4)); + + EXPECT_CALL(epoxy, glRenderbufferStorageMultisample(GL_RENDERBUFFER, 4, + GL_RGBA8, 800, 600)); + EXPECT_CALL(epoxy, glRenderbufferStorageMultisample( + GL_RENDERBUFFER, 4, GL_DEPTH24_STENCIL8, 800, 600)); + EXPECT_CALL(epoxy, + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, + GL_RENDERBUFFER, ::testing::_)); + EXPECT_CALL(epoxy, + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, + GL_RENDERBUFFER, ::testing::_)); + EXPECT_CALL(epoxy, + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_STENCIL_ATTACHMENT, + GL_RENDERBUFFER, ::testing::_)); + + test_impeller_backing_store(engine, project, GL_RGBA8, + /*expect_texture=*/false); +} + +TEST_F(FlEngineTest, CreateOpenGLBackingStoreWithImpellerMSAAAndBgraExtension) { + ::testing::NiceMock epoxy; + ON_CALL(epoxy, epoxy_gl_version).WillByDefault(::testing::Return(30)); + ON_CALL(epoxy, epoxy_has_gl_extension(::testing::_)) + .WillByDefault(::testing::Return(false)); + ON_CALL(epoxy, epoxy_has_gl_extension( + ::testing::StrEq("GL_EXT_texture_format_BGRA8888"))) + .WillByDefault(::testing::Return(true)); + ON_CALL(epoxy, glGetIntegerv(GL_MAX_SAMPLES, ::testing::_)) + .WillByDefault(::testing::SetArgPointee<1>(4)); + + // GL_EXT_texture_format_BGRA8888 only defines BGRA for textures, not + // renderbuffers. When explicit offscreen MSAA is used without + // GL_EXT_multisampled_render_to_texture, a multisample renderbuffer is + // created which must use GL_RGBA8. + EXPECT_CALL(epoxy, glRenderbufferStorageMultisample(GL_RENDERBUFFER, 4, + GL_RGBA8, 800, 600)); + EXPECT_CALL(epoxy, glRenderbufferStorageMultisample( + GL_RENDERBUFFER, 4, GL_DEPTH24_STENCIL8, 800, 600)); + + test_impeller_backing_store(engine, project, GL_RGBA8, + /*expect_texture=*/false); +} + +TEST_F(FlEngineTest, CreateOpenGLBackingStoreWithImplicitMSAAAndBgraExtension) { + ::testing::NiceMock epoxy; + ON_CALL(epoxy, epoxy_has_gl_extension(::testing::_)) + .WillByDefault(::testing::Return(false)); + ON_CALL(epoxy, epoxy_has_gl_extension( + ::testing::StrEq("GL_EXT_texture_format_BGRA8888"))) + .WillByDefault(::testing::Return(true)); + ON_CALL(epoxy, epoxy_has_gl_extension( + ::testing::StrEq("GL_EXT_multisampled_render_to_texture"))) + .WillByDefault(::testing::Return(true)); + + test_impeller_backing_store(engine, project, GL_BGRA8_EXT, + /*expect_texture=*/true); +} + TEST_F(FlEngineTest, ChildObjects) { // Check objects exist before engine started. EXPECT_NE(fl_engine_get_binary_messenger(engine), nullptr); diff --git a/engine/src/flutter/shell/platform/linux/fl_framebuffer.cc b/engine/src/flutter/shell/platform/linux/fl_framebuffer.cc index 59d467f5ed543..dac28ef40b477 100644 --- a/engine/src/flutter/shell/platform/linux/fl_framebuffer.cc +++ b/engine/src/flutter/shell/platform/linux/fl_framebuffer.cc @@ -24,7 +24,10 @@ struct _FlFramebuffer { // Texture backing framebuffer. GLuint texture_id; - // Stencil buffer associated with this framebuffer. + // Color renderbuffer backing framebuffer (if using offscreen MSAA). + GLuint color_renderbuffer; + + // Depth and stencil renderbuffer associated with this framebuffer. GLuint depth_stencil; // EGL image for this texture. @@ -36,9 +39,22 @@ G_DEFINE_TYPE(FlFramebuffer, fl_framebuffer, G_TYPE_OBJECT) static void fl_framebuffer_dispose(GObject* object) { FlFramebuffer* self = FL_FRAMEBUFFER(object); - glDeleteFramebuffers(1, &self->framebuffer_id); - glDeleteTextures(1, &self->texture_id); - glDeleteRenderbuffers(1, &self->depth_stencil); + if (self->framebuffer_id != 0) { + glDeleteFramebuffers(1, &self->framebuffer_id); + self->framebuffer_id = 0; + } + if (self->texture_id != 0) { + glDeleteTextures(1, &self->texture_id); + self->texture_id = 0; + } + if (self->color_renderbuffer != 0) { + glDeleteRenderbuffers(1, &self->color_renderbuffer); + self->color_renderbuffer = 0; + } + if (self->depth_stencil != 0) { + glDeleteRenderbuffers(1, &self->depth_stencil); + self->depth_stencil = 0; + } g_clear_object(&self->image); G_OBJECT_CLASS(fl_framebuffer_parent_class)->dispose(object); @@ -50,6 +66,40 @@ static void fl_framebuffer_class_init(FlFramebufferClass* klass) { static void fl_framebuffer_init(FlFramebuffer* self) {} +static bool check_supports_implicit_msaa() { + return epoxy_has_gl_extension("GL_EXT_multisampled_render_to_texture"); +} + +static bool check_supports_offscreen_msaa() { + if (epoxy_gl_version() >= 30) { + GLint max_samples = 0; + glGetIntegerv(GL_MAX_SAMPLES, &max_samples); + return max_samples >= 4; + } + return false; +} + +static GLuint create_texture(GLint format, size_t width, size_t height) { + GLuint texture_id; + glGenTextures(1, &texture_id); + glBindTexture(GL_TEXTURE_2D, texture_id); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexImage2D(GL_TEXTURE_2D, 0, format, width, height, 0, format, + GL_UNSIGNED_BYTE, nullptr); + glBindTexture(GL_TEXTURE_2D, 0); + return texture_id; +} + +static void attach_depth_stencil(GLuint depth_stencil) { + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, + GL_RENDERBUFFER, depth_stencil); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_STENCIL_ATTACHMENT, + GL_RENDERBUFFER, depth_stencil); +} + FlFramebuffer* fl_framebuffer_new(GLint format, size_t width, size_t height, @@ -60,19 +110,10 @@ FlFramebuffer* fl_framebuffer_new(GLint format, self->width = width; self->height = height; - glGenTextures(1, &self->texture_id); glGenFramebuffers(1, &self->framebuffer_id); - glBindFramebuffer(GL_FRAMEBUFFER, self->framebuffer_id); - glBindTexture(GL_TEXTURE_2D, self->texture_id); - glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); - glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - glTexImage2D(GL_TEXTURE_2D, 0, format, width, height, 0, format, - GL_UNSIGNED_BYTE, NULL); - glBindTexture(GL_TEXTURE_2D, 0); + self->texture_id = create_texture(format, width, height); if (shareable) { self->image = fl_egl_image_new(self->texture_id); @@ -83,15 +124,78 @@ FlFramebuffer* fl_framebuffer_new(GLint format, glGenRenderbuffers(1, &self->depth_stencil); glBindRenderbuffer(GL_RENDERBUFFER, self->depth_stencil); - glRenderbufferStorage(GL_RENDERBUFFER, // target - GL_DEPTH24_STENCIL8, // internal format - width, // width - height // height - ); - glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, - GL_RENDERBUFFER, self->depth_stencil); - glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_STENCIL_ATTACHMENT, - GL_RENDERBUFFER, self->depth_stencil); + glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, width, height); + attach_depth_stencil(self->depth_stencil); + + return self; +} + +FlFramebuffer* fl_framebuffer_new_multisample(GLint format, + size_t width, + size_t height, + gboolean use_msaa) { + FlFramebuffer* self = + FL_FRAMEBUFFER(g_object_new(fl_framebuffer_get_type(), nullptr)); + + self->width = width; + self->height = height; + + glGenFramebuffers(1, &self->framebuffer_id); + glBindFramebuffer(GL_FRAMEBUFFER, self->framebuffer_id); + + if (use_msaa) { + if (check_supports_implicit_msaa()) { + // Implicit MSAA uses GL_EXT_multisampled_render_to_texture, where the + // OpenGL driver automatically resolves multisamples into the attached + // texture when rendering is completed. + self->texture_id = create_texture(format, width, height); + glFramebufferTexture2DMultisampleEXT(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, + GL_TEXTURE_2D, self->texture_id, 0, + 4); + + glGenRenderbuffers(1, &self->depth_stencil); + glBindRenderbuffer(GL_RENDERBUFFER, self->depth_stencil); + glRenderbufferStorageMultisampleEXT(GL_RENDERBUFFER, 4, + GL_DEPTH24_STENCIL8, width, height); + attach_depth_stencil(self->depth_stencil); + } else if (check_supports_offscreen_msaa()) { + // Offscreen MSAA uses a multisample renderbuffer instead of a texture. + // The multisample resolve occurs in fl_compositor_opengl_composite_layers + // via glBlitFramebuffer (or composite_layer) into the compositor's + // single-sample framebuffer texture. + glGenRenderbuffers(1, &self->color_renderbuffer); + glBindRenderbuffer(GL_RENDERBUFFER, self->color_renderbuffer); + glRenderbufferStorageMultisample(GL_RENDERBUFFER, 4, GL_RGBA8, width, + height); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, + GL_RENDERBUFFER, self->color_renderbuffer); + + glGenRenderbuffers(1, &self->depth_stencil); + glBindRenderbuffer(GL_RENDERBUFFER, self->depth_stencil); + glRenderbufferStorageMultisample(GL_RENDERBUFFER, 4, GL_DEPTH24_STENCIL8, + width, height); + attach_depth_stencil(self->depth_stencil); + } else { + self->texture_id = create_texture(format, width, height); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, + GL_TEXTURE_2D, self->texture_id, 0); + + glGenRenderbuffers(1, &self->depth_stencil); + glBindRenderbuffer(GL_RENDERBUFFER, self->depth_stencil); + glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, width, + height); + attach_depth_stencil(self->depth_stencil); + } + } else { + self->texture_id = create_texture(format, width, height); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, + self->texture_id, 0); + + glGenRenderbuffers(1, &self->depth_stencil); + glBindRenderbuffer(GL_RENDERBUFFER, self->depth_stencil); + glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, width, height); + attach_depth_stencil(self->depth_stencil); + } return self; } diff --git a/engine/src/flutter/shell/platform/linux/fl_framebuffer.h b/engine/src/flutter/shell/platform/linux/fl_framebuffer.h index 9192e5476a4c0..2bebdbc4a9bc4 100644 --- a/engine/src/flutter/shell/platform/linux/fl_framebuffer.h +++ b/engine/src/flutter/shell/platform/linux/fl_framebuffer.h @@ -36,6 +36,35 @@ FlFramebuffer* fl_framebuffer_new(GLint format, size_t height, gboolean shareable); +/** + * fl_framebuffer_new_multisample: + * @format: texture format, e.g. GL_RGBA, GL_BGRA_EXT. + * @width: width of framebuffer in pixels. + * @height: height of framebuffer in pixels. + * @use_msaa: %TRUE to enable multisample anti-aliasing (MSAA) attachments. + * + * Creates a new frame buffer, configuring MSAA attachments if supported and + * @use_msaa is %TRUE. + * + * When @use_msaa is %TRUE, the framebuffer will configure 4x MSAA attachments + * using either implicit MSAA (multisampled texture via + * GL_EXT_multisampled_render_to_texture) with the requested @format or + * offscreen MSAA (multisample color renderbuffer). If offscreen MSAA is used, + * the framebuffer is backed by a GL_RGBA8 renderbuffer instead of a texture + * (fl_framebuffer_get_texture_id() returns 0) and must be resolved into a + * single-sample texture via glBlitFramebuffer before compositing. + * + * If @use_msaa is %FALSE or MSAA is not supported by the OpenGL context, a + * standard single-sample texture with @format and depth/stencil renderbuffer + * are created. + * + * Returns: a new #FlFramebuffer. + */ +FlFramebuffer* fl_framebuffer_new_multisample(GLint format, + size_t width, + size_t height, + gboolean use_msaa); + /** * fl_framebuffer_get_shareable: * @framebuffer: an #FlFramebuffer. diff --git a/engine/src/flutter/shell/platform/linux/fl_framebuffer_test.cc b/engine/src/flutter/shell/platform/linux/fl_framebuffer_test.cc index 0f8c27b6a535b..557066ea46091 100644 --- a/engine/src/flutter/shell/platform/linux/fl_framebuffer_test.cc +++ b/engine/src/flutter/shell/platform/linux/fl_framebuffer_test.cc @@ -48,3 +48,156 @@ TEST_F(FlFramebufferTest, Sibling) { fl_framebuffer_new(GL_RGB, 100, 100, TRUE); g_autoptr(FlFramebuffer) sibling = fl_framebuffer_create_sibling(framebuffer); } + +TEST_F(FlFramebufferTest, ImpellerOffscreenMSAA) { + ON_CALL(epoxy, epoxy_gl_version).WillByDefault(::testing::Return(30)); + ON_CALL(epoxy, epoxy_has_gl_extension(::testing::_)) + .WillByDefault(::testing::Return(false)); + ON_CALL(epoxy, glGetIntegerv(GL_MAX_SAMPLES, ::testing::_)) + .WillByDefault(::testing::SetArgPointee<1>(4)); + + EXPECT_CALL(epoxy, glRenderbufferStorageMultisample(GL_RENDERBUFFER, 4, + GL_RGBA8, 100, 100)); + EXPECT_CALL(epoxy, glRenderbufferStorageMultisample( + GL_RENDERBUFFER, 4, GL_DEPTH24_STENCIL8, 100, 100)); + EXPECT_CALL(epoxy, + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, + GL_RENDERBUFFER, ::testing::_)); + EXPECT_CALL(epoxy, + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, + GL_RENDERBUFFER, ::testing::_)); + EXPECT_CALL(epoxy, + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_STENCIL_ATTACHMENT, + GL_RENDERBUFFER, ::testing::_)); + + FlFramebuffer* framebuffer = + fl_framebuffer_new_multisample(GL_RGBA, 100, 100, /*use_msaa=*/TRUE); + EXPECT_EQ(fl_framebuffer_get_texture_id(framebuffer), 0u); + + EXPECT_CALL(epoxy, glDeleteFramebuffers); + EXPECT_CALL(epoxy, glDeleteTextures).Times(0); + EXPECT_CALL(epoxy, glDeleteRenderbuffers).Times(2); + g_object_unref(framebuffer); +} + +TEST_F(FlFramebufferTest, ImpellerImplicitMSAA) { + ON_CALL(epoxy, epoxy_has_gl_extension( + ::testing::StrEq("GL_EXT_multisampled_render_to_texture"))) + .WillByDefault(::testing::Return(true)); + + EXPECT_CALL(epoxy, glGenTextures); + EXPECT_CALL(epoxy, glFramebufferTexture2DMultisampleEXT( + GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, + ::testing::_, 0, 4)); + EXPECT_CALL(epoxy, glRenderbufferStorageMultisampleEXT( + GL_RENDERBUFFER, 4, GL_DEPTH24_STENCIL8, 100, 100)); + + FlFramebuffer* framebuffer = + fl_framebuffer_new_multisample(GL_RGBA, 100, 100, /*use_msaa=*/TRUE); + EXPECT_NE(fl_framebuffer_get_texture_id(framebuffer), 0u); + + EXPECT_CALL(epoxy, glDeleteFramebuffers); + EXPECT_CALL(epoxy, glDeleteTextures); + EXPECT_CALL(epoxy, glDeleteRenderbuffers); + g_object_unref(framebuffer); +} + +TEST_F(FlFramebufferTest, ImpellerNoMSAA) { + ON_CALL(epoxy, epoxy_gl_version).WillByDefault(::testing::Return(20)); + ON_CALL(epoxy, epoxy_has_gl_extension(::testing::_)) + .WillByDefault(::testing::Return(false)); + + EXPECT_CALL(epoxy, glGenTextures); + EXPECT_CALL(epoxy, + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, + GL_TEXTURE_2D, ::testing::_, 0)); + EXPECT_CALL(epoxy, glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, + 100, 100)); + + FlFramebuffer* framebuffer = + fl_framebuffer_new_multisample(GL_RGBA, 100, 100, /*use_msaa=*/TRUE); + EXPECT_NE(fl_framebuffer_get_texture_id(framebuffer), 0u); + + EXPECT_CALL(epoxy, glDeleteFramebuffers); + EXPECT_CALL(epoxy, glDeleteTextures); + EXPECT_CALL(epoxy, glDeleteRenderbuffers); + g_object_unref(framebuffer); +} + +TEST_F(FlFramebufferTest, SkiaNoMSAA) { + ON_CALL(epoxy, epoxy_gl_version).WillByDefault(::testing::Return(30)); + ON_CALL(epoxy, glGetIntegerv(GL_MAX_SAMPLES, ::testing::_)) + .WillByDefault(::testing::SetArgPointee<1>(4)); + + EXPECT_CALL(epoxy, glGenTextures); + EXPECT_CALL(epoxy, + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, + GL_TEXTURE_2D, ::testing::_, 0)); + EXPECT_CALL(epoxy, glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, + 100, 100)); + + FlFramebuffer* framebuffer = + fl_framebuffer_new_multisample(GL_RGBA, 100, 100, /*use_msaa=*/FALSE); + EXPECT_NE(fl_framebuffer_get_texture_id(framebuffer), 0u); + + EXPECT_CALL(epoxy, glDeleteFramebuffers); + EXPECT_CALL(epoxy, glDeleteTextures); + EXPECT_CALL(epoxy, glDeleteRenderbuffers); + g_object_unref(framebuffer); +} + +TEST_F(FlFramebufferTest, ImpellerOffscreenMSAABgra) { + ON_CALL(epoxy, epoxy_gl_version).WillByDefault(::testing::Return(30)); + ON_CALL(epoxy, glGetIntegerv(GL_MAX_SAMPLES, ::testing::_)) + .WillByDefault(::testing::SetArgPointee<1>(4)); + + EXPECT_CALL(epoxy, glGenRenderbuffers).Times(2); + // GL_EXT_texture_format_BGRA8888 only defines BGRA for textures, not + // renderbuffers. When explicit offscreen MSAA is used without + // GL_EXT_multisampled_render_to_texture, a multisample renderbuffer is + // created which must use GL_RGBA8. + EXPECT_CALL(epoxy, glRenderbufferStorageMultisample(GL_RENDERBUFFER, 4, + GL_RGBA8, 100, 100)); + EXPECT_CALL(epoxy, glRenderbufferStorageMultisample( + GL_RENDERBUFFER, 4, GL_DEPTH24_STENCIL8, 100, 100)); + EXPECT_CALL(epoxy, + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, + GL_RENDERBUFFER, ::testing::_)); + EXPECT_CALL(epoxy, + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, + GL_RENDERBUFFER, ::testing::_)); + EXPECT_CALL(epoxy, + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_STENCIL_ATTACHMENT, + GL_RENDERBUFFER, ::testing::_)); + + FlFramebuffer* framebuffer = + fl_framebuffer_new_multisample(GL_BGRA_EXT, 100, 100, /*use_msaa=*/TRUE); + EXPECT_EQ(fl_framebuffer_get_texture_id(framebuffer), 0u); + + EXPECT_CALL(epoxy, glDeleteFramebuffers); + EXPECT_CALL(epoxy, glDeleteTextures).Times(0); + EXPECT_CALL(epoxy, glDeleteRenderbuffers).Times(2); + g_object_unref(framebuffer); +} + +TEST_F(FlFramebufferTest, ImpellerImplicitMSAABgra) { + ON_CALL(epoxy, epoxy_has_gl_extension( + ::testing::StrEq("GL_EXT_multisampled_render_to_texture"))) + .WillByDefault(::testing::Return(true)); + + EXPECT_CALL(epoxy, glGenTextures); + EXPECT_CALL(epoxy, glFramebufferTexture2DMultisampleEXT( + GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, + ::testing::_, 0, 4)); + EXPECT_CALL(epoxy, glRenderbufferStorageMultisampleEXT( + GL_RENDERBUFFER, 4, GL_DEPTH24_STENCIL8, 100, 100)); + + FlFramebuffer* framebuffer = + fl_framebuffer_new_multisample(GL_BGRA_EXT, 100, 100, /*use_msaa=*/TRUE); + EXPECT_NE(fl_framebuffer_get_texture_id(framebuffer), 0u); + + EXPECT_CALL(epoxy, glDeleteFramebuffers); + EXPECT_CALL(epoxy, glDeleteTextures); + EXPECT_CALL(epoxy, glDeleteRenderbuffers); + g_object_unref(framebuffer); +} diff --git a/engine/src/flutter/shell/platform/linux/fl_opengl_frame.cc b/engine/src/flutter/shell/platform/linux/fl_opengl_frame.cc index cb9c793caacb5..528df86bc3d55 100644 --- a/engine/src/flutter/shell/platform/linux/fl_opengl_frame.cc +++ b/engine/src/flutter/shell/platform/linux/fl_opengl_frame.cc @@ -83,7 +83,10 @@ void fl_opengl_frame_composite(FlOpenGLFrame* self, fl_framebuffer_get_width(self->framebuffer) != width || fl_framebuffer_get_height(self->framebuffer) != height) { GLint general_format = GL_RGBA; - if (epoxy_has_gl_extension("GL_EXT_texture_format_BGRA8888")) { + if (layers[0]->type == kFlutterLayerContentTypeBackingStore && + layers[0]->backing_store != nullptr && + layers[0]->backing_store->type == kFlutterBackingStoreTypeOpenGL && + layers[0]->backing_store->open_gl.framebuffer.target == GL_BGRA8_EXT) { general_format = GL_BGRA_EXT; } g_clear_object(&self->framebuffer); diff --git a/engine/src/flutter/shell/platform/linux/fl_opengl_frame_test.cc b/engine/src/flutter/shell/platform/linux/fl_opengl_frame_test.cc new file mode 100644 index 0000000000000..b986ade36ffee --- /dev/null +++ b/engine/src/flutter/shell/platform/linux/fl_opengl_frame_test.cc @@ -0,0 +1,135 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "flutter/shell/platform/linux/fl_opengl_frame.h" + +#include + +#include "flutter/shell/platform/linux/fl_compositor_opengl.h" +#include "flutter/shell/platform/linux/fl_framebuffer.h" +#include "flutter/shell/platform/linux/fl_opengl_manager.h" +#include "flutter/shell/platform/linux/testing/linux_test.h" +#include "flutter/shell/platform/linux/testing/mock_epoxy.h" +#include "gtest/gtest.h" + +class FlOpenGLFrameTest : public flutter::testing::LinuxTest { + protected: + void SetUp() override { + opengl_manager = fl_opengl_manager_new(); + compositor = fl_compositor_opengl_new(opengl_manager); + } + + ~FlOpenGLFrameTest() override { + g_clear_object(&compositor); + g_clear_object(&opengl_manager); + } + + ::testing::NiceMock epoxy; + FlOpenGLManager* opengl_manager = nullptr; + FlCompositorOpenGL* compositor = nullptr; +}; + +TEST_F(FlOpenGLFrameTest, GetSizeInitiallyZero) { + g_autoptr(FlOpenGLFrame) frame = fl_opengl_frame_new(/*shareable=*/TRUE); + size_t frame_width = 123; + size_t frame_height = 456; + fl_opengl_frame_get_size(frame, &frame_width, &frame_height); + EXPECT_EQ(frame_width, 0u); + EXPECT_EQ(frame_height, 0u); +} + +TEST_F(FlOpenGLFrameTest, CompositeRGBA) { + constexpr size_t width = 100; + constexpr size_t height = 100; + + g_autoptr(FlOpenGLFrame) frame = fl_opengl_frame_new(/*shareable=*/TRUE); + g_autoptr(FlFramebuffer) framebuffer = + fl_framebuffer_new(GL_RGBA, width, height, FALSE); + FlutterBackingStore backing_store = { + .type = kFlutterBackingStoreTypeOpenGL, + .open_gl = { + .framebuffer = {.target = GL_RGBA8, .user_data = framebuffer}}}; + FlutterLayer layer = {.type = kFlutterLayerContentTypeBackingStore, + .backing_store = &backing_store, + .offset = {0, 0}, + .size = {width, height}}; + const FlutterLayer* layers[1] = {&layer}; + + EXPECT_CALL(epoxy, glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, + GL_RGBA, GL_UNSIGNED_BYTE, nullptr)); + + fl_opengl_frame_composite(frame, compositor, layers, 1); + + size_t frame_width = 0; + size_t frame_height = 0; + fl_opengl_frame_get_size(frame, &frame_width, &frame_height); + EXPECT_EQ(frame_width, width); + EXPECT_EQ(frame_height, height); +} + +TEST_F(FlOpenGLFrameTest, CompositeBGRA) { + constexpr size_t width = 100; + constexpr size_t height = 100; + + g_autoptr(FlOpenGLFrame) frame = fl_opengl_frame_new(/*shareable=*/TRUE); + g_autoptr(FlFramebuffer) framebuffer = + fl_framebuffer_new(GL_BGRA_EXT, width, height, FALSE); + FlutterBackingStore backing_store = { + .type = kFlutterBackingStoreTypeOpenGL, + .open_gl = { + .framebuffer = {.target = GL_BGRA8_EXT, .user_data = framebuffer}}}; + FlutterLayer layer = {.type = kFlutterLayerContentTypeBackingStore, + .backing_store = &backing_store, + .offset = {0, 0}, + .size = {width, height}}; + const FlutterLayer* layers[1] = {&layer}; + + EXPECT_CALL(epoxy, glTexImage2D(GL_TEXTURE_2D, 0, GL_BGRA_EXT, width, height, + 0, GL_BGRA_EXT, GL_UNSIGNED_BYTE, nullptr)); + + fl_opengl_frame_composite(frame, compositor, layers, 1); + + size_t frame_width = 0; + size_t frame_height = 0; + fl_opengl_frame_get_size(frame, &frame_width, &frame_height); + EXPECT_EQ(frame_width, width); + EXPECT_EQ(frame_height, height); +} + +TEST_F(FlOpenGLFrameTest, ZeroSizeClearsFrame) { + constexpr size_t width = 100; + constexpr size_t height = 100; + + g_autoptr(FlOpenGLFrame) frame = fl_opengl_frame_new(/*shareable=*/TRUE); + g_autoptr(FlFramebuffer) framebuffer = + fl_framebuffer_new(GL_RGBA, width, height, FALSE); + FlutterBackingStore backing_store = { + .type = kFlutterBackingStoreTypeOpenGL, + .open_gl = { + .framebuffer = {.target = GL_RGBA8, .user_data = framebuffer}}}; + FlutterLayer layer = {.type = kFlutterLayerContentTypeBackingStore, + .backing_store = &backing_store, + .offset = {0, 0}, + .size = {width, height}}; + const FlutterLayer* layers[1] = {&layer}; + + fl_opengl_frame_composite(frame, compositor, layers, 1); + + size_t frame_width = 0; + size_t frame_height = 0; + fl_opengl_frame_get_size(frame, &frame_width, &frame_height); + EXPECT_EQ(frame_width, width); + EXPECT_EQ(frame_height, height); + + FlutterLayer zero_layer = {.type = kFlutterLayerContentTypeBackingStore, + .backing_store = &backing_store, + .offset = {0, 0}, + .size = {0, 0}}; + const FlutterLayer* zero_layers[1] = {&zero_layer}; + fl_opengl_frame_composite(frame, compositor, zero_layers, 1); + + fl_opengl_frame_get_size(frame, &frame_width, &frame_height); + EXPECT_EQ(frame_width, 0u); + EXPECT_EQ(frame_height, 0u); +} diff --git a/engine/src/flutter/shell/platform/linux/testing/mock_epoxy.cc b/engine/src/flutter/shell/platform/linux/testing/mock_epoxy.cc index a1c476a9a4e9e..081fbaf65f8ea 100644 --- a/engine/src/flutter/shell/platform/linux/testing/mock_epoxy.cc +++ b/engine/src/flutter/shell/platform/linux/testing/mock_epoxy.cc @@ -478,17 +478,38 @@ static void _glFramebufferRenderbuffer(GLenum target, GLenum renderbuffertarget, GLuint renderbuffer) { framebuffer_renderbuffers[attachment] = renderbuffer; + if (mock) { + mock->glFramebufferRenderbuffer(target, attachment, renderbuffertarget, + renderbuffer); + } } static void _glFramebufferTexture2D(GLenum target, GLenum attachment, GLenum textarget, GLuint texture, - GLint level) {} + GLint level) { + if (mock) { + mock->glFramebufferTexture2D(target, attachment, textarget, texture, level); + } +} + +static void _glFramebufferTexture2DMultisampleEXT(GLenum target, + GLenum attachment, + GLenum textarget, + GLuint texture, + GLint level, + GLsizei samples) { + if (mock) { + mock->glFramebufferTexture2DMultisampleEXT(target, attachment, textarget, + texture, level, samples); + } +} static void _glGenTextures(GLsizei n, GLuint* textures) { + static GLuint next_id = 1; for (GLsizei i = 0; i < n; i++) { - textures[i] = 0; + textures[i] = next_id++; } if (mock) { mock->glGenTextures(n, textures); @@ -496,8 +517,9 @@ static void _glGenTextures(GLsizei n, GLuint* textures) { } static void _glGenFramebuffers(GLsizei n, GLuint* framebuffers) { + static GLuint next_id = 1; for (GLsizei i = 0; i < n; i++) { - framebuffers[i] = 0; + framebuffers[i] = next_id++; } if (mock) { mock->glGenFramebuffers(n, framebuffers); @@ -505,8 +527,9 @@ static void _glGenFramebuffers(GLsizei n, GLuint* framebuffers) { } static void _glGenRenderbuffers(GLsizei n, GLuint* renderbuffers) { + static GLuint next_id = 1; for (GLsizei i = 0; i < n; i++) { - renderbuffers[i] = 0; + renderbuffers[i] = next_id++; } if (mock) { mock->glGenRenderbuffers(n, renderbuffers); @@ -531,6 +554,9 @@ static void _glGetIntegerv(GLenum pname, GLint* data) { if (pname == GL_TEXTURE_BINDING_2D) { *data = bound_texture_2d; } + if (mock) { + mock->glGetIntegerv(pname, data); + } } static void _glGetProgramiv(GLuint program, GLenum pname, GLint* params) { @@ -582,9 +608,14 @@ static void _glTexImage2D(GLenum target, GLenum format, GLenum type, const void* pixels) { + if (mock) { + mock->glTexImage2D(target, level, internalformat, width, height, border, + format, type, pixels); + } if (pixels != nullptr) { - FML_CHECK(internalformat == GL_RGBA || internalformat == GL_RGBA8); - FML_CHECK(format == GL_RGBA); + FML_CHECK(internalformat == GL_RGBA || internalformat == GL_RGBA8 || + internalformat == GL_BGRA_EXT); + FML_CHECK(format == GL_RGBA || format == GL_BGRA_EXT); FML_CHECK(type == GL_UNSIGNED_BYTE); // Simple mock read to detect out-of-bounds reads in tests. @@ -603,7 +634,33 @@ void _glLinkProgram(GLuint program) {} void _glRenderbufferStorage(GLenum target, GLenum internalformat, GLsizei width, - GLsizei height) {} + GLsizei height) { + if (mock) { + mock->glRenderbufferStorage(target, internalformat, width, height); + } +} + +void _glRenderbufferStorageMultisample(GLenum target, + GLsizei samples, + GLenum internalformat, + GLsizei width, + GLsizei height) { + if (mock) { + mock->glRenderbufferStorageMultisample(target, samples, internalformat, + width, height); + } +} + +void _glRenderbufferStorageMultisampleEXT(GLenum target, + GLsizei samples, + GLenum internalformat, + GLsizei width, + GLsizei height) { + if (mock) { + mock->glRenderbufferStorageMultisampleEXT(target, samples, internalformat, + width, height); + } +} void _glShaderSource(GLuint shader, GLsizei count, @@ -711,17 +768,34 @@ void (*epoxy_glFramebufferTexture2D)(GLenum target, GLenum textarget, GLuint texture, GLint level); +void (*epoxy_glFramebufferTexture2DMultisampleEXT)(GLenum target, + GLenum attachment, + GLenum textarget, + GLuint texture, + GLint level, + GLsizei samples); void (*epoxy_glGetFramebufferAttachmentParameteriv)(GLenum target, GLenum attachment, GLenum pname, GLint* params); void (*epoxy_glGenFramebuffers)(GLsizei n, GLuint* framebuffers); +void (*epoxy_glGenRenderbuffers)(GLsizei n, GLuint* renderbuffers); void (*epoxy_glGenTextures)(GLsizei n, GLuint* textures); void (*epoxy_glLinkProgram)(GLuint program); void (*epoxy_glRenderbufferStorage)(GLenum target, GLenum internalformat, GLsizei width, GLsizei height); +void (*epoxy_glRenderbufferStorageMultisample)(GLenum target, + GLsizei samples, + GLenum internalformat, + GLsizei width, + GLsizei height); +void (*epoxy_glRenderbufferStorageMultisampleEXT)(GLenum target, + GLsizei samples, + GLenum internalformat, + GLsizei width, + GLsizei height); void (*epoxy_glShaderSource)(GLuint shader, GLsizei count, const GLchar* const* string, @@ -776,6 +850,8 @@ static void library_init() { epoxy_glEnable = _glEnable; epoxy_glFramebufferRenderbuffer = _glFramebufferRenderbuffer; epoxy_glFramebufferTexture2D = _glFramebufferTexture2D; + epoxy_glFramebufferTexture2DMultisampleEXT = + _glFramebufferTexture2DMultisampleEXT; epoxy_glGenFramebuffers = _glGenFramebuffers; epoxy_glGenRenderbuffers = _glGenRenderbuffers; epoxy_glGenTextures = _glGenTextures; @@ -790,6 +866,9 @@ static void library_init() { epoxy_glIsEnabled = _glIsEnabled; epoxy_glLinkProgram = _glLinkProgram; epoxy_glRenderbufferStorage = _glRenderbufferStorage; + epoxy_glRenderbufferStorageMultisample = _glRenderbufferStorageMultisample; + epoxy_glRenderbufferStorageMultisampleEXT = + _glRenderbufferStorageMultisampleEXT; epoxy_glShaderSource = _glShaderSource; epoxy_glTexParameterf = _glTexParameterf; epoxy_glTexParameteri = _glTexParameteri; diff --git a/engine/src/flutter/shell/platform/linux/testing/mock_epoxy.h b/engine/src/flutter/shell/platform/linux/testing/mock_epoxy.h index 5c6d477aa8f2c..d27f20857fa06 100644 --- a/engine/src/flutter/shell/platform/linux/testing/mock_epoxy.h +++ b/engine/src/flutter/shell/platform/linux/testing/mock_epoxy.h @@ -52,7 +52,58 @@ class MockEpoxy { MOCK_METHOD(void, glGenFramebuffers, (GLsizei n, GLuint* framebuffers)); MOCK_METHOD(void, glGenRenderbuffers, (GLsizei n, GLuint* renderbuffers)); MOCK_METHOD(void, glGenTextures, (GLsizei n, GLuint* textures)); + MOCK_METHOD(void, + glFramebufferRenderbuffer, + (GLenum target, + GLenum attachment, + GLenum renderbuffertarget, + GLuint renderbuffer)); + MOCK_METHOD(void, + glFramebufferTexture2D, + (GLenum target, + GLenum attachment, + GLenum textarget, + GLuint texture, + GLint level)); + MOCK_METHOD(void, + glFramebufferTexture2DMultisampleEXT, + (GLenum target, + GLenum attachment, + GLenum textarget, + GLuint texture, + GLint level, + GLsizei samples)); + MOCK_METHOD(void, glGetIntegerv, (GLenum pname, GLint* data)); MOCK_METHOD(const GLubyte*, glGetString, (GLenum pname)); + MOCK_METHOD( + void, + glRenderbufferStorage, + (GLenum target, GLenum internalformat, GLsizei width, GLsizei height)); + MOCK_METHOD(void, + glRenderbufferStorageMultisample, + (GLenum target, + GLsizei samples, + GLenum internalformat, + GLsizei width, + GLsizei height)); + MOCK_METHOD(void, + glRenderbufferStorageMultisampleEXT, + (GLenum target, + GLsizei samples, + GLenum internalformat, + GLsizei width, + GLsizei height)); + MOCK_METHOD(void, + glTexImage2D, + (GLenum target, + GLint level, + GLint internalformat, + GLsizei width, + GLsizei height, + GLint border, + GLenum format, + GLenum type, + const void* pixels)); }; } // namespace testing From 7d219defe60194c1ade4a1a33760dbcde7903b3d Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Sun, 23 Aug 2026 22:37:24 +0000 Subject: [PATCH 27/46] Roll Fuchsia Linux SDK from Cqs-NyeELd60hqv1e... to 8Xu4ujBJniC0nQGx3... (#191561) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/fuchsia-linux-sdk-flutter Please CC awolff@google.com,zra@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPS b/DEPS index 6d70141a7ddff..c45a609e9f76d 100644 --- a/DEPS +++ b/DEPS @@ -830,7 +830,7 @@ deps = { 'packages': [ { 'package': 'fuchsia/sdk/core/linux-amd64', - 'version': 'Cqs-NyeELd60hqv1egYGg5tY2BYemRmtmKW2eysoIZQC' + 'version': '8Xu4ujBJniC0nQGx3MAlb2ANTQhaU6CrIcmBlf-yz0gC' } ], 'condition': 'download_fuchsia_deps and not download_fuchsia_sdk', From a6d9dcd729e16d9c85a223105a4772c7dcdc613e Mon Sep 17 00:00:00 2001 From: Matthew Kosarek Date: Mon, 24 Aug 2026 02:12:07 +0000 Subject: [PATCH 28/46] =?UTF-8?q?Revert=20"Improve=20initial=20size=20and?= =?UTF-8?q?=20placement=20logic=20for=20popups=20and=20toolt=E2=80=A6ips?= =?UTF-8?q?=20on=20Linux=20(#189994)=20(#191498)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit f6061d0003a7a9e0cb30a45379c4c4bec0412c40. Reverts https://github.com/flutter/flutter/pull/189994 ## Pre-launch Checklist - [ ] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [ ] I read the [AI contribution guidelines] and understand my responsibilities, or I am not using AI tools. - [ ] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [ ] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [ ] I signed the [CLA]. - [ ] I listed at least one issue that this PR fixes in the description above. - [ ] I updated/added relevant documentation (doc comments with `///`). - [ ] I added new tests to check the change I am making, or this PR is [test-exempt]. - [ ] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [ ] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. If this change needs to override an active code freeze, provide a comment explaining why. The code freeze workflow can be overridden by code reviewers. See pinned issues for any active code freezes with guidance. **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [AI contribution guidelines]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md Co-authored-by: Robert Ancell --- .../shell/platform/linux/fl_view_monitor.cc | 14 +- .../shell/platform/linux/fl_view_monitor.h | 7 +- .../lib/src/widgets/_window_linux.dart | 248 ++---------------- 3 files changed, 27 insertions(+), 242 deletions(-) diff --git a/engine/src/flutter/shell/platform/linux/fl_view_monitor.cc b/engine/src/flutter/shell/platform/linux/fl_view_monitor.cc index a3427877d591f..79ceac53089b3 100644 --- a/engine/src/flutter/shell/platform/linux/fl_view_monitor.cc +++ b/engine/src/flutter/shell/platform/linux/fl_view_monitor.cc @@ -18,7 +18,6 @@ struct _FlViewMonitor { // Callbacks. void (*on_first_frame)(void); - void (*on_size_changed)(int width, int height); }; G_DEFINE_TYPE(FlViewMonitor, fl_view_monitor, G_TYPE_OBJECT) @@ -30,13 +29,6 @@ static void first_frame_cb(FlViewMonitor* self) { } } -static void size_allocate_cb(FlViewMonitor* self, GtkAllocation* allocation) { - flutter::IsolateScope scope(self->isolate); - if (self->on_size_changed) { - self->on_size_changed(allocation->width, allocation->height); - } -} - static void fl_view_monitor_dispose(GObject* object) { FlViewMonitor* self = FL_VIEW_MONITOR(object); @@ -53,19 +45,15 @@ static void fl_view_monitor_init(FlViewMonitor* self) {} G_MODULE_EXPORT FlViewMonitor* fl_view_monitor_new( FlView* view, - void (*on_first_frame)(void), - void (*on_size_changed)(int width, int height)) { + void (*on_first_frame)(void)) { FlViewMonitor* self = FL_VIEW_MONITOR(g_object_new(fl_view_monitor_get_type(), nullptr)); self->view = FL_VIEW(g_object_ref(view)); self->isolate = flutter::Isolate::Current(); self->on_first_frame = on_first_frame; - self->on_size_changed = on_size_changed; g_signal_connect_object(view, "first-frame", G_CALLBACK(first_frame_cb), self, G_CONNECT_SWAPPED); - g_signal_connect_object(view, "size-allocate", G_CALLBACK(size_allocate_cb), - self, G_CONNECT_SWAPPED); return self; } diff --git a/engine/src/flutter/shell/platform/linux/fl_view_monitor.h b/engine/src/flutter/shell/platform/linux/fl_view_monitor.h index f0b8d4e619238..1f5151222d3b1 100644 --- a/engine/src/flutter/shell/platform/linux/fl_view_monitor.h +++ b/engine/src/flutter/shell/platform/linux/fl_view_monitor.h @@ -15,18 +15,13 @@ G_DECLARE_FINAL_TYPE(FlViewMonitor, fl_view_monitor, FL, VIEW_MONITOR, GObject); * fl_view_monitor_new: * @view: the view being monitored. * @on_first_frame: the function to call when the first frame is rendered. - * @on_size_changed: the function to call when the view is allocated a size, - * with the width and height in logical pixels. * * Helper class to allow the Flutter engine to monitor a FlView using FFI. * Callbacks are called in the isolate this class was created with. * * Returns: a new #FlViewMonitor. */ -FlViewMonitor* fl_view_monitor_new(FlView* view, - void (*on_first_frame)(void), - void (*on_size_changed)(int width, - int height)); +FlViewMonitor* fl_view_monitor_new(FlView* view, void (*on_first_frame)(void)); G_END_DECLS diff --git a/packages/flutter/lib/src/widgets/_window_linux.dart b/packages/flutter/lib/src/widgets/_window_linux.dart index 478e1b609e9f6..b94afc6b1a748 100644 --- a/packages/flutter/lib/src/widgets/_window_linux.dart +++ b/packages/flutter/lib/src/widgets/_window_linux.dart @@ -14,12 +14,10 @@ // // See: https://github.com/flutter/flutter/issues/30701. -import 'dart:async'; import 'dart:convert'; import 'dart:ffi' as ffi; import 'dart:io'; import 'dart:ui' show Display, FlutterView; -import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; @@ -296,147 +294,6 @@ abstract interface class BaseWindowControllerLinux { ffi.Pointer get flutterViewHandle; } -/// Deferred-show and reposition-on-resize behavior shared by the anchored, -/// sized-to-content Linux windows: [TooltipWindowControllerLinux] and -/// [PopupWindowControllerLinux]. -/// -/// Rather than being shown as soon as its view renders its first frame, the -/// window is shown once the view has been allocated the size of that content -/// (see [_handleFirstFrame]) so that it is placed correctly for the size it -/// maps with. On Wayland that first placement is final; on X11 a later resize -/// is corrected (see [_handleConfigure]). -mixin _SizedToContentWindowMixin on ChangeNotifier { - // Provided by the mixing-in controller: the native window being shown, the - // view rendering the Flutter content into it, whether it has been destroyed, - // and how to place it against its anchor rectangle. - _GtkWindow get _window; - FlutterView get rootView; - bool get isDestroyed; - void updatePosition({Rect? anchorRect, WindowPositioner? positioner}); - - // The window size when it was mapped; null until [_showFirstTime] has shown - // the window. Compared in [_handleConfigure] to detect a resize that - // requires repositioning the window. - Size? _mappedSize; - - // Guards [_handleConfigure] against re-entrancy from the configure events - // that repositioning itself emits. - bool _repositioning = false; - - // Latest size allocated to the view, in logical pixels; null until the - // first size-allocate has been reported. - Size? _viewSize; - - // Whether the view has rendered its first frame. The window is not shown - // before then. - bool _firstFrameReceived = false; - - // Shows the window if the view's allocation fails to converge on the - // content size — see [_handleFirstFrame]. - Timer? _showFallbackTimer; - - /// Handles the view rendering its first frame. - /// - /// The first-frame signal is emitted when the renderer produces a frame, - /// before the renderer's idle callback has applied the sized-to-content size - /// request to the window; showing here would map the window at its - /// pre-content size and the (one-shot on Wayland, see [updatePosition]) - /// positioner would place it using that wrong size. Instead the show is - /// deferred until [_handleViewSizeChanged] observes the view being allocated - /// the content size, so the window maps at that size and is placed correctly - /// — fully on screen — on the first show. - void _handleFirstFrame() { - _firstFrameReceived = true; - // If the view's allocation fails to converge on the content size, show - // anyway; on X11 [_handleConfigure] will then correct the placement. - _showFallbackTimer = Timer(const Duration(milliseconds: 250), _showFirstTime); - _maybeShowFirstTime(); - } - - /// Handles the view being allocated a new size of [width] x [height] logical - /// pixels. - void _handleViewSizeChanged(int width, int height) { - _viewSize = Size(width.toDouble(), height.toDouble()); - _maybeShowFirstTime(); - } - - /// Shows the window for the first time once the first frame has been - /// rendered and the view has been allocated the size of that content — see - /// [_handleFirstFrame]. - void _maybeShowFirstTime() { - if (_mappedSize == null && _firstFrameReceived && _viewMatchesContentSize()) { - _showFirstTime(); - } - } - - void _showFirstTime() { - if (isDestroyed || _mappedSize != null) { - return; - } - _showFallbackTimer?.cancel(); - updatePosition(); - _window.show(); - _mappedSize = _window.getSize(); - } - - /// Whether the size allocated to the view matches the size of the Flutter - /// content rendered into it. - /// - /// Both sizes are in logical pixels. - bool _viewMatchesContentSize() { - final Size? viewSize = _viewSize; - if (viewSize == null) { - return false; - } - final RenderView? renderView = RendererBinding.instance.renderViews.firstWhereOrNull( - (RenderView view) => view.flutterView == rootView, - ); - if (renderView == null) { - return true; - } - final Size contentSize = renderView.size; - return viewSize == contentSize; - } - - /// Handles the window's configure events, repositioning the window when its - /// size has changed since it was mapped. - /// - /// On X11 move-to-rect is computed client-side and repositions the mapped - /// window immediately, so the new size is taken into account as soon as it - /// is known. - /// - /// On Wayland the window is left where it was placed when it was mapped. - /// GDK3's Wayland backend applies the xdg_positioner parameters only while - /// mapping the window (the positioner is not reactive and GDK3 has no - /// reposition support), so the only way to re-evaluate them at the new size - /// is to remap the window (hide + show) and have GDK create a fresh - /// xdg_popup. That makes the window blink on every resize, which for content - /// that resizes repeatedly is worse than the stale placement it corrects. - void _handleConfigure() { - if (!_isWaylandDisplay && !isDestroyed && !_repositioning && _mappedSize != null) { - final Size size = _window.getSize(); - if (size != _mappedSize) { - _mappedSize = size; - // Guarded so that an overridden [updatePosition] that throws cannot - // wedge the flag and disable repositioning for good. - _repositioning = true; - try { - updatePosition(); - } finally { - _repositioning = false; - } - } - } - notifyListeners(); - } - - /// Releases the resources held while waiting to show the window. Called by - /// the mixing-in controller when it destroys the window. - void _cancelDeferredShow() { - _showFallbackTimer?.cancel(); - } -} - /// Implementation of [WindowController] for the Linux platform. /// /// {@macro flutter.widgets.windowing.experimental} @@ -444,7 +301,8 @@ mixin _SizedToContentWindowMixin on ChangeNotifier { /// See also: /// /// * [WindowController], the base class for regular windows. -class WindowControllerLinux extends WindowController implements BaseWindowControllerLinux { +class WindowControllerLinux extends WindowController + implements BaseWindowControllerLinux { /// Creates a new regular window controller for Linux. /// /// When this constructor completes the native window has been created and @@ -651,8 +509,7 @@ class WindowControllerLinux extends WindowController implements BaseWindowContro /// See also: /// /// * [DialogWindowController], the base class for dialog windows. -class DialogWindowControllerLinux extends DialogWindowController - implements BaseWindowControllerLinux { +class DialogWindowControllerLinux extends DialogWindowController implements BaseWindowControllerLinux { /// Creates a new dialog window controller for Linux. /// /// When this constructor completes the native window has been created and @@ -843,7 +700,6 @@ class DialogWindowControllerLinux extends DialogWindowController /// /// * [TooltipWindowController], the base class for tooltip windows. class TooltipWindowControllerLinux extends TooltipWindowController - with _SizedToContentWindowMixin implements BaseWindowControllerLinux { /// Creates a new tooltip window controller for Linux. /// @@ -879,7 +735,7 @@ class TooltipWindowControllerLinux extends TooltipWindowController _windowMonitor = _FlWindowMonitor( _window, - onConfigure: _handleConfigure, + onConfigure: notifyListeners, onDestroy: _delegate.onWindowDestroyed, ); setConstraints(constraints); @@ -887,8 +743,9 @@ class TooltipWindowControllerLinux extends TooltipWindowController _view = _FlView(engine, isSizedToContent: true); _viewMonitor = _FlViewMonitor( _view, - onFirstFrame: _handleFirstFrame, - onSizeChanged: _handleViewSizeChanged, + onFirstFrame: () { + _window.show(); + }, ); final int viewId = _view.getId(); rootView = WidgetsBinding.instance.platformDispatcher.views.firstWhere( @@ -907,7 +764,6 @@ class TooltipWindowControllerLinux extends TooltipWindowController final WindowingOwnerLinux _owner; final TooltipWindowControllerDelegate _delegate; - @override final _GtkWindow _window; late Rect _anchorRect; late WindowPositioner _positioner; @@ -935,7 +791,6 @@ class TooltipWindowControllerLinux extends TooltipWindowController _window.destroy(); _windowMonitor.close(); _windowMonitor.unref(); - _cancelDeferredShow(); _destroyed = true; _owner.registrar.unregister(rootView.viewId); notifyListeners(); @@ -956,13 +811,11 @@ class TooltipWindowControllerLinux extends TooltipWindowController if (parentWindow != null && view != null) { offset = view.translateCoordinates(parentWindow, (0, 0)) ?? (0, 0); } - // On Wayland GTK3 applies this only while mapping the window as it does - // not set the - // [reactive flag](https://wayland.app/protocols/xdg-shell#xdg_positioner:request:set_reactive) - // on the positioner, so the placement is only [received once](https://wayland.app/protocols/xdg-shell#xdg_popup:event:configure); - // a tooltip that is resized after mapping keeps the placement computed at - // the size it was mapped with — see [_handleConfigure]. On X11 this call - // repositions the window immediately. + // This is only applied in GTK3 the first time the tooltip is shown as GTK3 + // only sends updates when the popup surface configure event is + // received. Since GTK3 does not set the [reactive flag](https://wayland.app/protocols/xdg-shell#xdg_positioner:request:set_reactive) + // on the positioner it is only [received once](https://wayland.app/protocols/xdg-shell#xdg_popup:event:configure). + // This means if a Linux tooltip is resized it will not be repositioned. _window.getWindow().moveToRect( x: _anchorRect.left.toInt() + offset.$1, y: _anchorRect.top.toInt() + offset.$2, @@ -1042,9 +895,7 @@ class TooltipWindowControllerLinux extends TooltipWindowController /// See also: /// /// * [PopupWindowController], the base class for popup windows. -class PopupWindowControllerLinux extends PopupWindowController - with _SizedToContentWindowMixin - implements BaseWindowControllerLinux { +class PopupWindowControllerLinux extends PopupWindowController implements BaseWindowControllerLinux { /// Creates a new popup window controller for Linux. /// /// When this constructor completes the native window has been created and @@ -1077,7 +928,7 @@ class PopupWindowControllerLinux extends PopupWindowController _windowMonitor = _FlWindowMonitor( _window, - onConfigure: _handleConfigure, + onConfigure: notifyListeners, onMovedToRect: (x, y, width, height) { _offsetFromParent = Offset(x.toDouble(), y.toDouble()); }, @@ -1088,8 +939,9 @@ class PopupWindowControllerLinux extends PopupWindowController _view = _FlView(engine, isSizedToContent: true); _viewMonitor = _FlViewMonitor( _view, - onFirstFrame: _handleFirstFrame, - onSizeChanged: _handleViewSizeChanged, + onFirstFrame: () { + _window.show(); + }, ); final int viewId = _view.getId(); rootView = WidgetsBinding.instance.platformDispatcher.views.firstWhere( @@ -1108,7 +960,6 @@ class PopupWindowControllerLinux extends PopupWindowController final WindowingOwnerLinux _owner; final PopupWindowControllerDelegate _delegate; - @override final _GtkWindow _window; late Rect _anchorRect; late WindowPositioner _positioner; @@ -1137,7 +988,6 @@ class PopupWindowControllerLinux extends PopupWindowController _window.destroy(); _windowMonitor.close(); _windowMonitor.unref(); - _cancelDeferredShow(); _destroyed = true; _owner.registrar.unregister(rootView.viewId); notifyListeners(); @@ -1158,13 +1008,11 @@ class PopupWindowControllerLinux extends PopupWindowController if (parentWindow != null && view != null) { offset = view.translateCoordinates(parentWindow, (0, 0)) ?? (0, 0); } - // On Wayland GTK3 applies this only while mapping the window as it does - // not set the - // [reactive flag](https://wayland.app/protocols/xdg-shell#xdg_positioner:request:set_reactive) - // on the positioner, so the placement is only [received once](https://wayland.app/protocols/xdg-shell#xdg_popup:event:configure); - // a popup that is resized after mapping keeps the placement computed at - // the size it was mapped with — see [_handleConfigure]. On X11 this call - // repositions the window immediately. + // This is only applied in GTK3 the first time the popup is shown as GTK3 + // only sends updates when the popup surface configure event is + // received. Since GTK3 does not set the [reactive flag](https://wayland.app/protocols/xdg-shell#xdg_positioner:request:set_reactive) + // on the positioner it is only [received once](https://wayland.app/protocols/xdg-shell#xdg_popup:event:configure). + // This means if a Linux popup is resized it will not be repositioned. _window.getWindow().moveToRect( x: _anchorRect.left.toInt() + offset.$1, y: _anchorRect.top.toInt() + offset.$2, @@ -1353,31 +1201,6 @@ String _nativeToString(ffi.Pointer value) { return utf8.decode(value.asTypedList(length)); } -/// Whether the default GDK display is a Wayland display (as opposed to X11). -/// -/// Determined from the display's GType name rather than GDK_IS_WAYLAND_DISPLAY, -/// which is a C macro over symbols that are only present when GDK is built with -/// the Wayland backend; the GObject type name lookup succeeds on any build. -final bool _isWaylandDisplay = () { - final ffi.Pointer display = _gdkDisplayGetDefault(); - if (display == ffi.nullptr) { - return false; - } - final ffi.Pointer typeName = _gTypeNameFromInstance(display); - if (typeName == ffi.nullptr) { - return false; - } - return _nativeToString(typeName.cast()) == 'GdkWaylandDisplay'; -}(); - -@ffi.Native Function()>(symbol: 'gdk_display_get_default') -external ffi.Pointer _gdkDisplayGetDefault(); - -@ffi.Native Function(ffi.Pointer)>( - symbol: 'g_type_name_from_instance', -) -external ffi.Pointer _gTypeNameFromInstance(ffi.Pointer instance); - /// Wraps GObject. class _GObject { /// Creates a wrapper to an existing [GObject] in [instance]. @@ -1903,54 +1726,33 @@ class _FlView extends _GtkWidget { /// Wraps FlViewMonitor (helper object for handling signals from FlView). class _FlViewMonitor extends _GObject { /// Create a new FlViewMonitor. - factory _FlViewMonitor( - _FlView view, { - VoidCallback? onFirstFrame, - void Function(int, int)? onSizeChanged, - }) { + factory _FlViewMonitor(_FlView view, {VoidCallback? onFirstFrame}) { void noop() {} - void noopSizeChanged(int width, int height) {} return _FlViewMonitor._internal( view.instance, ffi.NativeCallable.isolateLocal(onFirstFrame ?? noop), - ffi.NativeCallable.isolateLocal( - onSizeChanged ?? noopSizeChanged, - ), ); } - _FlViewMonitor._internal( - ffi.Pointer view, - this._onFirstFrameFunction, - this._onSizeChangedFunction, - ) : super( - _flViewMonitorNew( - view, - _onFirstFrameFunction.nativeFunction, - _onSizeChangedFunction.nativeFunction, - ), - ); + _FlViewMonitor._internal(ffi.Pointer view, this._onFirstFrameFunction) + : super(_flViewMonitorNew(view, _onFirstFrameFunction.nativeFunction)); final ffi.NativeCallable _onFirstFrameFunction; - final ffi.NativeCallable _onSizeChangedFunction; /// Close all FFI resources used in the monitor. void close() { _onFirstFrameFunction.close(); - _onSizeChangedFunction.close(); } @ffi.Native< ffi.Pointer Function( ffi.Pointer, ffi.Pointer>, - ffi.Pointer>, ) >(symbol: 'fl_view_monitor_new') external static ffi.Pointer _flViewMonitorNew( ffi.Pointer view, ffi.Pointer> onFirstFrame, - ffi.Pointer> onSizeChanged, ); } From 49d0823cb00e22a08534f2b5e2a125386176eea6 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Mon, 24 Aug 2026 05:59:24 +0000 Subject: [PATCH 29/46] Roll Skia from 886302c7c68e to c78782c7c1c2 (1 revision) (#191566) https://skia.googlesource.com/skia.git/+log/886302c7c68e..c78782c7c1c2 2026-08-24 skia-autoroll@skia-public.iam.gserviceaccount.com Roll debugger-app-base from 816871cb56df to 4ab9cd4f66b2 If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/skia-flutter-autoroll Please CC awolff@google.com,fmalita@google.com,kjlubick@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Skia: https://bugs.chromium.org/p/skia/issues/entry To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPS b/DEPS index c45a609e9f76d..3e1d66c348edb 100644 --- a/DEPS +++ b/DEPS @@ -15,7 +15,7 @@ vars = { 'skia_git': 'https://skia.googlesource.com', 'llvm_git': 'https://llvm.googlesource.com', 'dart_ai_rev': '9c96bfe5f091c9451eff5b59c9bffeb2e806b875', - 'skia_revision': '886302c7c68ed1d35f793b9c99cfdb57ba80e30a', + 'skia_revision': 'c78782c7c1c2415611fb2f6bf96b5f1068dcd117', # Do not download the Emscripten SDK by default. # This prevents us from downloading the Emscripten toolchain for builds From 71edf979c7578b1468178c157a2cca94dd6da884 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Mon, 24 Aug 2026 07:57:34 +0000 Subject: [PATCH 30/46] Roll Skia from c78782c7c1c2 to 925e13559532 (5 revisions) (#191568) https://skia.googlesource.com/skia.git/+log/c78782c7c1c2..925e13559532 2026-08-24 recipe-mega-autoroller@chops-service-accounts.iam.gserviceaccount.com Roll recipe dependencies (trivial). 2026-08-24 skia-autoroll@skia-public.iam.gserviceaccount.com Roll ANGLE from 6f066af1045b to 80760643c85e (5 revisions) 2026-08-24 mitchella@google.com etc1: add missing metadata fields to README.google 2026-08-24 skia-autoroll@skia-public.iam.gserviceaccount.com Roll Skia Infra from 00da75d62171 to 00004eadd242 (9 revisions) 2026-08-24 skia-autoroll@skia-public.iam.gserviceaccount.com Roll Dawn from 15f7ba37a9ac to 01af1ad339cb (10 revisions) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/skia-flutter-autoroll Please CC awolff@google.com,fmalita@google.com,kjlubick@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Skia: https://bugs.chromium.org/p/skia/issues/entry To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPS b/DEPS index 3e1d66c348edb..fb12b91dee873 100644 --- a/DEPS +++ b/DEPS @@ -15,7 +15,7 @@ vars = { 'skia_git': 'https://skia.googlesource.com', 'llvm_git': 'https://llvm.googlesource.com', 'dart_ai_rev': '9c96bfe5f091c9451eff5b59c9bffeb2e806b875', - 'skia_revision': 'c78782c7c1c2415611fb2f6bf96b5f1068dcd117', + 'skia_revision': '925e1355953264cbd07a6e67936b858df9d87779', # Do not download the Emscripten SDK by default. # This prevents us from downloading the Emscripten toolchain for builds From 3eabedfade429b631622cb4dbc5be4d284572314 Mon Sep 17 00:00:00 2001 From: Chris Bracken Date: Mon, 24 Aug 2026 08:20:29 +0000 Subject: [PATCH 31/46] iOS: Improve FlutterEngineGroup tests (#191563) Adds tests for `[FlutterEngine spawn]` that do checks at the `flutter::Shell` level. `testSpawn` already verified that a spawned engine shares its spawner's thread host this adds tests that ensure that spawned engines share a single Dart VM, shared task runners, and that the GPU enabled/disabled state is correctly set on the underlying `Shell`, not just on the `FlutterEngine` itself. This fills in some test coverage gaps before we start moving iOS towards the embedder API. Issue: https://github.com/flutter/flutter/issues/112232 ## Pre-launch Checklist - [X] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [X] I read the [AI contribution guidelines] and understand my responsibilities, or I am not using AI tools. - [X] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [X] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [X] I signed the [CLA]. - [X] I listed at least one issue that this PR fixes in the description above. - [X] I updated/added relevant documentation (doc comments with `///`). - [X] I added new tests to check the change I am making, or this PR is [test-exempt]. - [X] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [X] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. If this change needs to override an active code freeze, provide a comment explaining why. The code freeze workflow can be overridden by code reviewers. See pinned issues for any active code freezes with guidance. **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [AI contribution guidelines]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --- .../Source/FlutterEngineGroupTest.mm | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngineGroupTest.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngineGroupTest.mm index 7832e9524ad7e..147dcd53a3910 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngineGroupTest.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngineGroupTest.mm @@ -5,6 +5,7 @@ #import #import +#include "flutter/fml/synchronization/sync_switch.h" #import "flutter/shell/platform/darwin/ios/framework/Headers/FlutterEngineGroup.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine+Test.h" @@ -30,10 +31,42 @@ - (void)testSpawn { FlutterEngine* spawner = [group makeEngineWithEntrypoint:nil libraryURI:nil]; spawner.isGpuDisabled = YES; FlutterEngine* spawnee = [group makeEngineWithEntrypoint:nil libraryURI:nil]; + + // Verify engines exist. XCTAssertNotNil(spawner); XCTAssertNotNil(spawnee); + + // Verify engine properties match. XCTAssertEqual(&spawner.threadHost, &spawnee.threadHost); XCTAssertEqual(spawner.isGpuDisabled, spawnee.isGpuDisabled); + + // Verify the shell came up with GPU disabled. + BOOL gpuDisabled = NO; + [spawnee shell].GetIsGpuDisabledSyncSwitch()->Execute( + fml::SyncSwitch::Handlers().SetIfTrue([&] { gpuDisabled = YES; })); + XCTAssertTrue(gpuDisabled); +} + +// Verifies that the Dart VM, isolate group snapshot, and task runners are shared between engines. +- (void)testSpawnSharesDartVMAndTaskRunners { + FlutterEngineGroup* group = [[FlutterEngineGroup alloc] initWithName:@"foo" project:nil]; + FlutterEngine* spawner = [group makeEngineWithEntrypoint:nil libraryURI:nil]; + FlutterEngine* spawnee = [group makeEngineWithEntrypoint:nil libraryURI:nil]; + XCTAssertNotNil(spawner); + XCTAssertNotNil(spawnee); + + // A single Dart VM backs every engine in the group. + XCTAssertEqual([spawner shell].GetDartVM(), [spawnee shell].GetDartVM()); + + // The engine-managed threads are shared; only the Dart isolate differs. + const flutter::TaskRunners& spawnerRunners = [spawner shell].GetTaskRunners(); + const flutter::TaskRunners& spawneeRunners = [spawnee shell].GetTaskRunners(); + XCTAssertEqual(spawnerRunners.GetRasterTaskRunner(), spawneeRunners.GetRasterTaskRunner()); + XCTAssertEqual(spawnerRunners.GetIOTaskRunner(), spawneeRunners.GetIOTaskRunner()); + XCTAssertEqual(spawnerRunners.GetPlatformTaskRunner(), spawneeRunners.GetPlatformTaskRunner()); + + // Platform and UI are merged on iOS, so this holds for the spawned engine too. + XCTAssertEqual(spawneeRunners.GetUITaskRunner(), spawneeRunners.GetPlatformTaskRunner()); } - (void)testDeleteLastEngine { From b4755fd4a4d7c2f69ea5531d55038af598399068 Mon Sep 17 00:00:00 2001 From: Chris Bracken Date: Mon, 24 Aug 2026 08:20:39 +0000 Subject: [PATCH 32/46] iOS: Add stylus pointer tests (#191564) Adds tests for the stylus and touch-geometry fields that currently lack testing on iOS. `dispatchTouches:` normalises iOS-specific UITouch objects into generic engine `PointerData` structs. These include `force`, `majorRadius`, `altitudeAngle` and `azimuthAngleInView:`, but those have no test coverage. Currently, the only testing we have of `PointerData` checks a timestamp. `tilt` and `orientation` need particular care as they're not just straight copies of the data. iOS measures from the surface plane such that an angle of 0 is parallel to the surface and pi/2 is vertical. `PointerData` measures from the surface normal such that 0 is vertical and pi/2 is parallel to the plane. When we migrate to the embedder API, we'll need to expose these details via `FlutterPointerEvent`. It currently has `pressure` but none of `tilt`, `orientation` or the radius fields, so all of this has to be added to the embedder API before iOS can adopt it. Issue: https://github.com/flutter/flutter/issues/112232 ## Pre-launch Checklist - [X] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [X] I read the [AI contribution guidelines] and understand my responsibilities, or I am not using AI tools. - [X] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [X] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [X] I signed the [CLA]. - [X] I listed at least one issue that this PR fixes in the description above. - [X] I updated/added relevant documentation (doc comments with `///`). - [X] I added new tests to check the change I am making, or this PR is [test-exempt]. - [X] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [X] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. If this change needs to override an active code freeze, provide a comment explaining why. The code freeze workflow can be overridden by code reviewers. See pinned issues for any active code freezes with guidance. **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [AI contribution guidelines]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --- .../Source/FlutterViewControllerTest.mm | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm index 54f5391b7e9f1..a89501a41d83d 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm @@ -8,6 +8,7 @@ #include "flutter/fml/platform/darwin/message_loop_darwin.h" #import "flutter/lib/ui/window/platform_configuration.h" #include "flutter/lib/ui/window/pointer_data.h" +#include "flutter/lib/ui/window/pointer_data_packet.h" #import "flutter/lib/ui/window/viewport_metrics.h" #import "flutter/shell/platform/darwin/common/framework/Headers/FlutterBinaryMessenger.h" #import "flutter/shell/platform/darwin/common/framework/Headers/FlutterHourFormat.h" @@ -344,6 +345,9 @@ - (void)goToApplicationLifecycle:(nonnull NSString*)state; - (void)addInternalPlugins; - (flutter::PointerData)generatePointerDataForFake; +- (void)dispatchTouches:(NSSet*)touches + pointerDataChangeOverride:(flutter::PointerData::Change*)changeOverride + event:(UIEvent*)event; - (void)sharedSetupWithProject:(nullable FlutterDartProject*)project initialRoute:(nullable NSString*)initialRoute; - (void)applicationBecameActive:(NSNotification*)notification; @@ -373,6 +377,27 @@ @interface UITouch () @end +/// A `FlutterEngine` that saves received `PointerData`s instead of forwarding to the shell. +@interface PointerDataCapturingEngine : FlutterEngine +- (const std::vector&)capturedPointerData; +@end + +@implementation PointerDataCapturingEngine { + std::vector _capturedPointerData; +} + +- (void)dispatchPointerDataPacket:(std::unique_ptr)packet { + for (size_t i = 0; i < packet->GetLength(); i++) { + _capturedPointerData.push_back(packet->GetPointerData(i)); + } +} + +- (const std::vector&)capturedPointerData { + return _capturedPointerData; +} + +@end + @implementation FlutterViewControllerTest - (void)setUp { @@ -2285,6 +2310,109 @@ - (void)testMouseSupport API_AVAILABLE(ios(13.4)) { dispatchPointerDataPacket:std::make_unique(0)]; } +// Verify stylus and touch-geometry fields. +// +// Ensure `tilt` and `orientation` are correctly renormalized. iOS reports angles relative to the +// surface plane with 0 representing horizontal. The engine expects them relative to the surface +// normal with zero representing vertical. +- (void)testStylusTouchFieldsSurviveConversion { + PointerDataCapturingEngine* engine = [[PointerDataCapturingEngine alloc] initWithName:@"foobar" + project:nil]; + // `-[FlutterEngine setViewController:]` requires PlatformViewIOS, which only exists after + // the shell has been created. + [engine run]; + FlutterViewController* vc = [[FlutterViewController alloc] initWithEngine:engine + nibName:nil + bundle:nil]; + XCTAssertNotNil(vc); + + // A stylus held at 30 degrees off the surface, swung 45 degrees clockwise from the +x axis. + const CGFloat altitudeAngle = M_PI / 6; // 30 degrees from the surface plane. + const CGFloat azimuthAngle = M_PI / 4; // 45 degrees clockwise from +x. + const CGFloat force = 0.75; + const CGFloat maximumPossibleForce = 4.0; + const CGFloat majorRadius = 20.0; + const CGFloat majorRadiusTolerance = 3.0; + + id fakeTouch = OCMPartialMock([[UITouch alloc] init]); + [fakeTouch setPhase:UITouchPhaseBegan]; + OCMStub([fakeTouch force]).andReturn(force); + OCMStub([fakeTouch maximumPossibleForce]).andReturn(maximumPossibleForce); + OCMStub([fakeTouch majorRadius]).andReturn(majorRadius); + OCMStub([fakeTouch majorRadiusTolerance]).andReturn(majorRadiusTolerance); + OCMStub([fakeTouch altitudeAngle]).andReturn(altitudeAngle); + OCMStub([fakeTouch azimuthAngleInView:[OCMArg any]]).andReturn(azimuthAngle); + + [vc dispatchTouches:[NSSet setWithObject:fakeTouch] pointerDataChangeOverride:nullptr event:nil]; + + const std::vector& captured = [engine capturedPointerData]; + XCTAssertEqual(captured.size(), 1u); + const flutter::PointerData& data = captured[0]; + + const double tolerance = 1e-6; + + // Pressure is a straight copy; `pressure_min` is always 0. + XCTAssertEqualWithAccuracy(data.pressure, force, tolerance); + XCTAssertEqualWithAccuracy(data.pressure_max, maximumPossibleForce, tolerance); + XCTAssertEqualWithAccuracy(data.pressure_min, 0.0, tolerance); + + // Radius is a copy, with the tolerance applied symmetrically to produce min/max. + XCTAssertEqualWithAccuracy(data.radius_major, majorRadius, tolerance); + XCTAssertEqualWithAccuracy(data.radius_min, majorRadius - majorRadiusTolerance, tolerance); + XCTAssertEqualWithAccuracy(data.radius_max, majorRadius + majorRadiusTolerance, tolerance); + + // iOS measures altitude from the surface plane; `PointerData.tilt` measures from the surface + // normal. Same range, swapped origin, so the two are complements about pi/2. + XCTAssertEqualWithAccuracy(data.tilt, M_PI_2 - altitudeAngle, tolerance); + + // iOS measures azimuth from the +x axis; `PointerData.orientation` measures from the +y axis. + // Same sweep direction, phase-shifted by pi/2. + XCTAssertEqualWithAccuracy(data.orientation, azimuthAngle - M_PI_2, tolerance); +} + +// Verify max stylus tilt angle. +// +// A stylus lying flat on the glass is altitude 0 to iOS, which must map to the maximum tilt of pi/2 +// in the engine. A stylus held perpendicular is altitude pi/2, mapping to engine tilt 0. +- (void)testStylusTiltEndpoints { + PointerDataCapturingEngine* engine = [[PointerDataCapturingEngine alloc] initWithName:@"foobar" + project:nil]; + [engine run]; + FlutterViewController* vc = [[FlutterViewController alloc] initWithEngine:engine + nibName:nil + bundle:nil]; + + id flatTouch = OCMPartialMock([[UITouch alloc] init]); + [flatTouch setPhase:UITouchPhaseBegan]; + OCMStub([flatTouch altitudeAngle]).andReturn(0.0); + OCMStub([flatTouch azimuthAngleInView:[OCMArg any]]).andReturn(M_PI_2); + + [vc dispatchTouches:[NSSet setWithObject:flatTouch] pointerDataChangeOverride:nullptr event:nil]; + + id perpendicularTouch = OCMPartialMock([[UITouch alloc] init]); + [perpendicularTouch setPhase:UITouchPhaseBegan]; + OCMStub([perpendicularTouch altitudeAngle]).andReturn(M_PI_2); + OCMStub([perpendicularTouch azimuthAngleInView:[OCMArg any]]).andReturn(0.0); + + [vc dispatchTouches:[NSSet setWithObject:perpendicularTouch] + pointerDataChangeOverride:nullptr + event:nil]; + + const double tolerance = 1e-6; + const std::vector& captured = [engine capturedPointerData]; + XCTAssertEqual(captured.size(), 2u); + + // Flat on the surface: maximum tilt. + XCTAssertEqualWithAccuracy(captured[0].tilt, M_PI_2, tolerance); + // Azimuth of pi/2 points along +y, which is orientation 0. + XCTAssertEqualWithAccuracy(captured[0].orientation, 0.0, tolerance); + + // Perpendicular to the surface: zero tilt. + XCTAssertEqualWithAccuracy(captured[1].tilt, 0.0, tolerance); + // Azimuth of 0 points along +x, a quarter turn back from +y. + XCTAssertEqualWithAccuracy(captured[1].orientation, -M_PI_2, tolerance); +} + - (void)testFakeEventTimeStamp { FlutterViewController* vc = [[FlutterViewController alloc] initWithEngine:self.mockEngine nibName:nil From e6bc184fdff90a467e91af894a4f466a7330b5d5 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Mon, 24 Aug 2026 09:57:24 +0000 Subject: [PATCH 33/46] Roll Skia from 925e13559532 to 50452b13f05b (2 revisions) (#191570) https://skia.googlesource.com/skia.git/+log/925e13559532..50452b13f05b 2026-08-24 skia-autoroll@skia-public.iam.gserviceaccount.com Roll vulkan-deps from 0e2916f1c990 to 7cc39635d2f5 (6 revisions) 2026-08-24 recipe-mega-autoroller@chops-service-accounts.iam.gserviceaccount.com Roll recipe dependencies (trivial). If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/skia-flutter-autoroll Please CC awolff@google.com,fmalita@google.com,kjlubick@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Skia: https://bugs.chromium.org/p/skia/issues/entry To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPS b/DEPS index fb12b91dee873..4195fbe284900 100644 --- a/DEPS +++ b/DEPS @@ -15,7 +15,7 @@ vars = { 'skia_git': 'https://skia.googlesource.com', 'llvm_git': 'https://llvm.googlesource.com', 'dart_ai_rev': '9c96bfe5f091c9451eff5b59c9bffeb2e806b875', - 'skia_revision': '925e1355953264cbd07a6e67936b858df9d87779', + 'skia_revision': '50452b13f05b2b3f4b2074487a0f58f1adcf4c05', # Do not download the Emscripten SDK by default. # This prevents us from downloading the Emscripten toolchain for builds From ed2132410ee94b5a590cb7f67cee7a6ea9101a60 Mon Sep 17 00:00:00 2001 From: walley892 Date: Mon, 24 Aug 2026 11:23:25 +0000 Subject: [PATCH 34/46] Move more RSE rendering cases to complex_rse.frag (#191235) Move all non-squarelike non-symmetric RoundSuperellipse rendering to complex_rse.frag. See discussion in https://github.com/flutter/flutter/pull/190874 --- .../flutter/impeller/display_list/canvas.cc | 20 ++- .../entity/contents/complex_rse_contents.cc | 34 +++-- .../entity/contents/complex_rse_contents.h | 7 +- .../entity/contents/uber_sdf_contents.cc | 2 - .../entity/contents/uber_sdf_parameters.cc | 20 ++- .../entity/contents/uber_sdf_parameters.h | 19 +-- .../contents/uber_sdf_parameters_unittests.cc | 122 +++++++++++++++--- .../impeller/entity/shaders/complex_rse.frag | 39 +----- .../impeller/entity/shaders/rse_sdf.glsl | 74 +++++++++++ .../impeller/entity/shaders/uber_sdf.frag | 52 +++----- engine/src/flutter/impeller/tools/malioc.json | 14 +- 11 files changed, 253 insertions(+), 150 deletions(-) create mode 100644 engine/src/flutter/impeller/entity/shaders/rse_sdf.glsl diff --git a/engine/src/flutter/impeller/display_list/canvas.cc b/engine/src/flutter/impeller/display_list/canvas.cc index 450e70488c702..5b74b64767530 100644 --- a/engine/src/flutter/impeller/display_list/canvas.cc +++ b/engine/src/flutter/impeller/display_list/canvas.cc @@ -1237,23 +1237,19 @@ void Canvas::DrawRoundSuperellipse(const RoundSuperellipse& round_superellipse, if (renderer_.GetContext()->GetFlags().use_sdfs && IsCompatibleWithSDFRendering(paint)) { - auto round_superellipse_params = RoundSuperellipseParam::MakeBoundsRadii( - round_superellipse.GetBounds(), round_superellipse.GetRadii()); - - if (round_superellipse_params.all_corners_same) { - auto params = UberSDFParameters::MakeRoundedSuperellipse( - /*color=*/paint.color, - /*bounds=*/round_superellipse.GetBounds(), - /*round_superellipse_params=*/round_superellipse_params, - /*stroke=*/paint.GetStroke()); + // Try to draw using UberSDF. + auto params = UberSDFParameters::MakeRoundedSuperellipse( + /*color=*/paint.color, /*round_superellipse=*/round_superellipse, + /*stroke=*/paint.GetStroke()); - AddRenderSDFEntityToCurrentPass(paint, params); + if (params) { + AddRenderSDFEntityToCurrentPass(paint, *params); return; } else { + // Fall back to ComplexRoundSuperellipse. auto contents = ComplexRoundedSuperellipseContents::Make( /*color=*/paint.color_source ? Color::White() : paint.color, - /*bounds=*/round_superellipse.GetBounds(), - /*round_superellipse_params=*/round_superellipse_params, + /*round_superellipse=*/round_superellipse, /*stroke=*/paint.GetStroke()); const Geometry* geom = contents->GetGeometry(); diff --git a/engine/src/flutter/impeller/entity/contents/complex_rse_contents.cc b/engine/src/flutter/impeller/entity/contents/complex_rse_contents.cc index 5f9ccb18065a1..6949878d96e1a 100644 --- a/engine/src/flutter/impeller/entity/contents/complex_rse_contents.cc +++ b/engine/src/flutter/impeller/entity/contents/complex_rse_contents.cc @@ -8,6 +8,7 @@ #include "impeller/entity/contents/content_context.h" #include "impeller/entity/contents/pipelines.h" #include "impeller/entity/geometry/geometry.h" +#include "impeller/geometry/round_superellipse_param.h" namespace impeller { @@ -24,22 +25,22 @@ using FS = ComplexRSEPipeline::FragmentShader; std::unique_ptr ComplexRoundedSuperellipseContents::Make( Color color, - const Rect& bounds, - const RoundSuperellipseParam& round_superellipse_params, + const RoundSuperellipse& round_superellipse, std::optional stroke) { return std::unique_ptr( - new ComplexRoundedSuperellipseContents( - color, bounds, round_superellipse_params, stroke)); + new ComplexRoundedSuperellipseContents(color, round_superellipse, + stroke)); } ComplexRoundedSuperellipseContents::ComplexRoundedSuperellipseContents( Color color, - const Rect& bounds, - const RoundSuperellipseParam& round_superellipse_params, + const RoundSuperellipse& round_superellipse, std::optional stroke) : color_(color), - bounds_(bounds), - round_superellipse_params_(round_superellipse_params), + bounds_(round_superellipse.GetBounds()), + round_superellipse_params_(RoundSuperellipseParam::MakeBoundsRadii( + round_superellipse.GetBounds(), + round_superellipse.GetRadii())), stroke_(stroke) { if (stroke) { geometry_ = Geometry::MakeRect(bounds_.Expand(stroke->width / 2.0f)); @@ -57,12 +58,17 @@ bool ComplexRoundedSuperellipseContents::Render(const ContentContext& renderer, RoundSuperellipseParam::Quadrant top_right = round_superellipse_params_.top_right; - RoundSuperellipseParam::Quadrant bottom_right = - round_superellipse_params_.bottom_right; - RoundSuperellipseParam::Quadrant bottom_left = - round_superellipse_params_.bottom_left; - RoundSuperellipseParam::Quadrant top_left = - round_superellipse_params_.top_left; + + RoundSuperellipseParam::Quadrant bottom_right, bottom_left, top_left; + if (round_superellipse_params_.all_corners_same) { + bottom_right = top_right; + bottom_left = top_right; + top_left = top_right; + } else { + bottom_right = round_superellipse_params_.bottom_right; + bottom_left = round_superellipse_params_.bottom_left; + top_left = round_superellipse_params_.top_left; + } Point top_right_center_relative = top_right.offset - center; Point bottom_right_center_relative = bottom_right.offset - center; diff --git a/engine/src/flutter/impeller/entity/contents/complex_rse_contents.h b/engine/src/flutter/impeller/entity/contents/complex_rse_contents.h index 055f5d2c03608..2739d10994ba4 100644 --- a/engine/src/flutter/impeller/entity/contents/complex_rse_contents.h +++ b/engine/src/flutter/impeller/entity/contents/complex_rse_contents.h @@ -13,6 +13,7 @@ #include "impeller/entity/geometry/geometry.h" #include "impeller/geometry/color.h" #include "impeller/geometry/rect.h" +#include "impeller/geometry/round_superellipse.h" #include "impeller/geometry/round_superellipse_param.h" #include "impeller/geometry/stroke_parameters.h" @@ -25,8 +26,7 @@ class ComplexRoundedSuperellipseContents : public ColorSourceContents { public: static std::unique_ptr Make( Color color, - const Rect& bounds, - const RoundSuperellipseParam& round_superellipse_params, + const RoundSuperellipse& round_superellipse, std::optional stroke); bool Render(const ContentContext& renderer, @@ -40,8 +40,7 @@ class ComplexRoundedSuperellipseContents : public ColorSourceContents { private: explicit ComplexRoundedSuperellipseContents( Color color, - const Rect& bounds, - const RoundSuperellipseParam& round_superellipse_params, + const RoundSuperellipse& round_superellipse, std::optional stroke); Color color_; diff --git a/engine/src/flutter/impeller/entity/contents/uber_sdf_contents.cc b/engine/src/flutter/impeller/entity/contents/uber_sdf_contents.cc index 5bec7103c92b5..531ab32f3a018 100644 --- a/engine/src/flutter/impeller/entity/contents/uber_sdf_contents.cc +++ b/engine/src/flutter/impeller/entity/contents/uber_sdf_contents.cc @@ -79,12 +79,10 @@ bool UberSDFContents::Render(const ContentContext& renderer, params_.stroke ? ToShaderStrokeJoin(params_.stroke->join) : 0.0f; frag_info.aa_pixels = UberSDFParameters::kAntialiasPixels; frag_info.superellipse_degree = params_.superellipse_degree; - frag_info.superellipse_semi_axis = params_.superellipse_semi_axis; frag_info.angle_span = params_.angle_span; frag_info.octant_offset_c = params_.octant_offset_c; frag_info.circle_center_top = params_.circle_center_top; frag_info.circle_center_right = params_.circle_center_right; - frag_info.superellipse_scale = params_.superellipse_scale; frag_info.radii = params_.radii; auto geometry_result = diff --git a/engine/src/flutter/impeller/entity/contents/uber_sdf_parameters.cc b/engine/src/flutter/impeller/entity/contents/uber_sdf_parameters.cc index e804a0cc7469c..f349bb74fa277 100644 --- a/engine/src/flutter/impeller/entity/contents/uber_sdf_parameters.cc +++ b/engine/src/flutter/impeller/entity/contents/uber_sdf_parameters.cc @@ -3,6 +3,9 @@ // found in the LICENSE file. #include "impeller/entity/contents/uber_sdf_parameters.h" +#include "fml/logging.h" +#include "impeller/geometry/round_superellipse.h" +#include "impeller/geometry/round_superellipse_param.h" namespace impeller { @@ -73,12 +76,19 @@ UberSDFParameters UberSDFParameters::MakeRoundedRect( radii.bottom_left.width, radii.top_left.width)}; } -UberSDFParameters UberSDFParameters::MakeRoundedSuperellipse( +std::optional UberSDFParameters::MakeRoundedSuperellipse( Color color, - const Rect& bounds, - const RoundSuperellipseParam& round_superellipse_params, + const RoundSuperellipse& round_superellipse, std::optional stroke) { - FML_DCHECK(round_superellipse_params.all_corners_same); + // UberSDF only supports RSEs with symmetric circular radii. + if (!(round_superellipse.GetRadii().AreAllCornersCircular() && + round_superellipse.GetRadii().AreAllCornersSame())) { + return std::nullopt; + } + auto bounds = round_superellipse.GetBounds(); + auto round_superellipse_params = RoundSuperellipseParam::MakeBoundsRadii( + bounds, round_superellipse.GetRadii()); + Point center = bounds.GetCenter(); RoundSuperellipseParam::Quadrant top_right = @@ -93,13 +103,11 @@ UberSDFParameters UberSDFParameters::MakeRoundedSuperellipse( .size = size, .stroke = stroke, .superellipse_degree = Point(top_right.top.se_n, top_right.right.se_n), - .superellipse_semi_axis = Point(top_right.top.se_a, top_right.right.se_a), .angle_span = Point(top_right.top.circle_max_angle.radians, top_right.right.circle_max_angle.radians), .octant_offset_c = top_right.top.se_a - top_right.right.se_a, .circle_center_top = top_right.top.circle_center, .circle_center_right = top_right.right.circle_center, - .superellipse_scale = top_right.signed_scale.Abs(), .radii = Vector4(top_right.top.circle_radius, top_right.right.circle_radius, 0.0f, 0.0f)}; } diff --git a/engine/src/flutter/impeller/entity/contents/uber_sdf_parameters.h b/engine/src/flutter/impeller/entity/contents/uber_sdf_parameters.h index 942d6ccf5db3e..ee675ca21642c 100644 --- a/engine/src/flutter/impeller/entity/contents/uber_sdf_parameters.h +++ b/engine/src/flutter/impeller/entity/contents/uber_sdf_parameters.h @@ -11,7 +11,7 @@ #include "impeller/geometry/point.h" #include "impeller/geometry/rect.h" #include "impeller/geometry/round_rect.h" -#include "impeller/geometry/round_superellipse_param.h" +#include "impeller/geometry/round_superellipse.h" #include "impeller/geometry/stroke_parameters.h" #include "impeller/geometry/vector.h" @@ -55,11 +55,12 @@ struct UberSDFParameters { const RoundingRadii& radii, std::optional stroke); - /// Creates UberSDFParameters for an asymmetric round superellipse. - static UberSDFParameters MakeRoundedSuperellipse( + /// Creates UberSDFParameters for a rounded superellipse with + /// uniform circular corner radii. Returns std::nullopt if given an + /// incompatible rounded superellipse. + static std::optional MakeRoundedSuperellipse( Color color, - const Rect& bounds, - const RoundSuperellipseParam& round_superellipse_params, + const RoundSuperellipse& round_superellipse, std::optional stroke); /// The type of shape to render. @@ -82,10 +83,6 @@ struct UberSDFParameters { /// The degree (n) of the superellipse curve for the top and right octants. Point superellipse_degree; - /// The semi-axis length of the superellipse curve for the top and right - /// octants. - Point superellipse_semi_axis; - /// The angular span of the circular cap for the top and right octants. Point angle_span; @@ -100,10 +97,6 @@ struct UberSDFParameters { /// quadrant. Point circle_center_right; - /// The scaling factors used to transform normalized superellipses to their - /// true size. - Point superellipse_scale; - /// Rounding radii for standard rounded rects and corner radii for circular /// caps of superellipses for top and right octants. Vector4 radii; diff --git a/engine/src/flutter/impeller/entity/contents/uber_sdf_parameters_unittests.cc b/engine/src/flutter/impeller/entity/contents/uber_sdf_parameters_unittests.cc index 2c5858925e1bb..1e906a30be451 100644 --- a/engine/src/flutter/impeller/entity/contents/uber_sdf_parameters_unittests.cc +++ b/engine/src/flutter/impeller/entity/contents/uber_sdf_parameters_unittests.cc @@ -2,9 +2,12 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +#include #include "gtest/gtest.h" #include "impeller/entity/contents/uber_sdf_parameters.h" #include "impeller/geometry/rect.h" +#include "impeller/geometry/round_superellipse.h" +#include "impeller/geometry/round_superellipse_param.h" namespace impeller { namespace testing { @@ -131,34 +134,111 @@ TEST(UberSDFParametersTest, MakeRoundedSuperellipse) { .bottom_left = Size(10.0f, 10.0f), .bottom_right = Size(10.0f, 10.0f), }; + auto round_superellipse = RoundSuperellipse::MakeRectRadii(rect, radii); auto round_superellipse_params = RoundSuperellipseParam::MakeBoundsRadii(rect, radii); - auto params = UberSDFParameters::MakeRoundedSuperellipse( - /*color=*/Color::Red(), /*bounds=*/rect, - /*round_superellipse_params=*/round_superellipse_params, + auto maybe_params = UberSDFParameters::MakeRoundedSuperellipse( + /*color=*/Color::Red(), /*round_superellipse=*/round_superellipse, /*stroke=*/std::nullopt); - EXPECT_EQ(params.type, - UberSDFParameters::Type::kRoundedSuperellipseSymmetric); - EXPECT_EQ(params.color, Color::Red()); - EXPECT_EQ(params.center, Point(60, 70)); - EXPECT_EQ(params.size, Point(50, 50)); - EXPECT_FALSE(params.stroke.has_value()); + ASSERT_TRUE(maybe_params.has_value()); + + if (maybe_params.has_value()) { + auto params = maybe_params.value(); + + EXPECT_EQ(params.type, + UberSDFParameters::Type::kRoundedSuperellipseSymmetric); + EXPECT_EQ(params.color, Color::Red()); + EXPECT_EQ(params.center, Point(60, 70)); + EXPECT_EQ(params.size, Point(50, 50)); + EXPECT_FALSE(params.stroke.has_value()); + + EXPECT_EQ(params.radii.x, + round_superellipse_params.top_right.top.circle_radius); + EXPECT_EQ(params.radii.y, + round_superellipse_params.top_right.right.circle_radius); + + EXPECT_EQ(params.superellipse_degree.x, + round_superellipse_params.top_right.top.se_n); + EXPECT_EQ(params.superellipse_degree.y, + round_superellipse_params.top_right.right.se_n); + + EXPECT_EQ(params.angle_span.x, + round_superellipse_params.top_right.top.circle_max_angle.radians); + EXPECT_EQ( + params.angle_span.y, + round_superellipse_params.top_right.right.circle_max_angle.radians); + + EXPECT_EQ(params.octant_offset_c, + round_superellipse_params.top_right.top.se_a - + round_superellipse_params.top_right.right.se_a); + + EXPECT_EQ(params.circle_center_top, + round_superellipse_params.top_right.top.circle_center); + EXPECT_EQ(params.circle_center_right, + round_superellipse_params.top_right.right.circle_center); + } +} + +TEST(UberSDFParametersTest, MakeRectangularRoundedSuperellipse) { + Rect rect = Rect::MakeXYWH(10, 20, 100, 200); + RoundingRadii radii = { + .top_left = Size(10.0f, 10.0f), + .top_right = Size(10.0f, 10.0f), + .bottom_left = Size(10.0f, 10.0f), + .bottom_right = Size(10.0f, 10.0f), + }; + auto round_superellipse = RoundSuperellipse::MakeRectRadii(rect, radii); + auto maybe_params = UberSDFParameters::MakeRoundedSuperellipse( + /*color=*/Color::Red(), /*round_superellipse=*/round_superellipse, + /*stroke=*/std::nullopt); + + ASSERT_TRUE(maybe_params.has_value()); + + if (maybe_params.has_value()) { + auto params = maybe_params.value(); - EXPECT_EQ(params.radii.x, - round_superellipse_params.top_right.top.circle_radius); - EXPECT_EQ(params.radii.y, - round_superellipse_params.top_right.right.circle_radius); + EXPECT_EQ(params.type, + UberSDFParameters::Type::kRoundedSuperellipseSymmetric); + EXPECT_EQ(params.color, Color::Red()); + EXPECT_EQ(params.center, Point(60, 120)); + EXPECT_EQ(params.size, Point(50, 100)); + EXPECT_FALSE(params.stroke.has_value()); - EXPECT_EQ(params.superellipse_degree.x, - round_superellipse_params.top_right.top.se_n); - EXPECT_EQ(params.superellipse_degree.y, - round_superellipse_params.top_right.right.se_n); + EXPECT_EQ(params.octant_offset_c, -50.0f); + } +} + +TEST(UberSDFParametersTest, MakeRoundedSuperellipseRejectsNonSymmetric) { + Rect rect = Rect::MakeXYWH(10, 20, 100, 200); + RoundingRadii radii = { + .top_left = Size(10.0f, 10.0f), + .top_right = Size(9.0f, 9.0f), + .bottom_left = Size(10.0f, 10.0f), + .bottom_right = Size(10.0f, 10.0f), + }; + auto round_superellipse = RoundSuperellipse::MakeRectRadii(rect, radii); + auto params = UberSDFParameters::MakeRoundedSuperellipse( + /*color=*/Color::Red(), /*round_superellipse=*/round_superellipse, + /*stroke=*/std::nullopt); + + EXPECT_FALSE(params.has_value()); +} + +TEST(UberSDFParametersTest, MakeRoundedSuperellipseRejectsNonCircular) { + Rect rect = Rect::MakeXYWH(10, 20, 100, 200); + RoundingRadii radii = { + .top_left = Size(9.0f, 10.0f), + .top_right = Size(9.0f, 10.0f), + .bottom_left = Size(9.0f, 10.0f), + .bottom_right = Size(9.0f, 10.0f), + }; + auto round_superellipse = RoundSuperellipse::MakeRectRadii(rect, radii); + auto params = UberSDFParameters::MakeRoundedSuperellipse( + /*color=*/Color::Red(), /*round_superellipse=*/round_superellipse, + /*stroke=*/std::nullopt); - EXPECT_EQ(params.superellipse_scale.x, - round_superellipse_params.top_right.signed_scale.Abs().x); - EXPECT_EQ(params.superellipse_scale.y, - round_superellipse_params.top_right.signed_scale.Abs().y); + EXPECT_FALSE(params.has_value()); } } // namespace testing diff --git a/engine/src/flutter/impeller/entity/shaders/complex_rse.frag b/engine/src/flutter/impeller/entity/shaders/complex_rse.frag index fe48c767958e6..588ba7fc96fa0 100644 --- a/engine/src/flutter/impeller/entity/shaders/complex_rse.frag +++ b/engine/src/flutter/impeller/entity/shaders/complex_rse.frag @@ -7,6 +7,7 @@ precision mediump float; #include #include +#include "rse_sdf.glsl" #include "sdf_functions.glsl" #include "sdf_utils.glsl" @@ -41,10 +42,6 @@ out vec4 frag_color; highp in vec2 v_position; -float distanceFromCircle(vec2 p, float radius) { - return length(p) - radius; -} - float getQuadrantDistance(vec2 p, float se_degree_top, float se_degree_right, @@ -120,37 +117,11 @@ float getQuadrantDistance(vec2 p, axis_length = se_a_right; } - // Move the point to the corner circle's coordinate system. - vec2 p_rel = p_oct - circle_center; - // Grab the angle offset of the point. - float theta = atan(p_rel.y, p_rel.x); - - // The angular distance between the point and the 45 degree midline. - float d_theta = theta - PI_OVER_FOUR; - d_theta = mod(d_theta + PI, TWO_PI) - PI; - - float dist_raw; - vec2 grad_oct; + vec3 dist_with_grad = distanceFromRSEOctantWithGrad( + p_oct, circle_center, radius, span, axis_length, se_degree); - // If the point is within the span of the corner circle's arc, - // use a circle SDF. - // This works because the normals of the circular and superelliptical sections - // agree at the transition angle, the total RSE curve is continuous and - // the closest point on a continuous curve to a point lies along the normal. - - // We also compute the gradient of the distance function for normalization. - if (abs(d_theta) < abs(span)) { - dist_raw = distanceFromCircle(p_rel, radius); - grad_oct = normalize(p_rel); - } else { - dist_raw = sdSuperellipse(p_oct / axis_length, se_degree) * axis_length; - // Clamp the coordinate to avoid division by zero - vec2 p_oct_clamped = max(p_oct, vec2(0.001)); - float max_p = max(p_oct_clamped.x, p_oct_clamped.y); - vec2 p_safe = p_oct_clamped / max_p; - // Approximation of the gradient - grad_oct = normalize(pow(p_safe, vec2(se_degree - 1.0))); - } + float dist_raw = dist_with_grad.x; + vec2 grad_oct = dist_with_grad.yz; if (p_norm.y + c <= p_norm.x) { grad_oct = grad_oct.yx; diff --git a/engine/src/flutter/impeller/entity/shaders/rse_sdf.glsl b/engine/src/flutter/impeller/entity/shaders/rse_sdf.glsl new file mode 100644 index 0000000000000..33cb5fb0f7363 --- /dev/null +++ b/engine/src/flutter/impeller/entity/shaders/rse_sdf.glsl @@ -0,0 +1,74 @@ +// 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. + +#ifndef RSE_SDF_GLSL_ +#define RSE_SDF_GLSL_ + +#include "sdf_functions.glsl" +#include "sdf_utils.glsl" + +bool useCircularDistance(vec2 p, float radius, float span) { + // Grab the angle offset of the point. + float theta = atan(p.y, p.x); + + // The angular distance between the point and the 45 degree midline. + float d_theta = theta - PI_OVER_FOUR; + d_theta = mod(d_theta + PI, TWO_PI) - PI; + + // If the point is within the span of the corner circle's arc, + // use a circle SDF. + // This works because the normals of the circular and superelliptical sections + // agree at the transition angle, the total RSE curve is continuous and + // the closest point on a continuous curve to a point lies along the normal. + if (abs(d_theta) < abs(span)) { + return true; + } + + return false; +} + +float distanceFromRSEOctant(vec2 p_oct, + vec2 circle_center, + float radius, + float span, + float axis_length, + float se_degree) { + // Move the point to the corner circle's coordinate system. + vec2 p_rel = p_oct - circle_center; + + if (useCircularDistance(p_rel, radius, span)) { + return length(p_rel) - radius; + } + return sdSuperellipse(p_oct / axis_length, se_degree) * axis_length; +} + +vec3 distanceFromRSEOctantWithGrad(vec2 p_oct, + vec2 circle_center, + float radius, + float span, + float axis_length, + float se_degree) { + // Move the point to the corner circle's coordinate system. + vec2 p_rel = p_oct - circle_center; + // Gradient of the sdf. + vec2 grad_oct; + + if (useCircularDistance(p_rel, radius, span)) { + grad_oct = normalize(p_rel); + float dist = length(p_rel) - radius; + return vec3(dist, grad_oct); + } + + // Clamp the coordinate to avoid division by zero + vec2 p_oct_clamped = max(p_oct, vec2(0.001)); + float max_p = max(p_oct_clamped.x, p_oct_clamped.y); + vec2 p_safe = p_oct_clamped / max_p; + // Approximation of the gradient + grad_oct = normalize(pow(p_safe, vec2(se_degree - 1.0))); + + float dist = sdSuperellipse(p_oct / axis_length, se_degree) * axis_length; + return vec3(dist, grad_oct); +} + +#endif // RSE_SDF_GLSL_ diff --git a/engine/src/flutter/impeller/entity/shaders/uber_sdf.frag b/engine/src/flutter/impeller/entity/shaders/uber_sdf.frag index 97a5f5b715928..3221a1639cbcd 100644 --- a/engine/src/flutter/impeller/entity/shaders/uber_sdf.frag +++ b/engine/src/flutter/impeller/entity/shaders/uber_sdf.frag @@ -7,6 +7,7 @@ precision mediump float; #include #include +#include "rse_sdf.glsl" #include "sdf_functions.glsl" #include "sdf_utils.glsl" @@ -20,12 +21,10 @@ uniform FragInfo { float stroked; float type; vec2 superellipse_degree; - vec2 superellipse_semi_axis; vec2 angle_span; float octant_offset_c; vec2 circle_center_top; vec2 circle_center_right; - vec2 superellipse_scale; vec4 radii; } frag_info; @@ -74,17 +73,14 @@ float distanceFromChamferRect(vec2 p, vec2 half_size, float chamfer_size) { float distanceFromRoundedSuperellipse(vec2 p, vec2 degree, - vec2 se_a, + vec2 size, vec2 radii, vec2 angle_span, vec2 circle_center_top, vec2 circle_center_right, - float c, - vec2 scale) { + float c) { // Do work in the first quadrant to simply things. p = abs(p); - // Map p in to a square. - vec2 p_norm = p / scale; // Declare all RSE params for a single octant. float se_degree, span, radius, axis_length; @@ -93,46 +89,29 @@ float distanceFromRoundedSuperellipse(vec2 p, // 'p' in the coordinate system of the octant. vec2 p_oct; - // We split the quadrant along the diagonal of the transition (p_norm.y + c == - // p_norm.x). This allows us to grab the correct set of parameters for the + // We split the quadrant along the diagonal of the transition (p.y + c == + // p.x). This allows us to grab the correct set of parameters for the // "top" and "right" halves of the corner. - if (p_norm.y + c > p_norm.x) { - p_oct = p_norm + vec2(0.0, c); + if (p.y + c > p.x) { + p_oct = p + vec2(0.0, c); se_degree = degree.x; span = angle_span.x; radius = radii.x; circle_center = circle_center_top; - axis_length = se_a.x; + axis_length = size.x; } else { // For the 'right' octant, we flip the point and shift it according to // the CPU's OctantContains/Flip logic. - p_oct = p_norm.yx - vec2(0.0, c); + p_oct = p.yx - vec2(0.0, c); se_degree = degree.y; span = angle_span.y; radius = radii.y; circle_center = circle_center_right; - axis_length = se_a.y; + axis_length = size.y; } - // Move the point to the corner circle's coordinate system. - vec2 p_rel = p_oct - circle_center; - - // Grab the angle offset of the point. - float theta = atan(p_rel.y, p_rel.x); - - // The angular distance between the point and the 45 degree midline. - float d_theta = theta - PI_OVER_FOUR; - d_theta = mod(d_theta + PI, TWO_PI) - PI; - - // If the point is within the span of the corner circle's arc, - // use a circle SDF. - // This works because the normals of the circular and superelliptical sections - // agree at the transition angle, the total RSE curve is continuous and - // the closest point on a continuous curve to a point lies along the normal. - if (abs(d_theta) < abs(span)) { - return distanceFromCircle(p_rel, radius); - } - return sdSuperellipse(p_oct / axis_length, se_degree) * axis_length; + return distanceFromRSEOctant(p_oct, circle_center, radius, span, axis_length, + se_degree); } // Special case pixel size calculation for rectangles. The standard `pixelSize` @@ -214,10 +193,9 @@ vec2 filledSDF(vec2 p) { pixel_size = roundRectPixelSize(p); } else { // Symmetric Rounded Superellipse sdf = distanceFromRoundedSuperellipse( - p, frag_info.superellipse_degree, frag_info.superellipse_semi_axis, - frag_info.radii.xy, frag_info.angle_span, frag_info.circle_center_top, - frag_info.circle_center_right, frag_info.octant_offset_c, - frag_info.superellipse_scale); + p, frag_info.superellipse_degree, frag_info.size, frag_info.radii.xy, + frag_info.angle_span, frag_info.circle_center_top, + frag_info.circle_center_right, frag_info.octant_offset_c); pixel_size = pixelSize(sdf); } return vec2(sdf, pixel_size); diff --git a/engine/src/flutter/impeller/tools/malioc.json b/engine/src/flutter/impeller/tools/malioc.json index d93c1e6ad6f2d..8a0515f4f7014 100644 --- a/engine/src/flutter/impeller/tools/malioc.json +++ b/engine/src/flutter/impeller/tools/malioc.json @@ -672,7 +672,7 @@ "longest_path_cycles": [ 4.25, 3.637500047683716, - 3.487499952316284, + 3.53125, 4.25, 0.0, 0.25, @@ -707,7 +707,7 @@ "total_cycles": [ 4.3125, 3.737499952316284, - 3.75, + 3.799999952316284, 4.3125, 0.0, 0.25, @@ -2882,7 +2882,7 @@ "longest_path_cycles": [ 4.25, 4.074999809265137, - 3.46875, + 3.53125, 4.25, 0.0, 0.25, @@ -2917,7 +2917,7 @@ "total_cycles": [ 4.3125, 4.1875, - 3.924999952316284, + 3.987499952316284, 4.3125, 0.0, 0.25, @@ -2957,7 +2957,7 @@ "arithmetic" ], "shortest_path_cycles": [ - 9.899999618530273, + 10.229999542236328, 1.0, 2.0 ], @@ -8651,7 +8651,7 @@ }, "stack_spill_bytes": 0, "thread_occupancy": 100, - "uniform_registers_used": 46, + "uniform_registers_used": 44, "work_registers_used": 32 } } @@ -8696,7 +8696,7 @@ ] }, "thread_occupancy": 100, - "uniform_registers_used": 7, + "uniform_registers_used": 5, "work_registers_used": 4 } } From 800eb027e5945d79e7e2a5180c7adbfa522233c5 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Mon, 24 Aug 2026 13:30:14 +0000 Subject: [PATCH 35/46] Roll Skia from 50452b13f05b to 57968d087d18 (1 revision) (#191572) https://skia.googlesource.com/skia.git/+log/50452b13f05b..57968d087d18 2026-08-24 skia-autoroll@skia-public.iam.gserviceaccount.com Manual roll Dawn from 01af1ad339cb to 55d132a23849 (7 revisions) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/skia-flutter-autoroll Please CC awolff@google.com,fmalita@google.com,kjlubick@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Skia: https://bugs.chromium.org/p/skia/issues/entry To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPS b/DEPS index 4195fbe284900..6abf85472cb14 100644 --- a/DEPS +++ b/DEPS @@ -15,7 +15,7 @@ vars = { 'skia_git': 'https://skia.googlesource.com', 'llvm_git': 'https://llvm.googlesource.com', 'dart_ai_rev': '9c96bfe5f091c9451eff5b59c9bffeb2e806b875', - 'skia_revision': '50452b13f05b2b3f4b2074487a0f58f1adcf4c05', + 'skia_revision': '57968d087d18c203a59689c48f4ec707eb17a52b', # Do not download the Emscripten SDK by default. # This prevents us from downloading the Emscripten toolchain for builds From a48daa092b0bcae109512d4d20852567d6b11da9 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Mon, 24 Aug 2026 13:54:43 +0000 Subject: [PATCH 36/46] Roll Dart SDK from cb056da580b6 to 78bbc37b6ff6 (13 revisions) (#191574) https://dart.googlesource.com/sdk.git/+log/cb056da580b6..78bbc37b6ff6 2026-08-24 dart-internal-merge@dart-ci-internal.iam.gserviceaccount.com Version 3.14.0-160.0.dev 2026-08-22 dart-internal-merge@dart-ci-internal.iam.gserviceaccount.com Version 3.14.0-159.0.dev 2026-08-22 dart-internal-merge@dart-ci-internal.iam.gserviceaccount.com Version 3.14.0-158.0.dev 2026-08-21 dart-internal-merge@dart-ci-internal.iam.gserviceaccount.com Version 3.14.0-157.0.dev 2026-08-21 dart-internal-merge@dart-ci-internal.iam.gserviceaccount.com Version 3.14.0-156.0.dev 2026-08-21 dart-internal-merge@dart-ci-internal.iam.gserviceaccount.com Version 3.14.0-155.0.dev 2026-08-21 dart-internal-merge@dart-ci-internal.iam.gserviceaccount.com Version 3.14.0-154.0.dev 2026-08-21 dart-internal-merge@dart-ci-internal.iam.gserviceaccount.com Version 3.14.0-153.0.dev 2026-08-20 dart-internal-merge@dart-ci-internal.iam.gserviceaccount.com Version 3.14.0-152.0.dev 2026-08-20 dart-internal-merge@dart-ci-internal.iam.gserviceaccount.com Version 3.14.0-151.0.dev 2026-08-20 dart-internal-merge@dart-ci-internal.iam.gserviceaccount.com Version 3.14.0-150.0.dev 2026-08-20 dart-internal-merge@dart-ci-internal.iam.gserviceaccount.com Version 3.14.0-149.0.dev 2026-08-20 dart-internal-merge@dart-ci-internal.iam.gserviceaccount.com Version 3.14.0-148.0.dev If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/dart-sdk-flutter Please CC awolff@google.com,dart-vm-team@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/DEPS b/DEPS index 6abf85472cb14..d3ba607aa2006 100644 --- a/DEPS +++ b/DEPS @@ -55,12 +55,12 @@ vars = { # updated revision list of existing dependencies. You will need to # gclient sync before and after update deps to ensure all deps are updated. # updated revision list of existing dependencies. - 'dart_revision': 'cb056da580b6357c87b4dbea6ae850a6d808cea6', + 'dart_revision': '78bbc37b6ff6341346c7c8fa06e5c9c6ec91ee97', # WARNING: DO NOT EDIT MANUALLY # The lines between blank lines above and below are generated by a script. See create_updated_flutter_deps.py 'dart_binaryen_rev': '9926156a583cec3d22d521232b31c70fa9a87dc1', - 'dart_boringssl_rev': 'defe5810ee8be430bcdeccf46a199bec0e93abdb', + 'dart_boringssl_rev': 'da182b2afcea3677f726e708ac5e53eea6760781', 'dart_core_rev': '773de9d6321bc2c7d86d6926e30b341f0b8c2cb1', 'dart_devtools_rev': '21f1838f3a9b138ac377efb953ca5a53c8832e75', 'dart_ecosystem_rev': 'cda8bd535dfbcff45010bc3c843d325682f944b2', @@ -70,7 +70,7 @@ vars = { 'dart_protobuf_rev': '91efb90f437bb6a30e6726c3369a2fcb9bba06e7', 'dart_pub_rev': '7654d523a42e764fad77c9e7b63a9686b88c9323', 'dart_sync_http_rev': '6666fff944221891182e1f80bf56569338164d72', - 'dart_tools_rev': 'a4b459cca3ab39466bf8fe6734622b92015b5e15', + 'dart_tools_rev': '7dc3279da2d7b6bdd49164cada8531d6a2125c15', 'dart_vector_math_rev': 'cf3b5db7340d317dd3489e5a35434b408020a852', 'dart_web_rev': '6b84f811cd67a5fd05f4dac24cb56542bcfc92e4', 'dart_webdriver_rev': '3a711ebb36871eac997c5d5d2429f7414873dc63', @@ -336,7 +336,7 @@ deps = { Var('dart_git') + '/core.git' + '@' + Var('dart_core_rev'), 'engine/src/flutter/third_party/dart/third_party/pkg/dart_style': - Var('dart_git') + '/dart_style.git@dfdf6420c7ea923d28edef3f11e89b4ff23d03bf', + Var('dart_git') + '/dart_style.git@39edc2d946a5d7bd1caf6f1695f366b00f7b873c', 'engine/src/flutter/third_party/dart/third_party/pkg/dartdoc': Var('dart_git') + '/dartdoc.git@27376696f59b8776af3a2d07291a53562767d345', @@ -354,7 +354,7 @@ deps = { Var('dart_git') + '/leak_tracker.git@f5620600a5ce1c44f65ddaa02001e200b096e14c', 'engine/src/flutter/third_party/dart/third_party/pkg/native': - Var('dart_git') + '/native.git@d196dea41ad2a901a6734066e2c73002a02f9fd5', + Var('dart_git') + '/native.git@057ba8856b8ed2135c549a27719884fd9c7f2ed1', 'engine/src/flutter/third_party/dart/third_party/pkg/protobuf': Var('dart_git') + '/protobuf.git' + '@' + Var('dart_protobuf_rev'), From 9a82789825881611e605c79329e5173d36ddffa9 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Mon, 24 Aug 2026 15:37:33 +0000 Subject: [PATCH 37/46] Roll Packages from 252bb33ad366 to df2ba94faea6 (5 revisions) (#191578) https://github.com/flutter/packages/compare/252bb33ad366...df2ba94faea6 2026-08-24 49699333+dependabot[bot]@users.noreply.github.com [dependabot]: Bump org.json:json from 20260719 to 20260814 in /packages/in_app_purchase/in_app_purchase_android/example/android/app (flutter/packages#12570) 2026-08-22 engine-flutter-autoroll@skia.org Roll Flutter from c2437523d308 to 65c9a8dc60bc (195 revisions) (flutter/packages#12554) 2026-08-21 tarrinneal@gmail.com [pigeon] add modern concurrency support (flutter/packages#12382) 2026-08-21 32538273+ValentinVignal@users.noreply.github.com [material_ui] Remove no-shuffle in scrollbar test (flutter/packages#12527) 2026-08-21 32538273+ValentinVignal@users.noreply.github.com [material_ui] Remove no-shuffle from dropdown test (flutter/packages#12528) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/flutter-packages-flutter-autoroll Please CC flutter-ecosystem@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- bin/internal/flutter_packages.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/flutter_packages.version b/bin/internal/flutter_packages.version index 56e1df57b98bb..6d9ea4dc1b591 100644 --- a/bin/internal/flutter_packages.version +++ b/bin/internal/flutter_packages.version @@ -1 +1 @@ -252bb33ad3666c7d28621c87edccafff86e210fd +df2ba94faea6a88a7c749c1d3116d9dc232c3ac8 From 0b1bac2ff4831b0ce2357cefac64a769498686d8 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Mon, 24 Aug 2026 16:01:20 +0000 Subject: [PATCH 38/46] Roll Skia from 57968d087d18 to 698c60bf58f1 (1 revision) (#191577) https://skia.googlesource.com/skia.git/+log/57968d087d18..698c60bf58f1 2026-08-24 jmbetancourt@google.com [Dawn] Isolate DRMUtils to its own file list that doesn't get built If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/skia-flutter-autoroll Please CC awolff@google.com,fmalita@google.com,kjlubick@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Skia: https://bugs.chromium.org/p/skia/issues/entry To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPS b/DEPS index d3ba607aa2006..bdab022d875b9 100644 --- a/DEPS +++ b/DEPS @@ -15,7 +15,7 @@ vars = { 'skia_git': 'https://skia.googlesource.com', 'llvm_git': 'https://llvm.googlesource.com', 'dart_ai_rev': '9c96bfe5f091c9451eff5b59c9bffeb2e806b875', - 'skia_revision': '57968d087d18c203a59689c48f4ec707eb17a52b', + 'skia_revision': '698c60bf58f175479343f07c31904b404a2b843e', # Do not download the Emscripten SDK by default. # This prevents us from downloading the Emscripten toolchain for builds From 57e47bd71e1a3f3501f9254651ddebd971290e5a Mon Sep 17 00:00:00 2001 From: Ben Konyi Date: Mon, 24 Aug 2026 16:18:10 +0000 Subject: [PATCH 39/46] [flutter_tools] Add --force flag to flutter channel (#191579) Adds a `--force` (`-f`) flag to `flutter channel` to allow switching channels even when local SDK modifications or untracked changes exist, matching `flutter upgrade` behavior. Fixes https://github.com/flutter/flutter/issues/191262 ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://discord.gg/rflutter [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests --- .../lib/src/commands/channel.dart | 12 +++- .../test/general.shard/channel_test.dart | 65 +++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/packages/flutter_tools/lib/src/commands/channel.dart b/packages/flutter_tools/lib/src/commands/channel.dart index adec381b58f3a..d9ee5c12f6069 100644 --- a/packages/flutter_tools/lib/src/commands/channel.dart +++ b/packages/flutter_tools/lib/src/commands/channel.dart @@ -27,6 +27,12 @@ class ChannelCommand extends FlutterCommand { 'This is the equivalent of running "flutter precache" with the "--all-platforms" flag.', defaultsTo: true, ); + argParser.addFlag( + 'force', + abbr: 'f', + help: 'Force switch channels, potentially discarding local changes.', + negatable: false, + ); } @override @@ -164,7 +170,7 @@ class ChannelCommand extends FlutterCommand { 'This is not an official channel. For a list of available channels, try "flutter channel".', ); } - await _checkout(branchName); + await _checkout(branchName, force: boolArg('force')); if (boolArg('cache-artifacts')) { await precacheArtifacts(Cache.flutterRoot); } @@ -183,7 +189,7 @@ class ChannelCommand extends FlutterCommand { } } - static Future _checkout(String branchName) async { + static Future _checkout(String branchName, {bool force = false}) async { // Get latest refs from upstream. RunResult runResult = await globals.git.run([ 'fetch', @@ -200,6 +206,7 @@ class ChannelCommand extends FlutterCommand { // branch already exists, try just switching to it runResult = await globals.git.run([ 'checkout', + if (force) '-f', branchName, '--', ], workingDirectory: Cache.flutterRoot); @@ -207,6 +214,7 @@ class ChannelCommand extends FlutterCommand { // branch does not exist, we have to create it runResult = await globals.git.run([ 'checkout', + if (force) '-f', '--track', '-b', branchName, diff --git a/packages/flutter_tools/test/general.shard/channel_test.dart b/packages/flutter_tools/test/general.shard/channel_test.dart index 3c15a5030a34e..0b52710a9881e 100644 --- a/packages/flutter_tools/test/general.shard/channel_test.dart +++ b/packages/flutter_tools/test/general.shard/channel_test.dart @@ -348,6 +348,71 @@ void main() { }, ); + testUsingContext( + 'can switch channels with --force', + () async { + fakeProcessManager.addCommands(const [ + FakeCommand(command: ['git', 'fetch']), + FakeCommand( + command: ['git', 'show-ref', '--verify', '--quiet', 'refs/heads/beta'], + ), + FakeCommand(command: ['git', 'checkout', '-f', 'beta', '--']), + FakeCommand( + command: ['bin/flutter', '--no-color', '--no-version-check', 'precache'], + ), + ]); + + final command = ChannelCommand(); + final CommandRunner runner = createTestCommandRunner(command); + await runner.run(['channel', '--force', 'beta']); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect( + testLogger.statusText, + containsIgnoringWhitespace("Switching to flutter channel 'beta'..."), + ); + expect(testLogger.errorText, hasLength(0)); + }, + overrides: { + FileSystem: () => MemoryFileSystem.test(), + ProcessManager: () => fakeProcessManager, + }, + ); + + testUsingContext( + 'can switch channels with -f when branch does not exist locally', + () async { + fakeProcessManager.addCommands(const [ + FakeCommand(command: ['git', 'fetch']), + FakeCommand( + command: ['git', 'show-ref', '--verify', '--quiet', 'refs/heads/beta'], + exitCode: 1, + ), + FakeCommand( + command: ['git', 'checkout', '-f', '--track', '-b', 'beta', 'origin/beta'], + ), + FakeCommand( + command: ['bin/flutter', '--no-color', '--no-version-check', 'precache'], + ), + ]); + + final command = ChannelCommand(); + final CommandRunner runner = createTestCommandRunner(command); + await runner.run(['channel', '-f', 'beta']); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect( + testLogger.statusText, + containsIgnoringWhitespace("Switching to flutter channel 'beta'..."), + ); + expect(testLogger.errorText, hasLength(0)); + }, + overrides: { + FileSystem: () => MemoryFileSystem.test(), + ProcessManager: () => fakeProcessManager, + }, + ); + testUsingContext( 'switching channels prompts to run flutter upgrade', () async { From bb5b1dc85f5b8c15888996eb795775ffd9d585d8 Mon Sep 17 00:00:00 2001 From: Daco Harkes Date: Mon, 24 Aug 2026 17:22:07 +0000 Subject: [PATCH 40/46] [native_assets] Roll native packages (#191253) Rolls Dart native asset packages (`code_assets`, `hooks`, `hooks_runner`, `native_toolchain_c`, `record_use`) to their latest published releases. ### Package Rolls | Package | Old Version | New Version | | :--- | :--- | :--- | | `package:code_assets` | `1.2.1` | `2.0.0` | | `package:hooks` | `2.1.0` | `2.2.0` | | `package:hooks_runner` | `1.6.1` | `1.6.2` | | `package:native_toolchain_c` | `0.19.3` | `0.19.4` | | `package:record_use` | `1.1.0` | `1.1.1` | | `package:objective_c` (transitively) | `9.4.1` | `9.6.0` | Migration changes: * **`package:code_assets` v2.0.0**: Overrides `==` and `hashCode` in `OS`, `Architecture`, and `Sanitizer` to support custom targets. Because these objects no longer have primitive equality, they cannot be used as keys or elements in `const` collections. Migrated `const _osTargets` in [`native_assets.dart`](file:///Users/dacoharkes/src/flutter/flutter/packages/flutter_tools/lib/src/isolated/native_assets/native_assets.dart#L1009) to a `final Map` with runtime set literals. * **`package:hooks` v2.2.0 (`HookInput` eager directory creation & test context updates)**: In `package:hooks` v2.2.0, reading `HookInput.outputDirectory` eagerly calls `.createSync(recursive: true)` on the target directory. When `FakeFlutterNativeAssetsBuildRunner` instantiates `BuildInput` in isolated unit tests, this now touches disk during hook checks: * **Filesystem Safety Guard Symlink Resolution (`fs_safety.dart`)**: On macOS, creating subdirectories under `/var/folders/...` triggered false-positive filesystem guard violations (`FileSystemException`). Flutter's `fs_safety.dart` failed to recognize allowed temp paths because calling `.resolveSymbolicLinksSync()` on paths whose leaf directories did not exist yet threw `PathNotFoundException`, leaving `/var` unresolved to `/private/var`. Added `_canonicalizeWithSymlinks` in [`fs_safety.dart`](file:///Users/dacoharkes/src/flutter/flutter/packages/flutter_tools/test/src/fs_safety.dart#L40-L55) to walk up parent directories and resolve symlinks for non-existent target paths. * **Test Context Overrides**: Added missing `FeatureFlags: () => TestFeatureFlags(...)` context overrides and `fakes.dart` imports to isolated native asset unit tests (`android`, `ios`, `macos`, `windows`). Without in-memory feature flag mocks, checking `featureFlags` in `_hookRunRequired` initializes `globals.config`, which attempts to create `.flutter_settings` folders on disk and collides with `testUsingContext`'s filesystem guard. --- dev/devicelab/lib/framework/fs_safety.dart | 34 +++++++++++-- .../data_asset_app/pubspec.yaml | 4 +- .../data_asset_package/pubspec.yaml | 4 +- .../hook_user_defines/pubspec.yaml | 6 +-- .../record_use_test_package/pubspec.yaml | 6 +-- .../isolated/native_assets/native_assets.dart | 6 ++- packages/flutter_tools/pubspec.yaml | 10 ++-- .../templates/package_ffi/pubspec.yaml.tmpl | 6 +-- .../isolated/android/native_assets_test.dart | 6 ++- .../isolated/ios/native_assets_test.dart | 3 ++ .../isolated/macos/native_assets_test.dart | 4 ++ .../isolated/windows/native_assets_test.dart | 7 ++- .../flutter_tools/test/src/fs_safety.dart | 34 +++++++++++-- pubspec.lock | 48 +++++++++++-------- pubspec.yaml | 10 ++-- 15 files changed, 133 insertions(+), 55 deletions(-) diff --git a/dev/devicelab/lib/framework/fs_safety.dart b/dev/devicelab/lib/framework/fs_safety.dart index 1adbffb604c7f..baa74eb231b0f 100644 --- a/dev/devicelab/lib/framework/fs_safety.dart +++ b/dev/devicelab/lib/framework/fs_safety.dart @@ -37,8 +37,34 @@ bool _isDangerousDirectory(String dirPath) { return false; } +/// Canonicalizes [p] and normalizes standard root symlink aliases on macOS. +/// +/// On macOS (Darwin), system root directories `/var`, `/tmp`, and `/etc` are +/// symlinks to `/private/var`, `/private/tmp`, and `/private/etc` respectively +/// (with Darwin per-user `$TMPDIR` located under `/var/folders/...`). +/// +/// Normalizing these prefixes at the string level ensures consistent prefix +/// matching against the resolved temporary directory without performing costly +/// filesystem syscalls (`resolveSymbolicLinksSync`) or failing when validating +/// paths to files/directories that do not exist yet. +String _canonicalize(String p) { + final String canonical = path.canonicalize(p); + if (io.Platform.isMacOS) { + if (canonical.startsWith('/var/') || canonical == '/var') { + return '/private$canonical'; + } + if (canonical.startsWith('/tmp/') || canonical == '/tmp') { + return '/private$canonical'; + } + if (canonical.startsWith('/etc/') || canonical == '/etc') { + return '/private$canonical'; + } + } + return canonical; +} + bool _isAllowedPath(String entityPath) { - final String canonicalEntity = path.canonicalize(entityPath); + final String canonicalEntity = _canonicalize(entityPath); // Allow system temp String canonicalTemp; @@ -46,7 +72,7 @@ bool _isAllowedPath(String entityPath) { if (currentOverrides is FSGuardIOOverrides) { canonicalTemp = currentOverrides._canonicalSystemTemp; } else { - canonicalTemp = path.canonicalize(io.Directory.systemTemp.path); + canonicalTemp = _canonicalize(io.Directory.systemTemp.path); } if (path.isWithin(canonicalTemp, canonicalEntity) || canonicalEntity == canonicalTemp) { @@ -578,9 +604,9 @@ final class FSGuardIOOverrides extends io.IOOverrides { ? _parent.getSystemTempDirectory() : super.getSystemTempDirectory(); try { - return path.canonicalize(rawTemp.resolveSymbolicLinksSync()); + return _canonicalize(rawTemp.resolveSymbolicLinksSync()); } on Object catch (_) { - return path.canonicalize(rawTemp.path); + return _canonicalize(rawTemp.path); } }(); diff --git a/dev/integration_tests/data_asset_app/pubspec.yaml b/dev/integration_tests/data_asset_app/pubspec.yaml index aa23692a78e9f..ae5e372b7aaad 100644 --- a/dev/integration_tests/data_asset_app/pubspec.yaml +++ b/dev/integration_tests/data_asset_app/pubspec.yaml @@ -12,7 +12,7 @@ resolution: workspace dependencies: flutter: sdk: flutter - hooks: ^2.1.0 + hooks: ^2.2.0 data_assets: ^0.20.0 data_asset_package: path: ../data_asset_package @@ -24,4 +24,4 @@ dev_dependencies: flutter: uses-material-design: true -# PUBSPEC CHECKSUM: qa7aie +# PUBSPEC CHECKSUM: g7de17 diff --git a/dev/integration_tests/data_asset_package/pubspec.yaml b/dev/integration_tests/data_asset_package/pubspec.yaml index 743de799a9209..a1ba019283445 100644 --- a/dev/integration_tests/data_asset_package/pubspec.yaml +++ b/dev/integration_tests/data_asset_package/pubspec.yaml @@ -12,7 +12,7 @@ resolution: workspace dependencies: flutter: sdk: flutter - hooks: ^2.1.0 + hooks: ^2.2.0 data_assets: ^0.20.0 dev_dependencies: @@ -22,4 +22,4 @@ dev_dependencies: flutter: uses-material-design: true -# PUBSPEC CHECKSUM: fqqee1 +# PUBSPEC CHECKSUM: qo17ak diff --git a/dev/integration_tests/hook_user_defines/pubspec.yaml b/dev/integration_tests/hook_user_defines/pubspec.yaml index e9b753e443462..23a4e6374e720 100644 --- a/dev/integration_tests/hook_user_defines/pubspec.yaml +++ b/dev/integration_tests/hook_user_defines/pubspec.yaml @@ -15,11 +15,11 @@ hooks: magic_value: 1000 dependencies: - hooks: 2.1.0 + hooks: 2.2.0 logging: 1.3.0 - native_toolchain_c: 0.19.3 + native_toolchain_c: 0.19.4 dev_dependencies: test: 1.29.0 -# PUBSPEC CHECKSUM: b3eo7f +# PUBSPEC CHECKSUM: hsfmta diff --git a/dev/integration_tests/record_use_test_package/pubspec.yaml b/dev/integration_tests/record_use_test_package/pubspec.yaml index fd92cd04bafc4..fd4970f2a4e14 100644 --- a/dev/integration_tests/record_use_test_package/pubspec.yaml +++ b/dev/integration_tests/record_use_test_package/pubspec.yaml @@ -11,9 +11,9 @@ environment: dependencies: flutter: sdk: flutter - hooks: ^2.1.0 + hooks: ^2.2.0 data_assets: ^0.20.0 - record_use: ^1.0.0 + record_use: ^1.1.1 meta: any dev_dependencies: @@ -23,4 +23,4 @@ dev_dependencies: flutter: uses-material-design: true -# PUBSPEC CHECKSUM: 2pvsru +# PUBSPEC CHECKSUM: 6itk7q diff --git a/packages/flutter_tools/lib/src/isolated/native_assets/native_assets.dart b/packages/flutter_tools/lib/src/isolated/native_assets/native_assets.dart index bff4e91d19723..1edf6a08ca000 100644 --- a/packages/flutter_tools/lib/src/isolated/native_assets/native_assets.dart +++ b/packages/flutter_tools/lib/src/isolated/native_assets/native_assets.dart @@ -4,6 +4,8 @@ // Logic for native assets shared between all host OSes. +import 'dart:collection'; + import 'package:code_assets/code_assets.dart'; import 'package:data_assets/data_assets.dart'; import 'package:hooks/hooks.dart'; @@ -1003,10 +1005,10 @@ OS getNativeOSFromTargetPlatform(TargetPlatform platform) { } extension OSArchitectures on OS { - Set get architectures => _osTargets[this]!; + Set get architectures => UnmodifiableSetView(_osTargets[this]!); } -const _osTargets = >{ +final _osTargets = >{ OS.android: { Architecture.arm, Architecture.arm64, diff --git a/packages/flutter_tools/pubspec.yaml b/packages/flutter_tools/pubspec.yaml index af97b3e3eb3f4..fe26ed1c25ea3 100644 --- a/packages/flutter_tools/pubspec.yaml +++ b/packages/flutter_tools/pubspec.yaml @@ -69,11 +69,11 @@ dependencies: pubspec_parse: 1.5.0 graphs: 2.3.2 - hooks_runner: 1.6.1 - hooks: 2.1.0 - code_assets: 1.2.1 + hooks_runner: 1.6.2 + hooks: 2.2.0 + code_assets: 2.0.0 data_assets: 0.20.0 - record_use: 1.1.0 + record_use: 1.1.1 # We depend on very specific internal implementation details of the # 'test' package, which change between versions, so when upgrading @@ -140,4 +140,4 @@ dartdoc: # Exclude this package from the hosted API docs. nodoc: true -# PUBSPEC CHECKSUM: 1leejj +# PUBSPEC CHECKSUM: 55lps5 diff --git a/packages/flutter_tools/templates/package_ffi/pubspec.yaml.tmpl b/packages/flutter_tools/templates/package_ffi/pubspec.yaml.tmpl index 2f7104f584fd6..166d484da2695 100644 --- a/packages/flutter_tools/templates/package_ffi/pubspec.yaml.tmpl +++ b/packages/flutter_tools/templates/package_ffi/pubspec.yaml.tmpl @@ -6,10 +6,10 @@ environment: sdk: {{dartSdkVersionBounds}} dependencies: - code_assets: ^1.2.1 - hooks: ^2.1.0 + code_assets: ^2.0.0 + hooks: ^2.2.0 logging: ^1.3.0 - native_toolchain_c: ^0.19.3 + native_toolchain_c: ^0.19.4 dev_dependencies: ffi: ^2.1.4 diff --git a/packages/flutter_tools/test/general.shard/isolated/android/native_assets_test.dart b/packages/flutter_tools/test/general.shard/isolated/android/native_assets_test.dart index 879276cddb958..6bfc166494587 100644 --- a/packages/flutter_tools/test/general.shard/isolated/android/native_assets_test.dart +++ b/packages/flutter_tools/test/general.shard/isolated/android/native_assets_test.dart @@ -54,7 +54,11 @@ void main() { 'build with assets $buildMode', // [intended] Backslashes in commands, but we will never run these commands on Windows. skip: const LocalPlatform().isWindows, - overrides: {ProcessManager: () => FakeProcessManager.empty()}, + overrides: { + ProcessManager: () => FakeProcessManager.empty(), + FeatureFlags: () => + TestFeatureFlags(isNativeAssetsEnabled: true, isDartDataAssetsEnabled: true), + }, () async { final File packageConfig = environment.projectDir.childFile( '.dart_tool/package_config.json', diff --git a/packages/flutter_tools/test/general.shard/isolated/ios/native_assets_test.dart b/packages/flutter_tools/test/general.shard/isolated/ios/native_assets_test.dart index 4152781956729..f57d9f6a03f66 100644 --- a/packages/flutter_tools/test/general.shard/isolated/ios/native_assets_test.dart +++ b/packages/flutter_tools/test/general.shard/isolated/ios/native_assets_test.dart @@ -14,6 +14,7 @@ import 'package:flutter_tools/src/base/platform.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/build_system/build_system.dart' hide Target; import 'package:flutter_tools/src/build_system/targets/native_assets.dart'; +import 'package:flutter_tools/src/features.dart'; import 'package:flutter_tools/src/globals.dart' as globals; import 'package:flutter_tools/src/isolated/native_assets/dart_hook_result.dart'; import 'package:flutter_tools/src/isolated/native_assets/ios/native_assets.dart'; @@ -55,6 +56,8 @@ void main() { testUsingContext( 'build with assets $buildMode', overrides: { + FeatureFlags: () => + TestFeatureFlags(isNativeAssetsEnabled: true, isDartDataAssetsEnabled: true), ProcessManager: () => FakeProcessManager.list([ const FakeCommand( command: [ diff --git a/packages/flutter_tools/test/general.shard/isolated/macos/native_assets_test.dart b/packages/flutter_tools/test/general.shard/isolated/macos/native_assets_test.dart index ba0fdc9124361..2c1b7077f3935 100644 --- a/packages/flutter_tools/test/general.shard/isolated/macos/native_assets_test.dart +++ b/packages/flutter_tools/test/general.shard/isolated/macos/native_assets_test.dart @@ -13,6 +13,7 @@ import 'package:flutter_tools/src/base/platform.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/build_system/build_system.dart'; import 'package:flutter_tools/src/build_system/targets/native_assets.dart'; +import 'package:flutter_tools/src/features.dart'; import 'package:flutter_tools/src/globals.dart' as globals; import 'package:flutter_tools/src/isolated/native_assets/dart_hook_result.dart'; import 'package:flutter_tools/src/isolated/native_assets/macos/native_assets_host.dart' @@ -22,6 +23,7 @@ import 'package:hooks/hooks.dart'; import '../../../src/common.dart'; import '../../../src/context.dart'; +import '../../../src/fakes.dart'; import '../fake_native_assets_build_runner.dart'; void main() { @@ -78,6 +80,8 @@ void main() { testUsingContext( 'build with assets $buildMode$testName', overrides: { + FeatureFlags: () => + TestFeatureFlags(isNativeAssetsEnabled: true, isDartDataAssetsEnabled: true), ProcessManager: () => FakeProcessManager.list([ if (flutterTester) ...[ FakeCommand( diff --git a/packages/flutter_tools/test/general.shard/isolated/windows/native_assets_test.dart b/packages/flutter_tools/test/general.shard/isolated/windows/native_assets_test.dart index 0ee3f47fa669a..914c867553c28 100644 --- a/packages/flutter_tools/test/general.shard/isolated/windows/native_assets_test.dart +++ b/packages/flutter_tools/test/general.shard/isolated/windows/native_assets_test.dart @@ -15,6 +15,7 @@ import 'package:flutter_tools/src/base/platform.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/build_system/build_system.dart'; import 'package:flutter_tools/src/build_system/targets/native_assets.dart'; +import 'package:flutter_tools/src/features.dart'; import 'package:flutter_tools/src/globals.dart' as globals; import 'package:flutter_tools/src/isolated/native_assets/dart_hook_result.dart'; import 'package:flutter_tools/src/isolated/native_assets/native_assets.dart'; @@ -69,7 +70,11 @@ void main() { testUsingContext( 'build with assets $buildMode$testName', - overrides: {ProcessManager: () => FakeProcessManager.empty()}, + overrides: { + ProcessManager: () => FakeProcessManager.empty(), + FeatureFlags: () => + TestFeatureFlags(isNativeAssetsEnabled: true, isDartDataAssetsEnabled: true), + }, () async { writePackageConfigFiles(directory: environment.projectDir, mainLibName: 'my_app'); final Uri nonFlutterTesterAssetUri = environment.buildDir diff --git a/packages/flutter_tools/test/src/fs_safety.dart b/packages/flutter_tools/test/src/fs_safety.dart index 1adbffb604c7f..baa74eb231b0f 100644 --- a/packages/flutter_tools/test/src/fs_safety.dart +++ b/packages/flutter_tools/test/src/fs_safety.dart @@ -37,8 +37,34 @@ bool _isDangerousDirectory(String dirPath) { return false; } +/// Canonicalizes [p] and normalizes standard root symlink aliases on macOS. +/// +/// On macOS (Darwin), system root directories `/var`, `/tmp`, and `/etc` are +/// symlinks to `/private/var`, `/private/tmp`, and `/private/etc` respectively +/// (with Darwin per-user `$TMPDIR` located under `/var/folders/...`). +/// +/// Normalizing these prefixes at the string level ensures consistent prefix +/// matching against the resolved temporary directory without performing costly +/// filesystem syscalls (`resolveSymbolicLinksSync`) or failing when validating +/// paths to files/directories that do not exist yet. +String _canonicalize(String p) { + final String canonical = path.canonicalize(p); + if (io.Platform.isMacOS) { + if (canonical.startsWith('/var/') || canonical == '/var') { + return '/private$canonical'; + } + if (canonical.startsWith('/tmp/') || canonical == '/tmp') { + return '/private$canonical'; + } + if (canonical.startsWith('/etc/') || canonical == '/etc') { + return '/private$canonical'; + } + } + return canonical; +} + bool _isAllowedPath(String entityPath) { - final String canonicalEntity = path.canonicalize(entityPath); + final String canonicalEntity = _canonicalize(entityPath); // Allow system temp String canonicalTemp; @@ -46,7 +72,7 @@ bool _isAllowedPath(String entityPath) { if (currentOverrides is FSGuardIOOverrides) { canonicalTemp = currentOverrides._canonicalSystemTemp; } else { - canonicalTemp = path.canonicalize(io.Directory.systemTemp.path); + canonicalTemp = _canonicalize(io.Directory.systemTemp.path); } if (path.isWithin(canonicalTemp, canonicalEntity) || canonicalEntity == canonicalTemp) { @@ -578,9 +604,9 @@ final class FSGuardIOOverrides extends io.IOOverrides { ? _parent.getSystemTempDirectory() : super.getSystemTempDirectory(); try { - return path.canonicalize(rawTemp.resolveSymbolicLinksSync()); + return _canonicalize(rawTemp.resolveSymbolicLinksSync()); } on Object catch (_) { - return path.canonicalize(rawTemp.path); + return _canonicalize(rawTemp.path); } }(); diff --git a/pubspec.lock b/pubspec.lock index 216238260a0dd..16ef79c70b487 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -174,10 +174,10 @@ packages: dependency: "direct main" description: name: code_assets - sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + sha256: cfd4f5f575a49c5f10ca856e9846073f1e6c3ee94912377eea5f6cefc5272941 url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "2.0.0" code_builder: dependency: transitive description: @@ -246,10 +246,10 @@ packages: dependency: transitive description: name: cupertino_ui - sha256: "427360789a03b76eaf659c37131570d2fc98e381f80d55f252bd9e30f37e7126" + sha256: "58191e8c198d274d299b4a92e5fa9bf1abf1d870067cf064b67f2217957aefe9" url: "https://pub.dev" source: hosted - version: "0.0.2" + version: "0.0.3+1" dap: dependency: transitive description: @@ -547,18 +547,18 @@ packages: dependency: "direct main" description: name: hooks - sha256: "03a564c3704524ee0f7fc56fc621e8796cc2eb8c24d1f1a33b34979815b285c4" + sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.2.0" hooks_runner: dependency: transitive description: name: hooks_runner - sha256: e5af5628be2fbe3bbb195ded03e6c6f739b0c92db43d1e2a95620678fd844458 + sha256: "35482fc4523200bad009902a0c87355b1191496ef7b0d8bfbbcb38111506787b" url: "https://pub.dev" source: hosted - version: "1.6.1" + version: "1.6.2" html: dependency: "direct main" description: @@ -627,18 +627,26 @@ packages: dependency: transitive description: name: jni - sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.3" jni_flutter: dependency: transitive description: name: jni_flutter - sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" js: dependency: "direct main" description: @@ -723,10 +731,10 @@ packages: dependency: transitive description: name: material_ui - sha256: e1fa67393d807bd1841e5ece1ec260ed84f440ae7351e40b6406f733e4fe31cf + sha256: ba53669b1fac77c3481ffba90833e2d847f2510faba8781a1bbe11e8f2c71a94 url: "https://pub.dev" source: hosted - version: "0.0.2" + version: "0.0.3+1" meta: dependency: "direct main" description: @@ -779,10 +787,10 @@ packages: dependency: "direct main" description: name: native_toolchain_c - sha256: a1c26117c48cebe5677b0cf0e33a980a79a7c5577effc86f52e5a0d309cdcb60 + sha256: "9d233b6f2d9c52e1a2b5fbe70451d2c10ac674d3bb419d0ec8de14989d437c26" url: "https://pub.dev" source: hosted - version: "0.19.3" + version: "0.19.4" nested: dependency: "direct main" description: @@ -803,10 +811,10 @@ packages: dependency: transitive description: name: objective_c - sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + sha256: ad56fd53a78ff6b1472fa59ff2a4e8b8ccabafc586fc263a1dfad0b99b5553e3 url: "https://pub.dev" source: hosted - version: "9.4.1" + version: "9.6.0" package_config: dependency: "direct main" description: @@ -963,10 +971,10 @@ packages: dependency: "direct main" description: name: record_use - sha256: "3b4ab682aff40175afca6ff090cc5aa7ce9dafe8dfbae1537a6c678e6678baee" + sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" retry: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index b2863416faf5b..5832c5b5eff69 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -100,7 +100,7 @@ dependencies: checked_yaml: 2.0.4 cli_config: 0.2.0 clock: ^1.1.2 - code_assets: 1.2.1 + code_assets: 2.0.0 collection: ^1.19.1 convert: 3.1.2 coverage: 1.15.1 @@ -122,9 +122,9 @@ dependencies: google_mobile_ads: 8.0.0 googleapis: 14.0.0 googleapis_auth: 2.3.3 - hooks: 2.1.0 + hooks: 2.2.0 data_assets: 0.20.0 - record_use: 1.1.0 + record_use: 1.1.1 html: 0.15.6 http: 1.6.0 http_multi_server: 3.2.2 @@ -143,7 +143,7 @@ dependencies: meta: ^1.19.0 metrics_center: 1.0.15 mime: 2.0.0 - native_toolchain_c: 0.19.3 + native_toolchain_c: 0.19.4 nested: 1.0.0 node_preamble: 2.0.2 package_config: 2.2.0 @@ -224,4 +224,4 @@ dependencies: dev_dependencies: ffigen: 20.1.1 -# PUBSPEC CHECKSUM: eqa987 +# PUBSPEC CHECKSUM: al1sha From 53643c25b21132a6a8eaf3c9fd2dd402b9b65ce1 Mon Sep 17 00:00:00 2001 From: Ben Konyi Date: Mon, 24 Aug 2026 17:24:15 +0000 Subject: [PATCH 41/46] [flutter_tools] Resolve workspace root when running widget preview from member package (#190952) When `flutter widget-preview start` is run from a member package in a pub workspace, redirect `getRootProject()` to the workspace root `FlutterProject`. This ensures that the widget preview scaffold is generated at the workspace root and has dependency overrides for all member packages in the workspace. Fixes https://github.com/flutter/flutter/issues/190692 --- .../lib/src/commands/widget_preview.dart | 8 +- packages/flutter_tools/lib/src/project.dart | 66 ++++++++++++- .../widget_preview/widget_preview_test.dart | 64 ++++++++++-- .../test/general.shard/project_test.dart | 98 +++++++++++++++++++ 4 files changed, 226 insertions(+), 10 deletions(-) diff --git a/packages/flutter_tools/lib/src/commands/widget_preview.dart b/packages/flutter_tools/lib/src/commands/widget_preview.dart index 28021da27366a..f763cc95275a3 100644 --- a/packages/flutter_tools/lib/src/commands/widget_preview.dart +++ b/packages/flutter_tools/lib/src/commands/widget_preview.dart @@ -114,7 +114,13 @@ abstract base class WidgetPreviewSubCommandBase extends FlutterCommand { } else { projectDir = fs.currentDirectory; } - return validateFlutterProjectForPreview(projectDir); + final FlutterProject project = validateFlutterProjectForPreview(projectDir); + final FlutterProject? workspaceRoot = project.workspaceRoot; + if (workspaceRoot != null) { + logger.printTrace('Found workspace root at ${workspaceRoot.directory.path}'); + return workspaceRoot; + } + return project; } FlutterProject validateFlutterProjectForPreview(Directory directory) { diff --git a/packages/flutter_tools/lib/src/project.dart b/packages/flutter_tools/lib/src/project.dart index 4879a81e82de9..1e9863d6cb6a4 100644 --- a/packages/flutter_tools/lib/src/project.dart +++ b/packages/flutter_tools/lib/src/project.dart @@ -95,6 +95,62 @@ class FlutterProject { _setManifest(manifest); } + FlutterProject? _workspaceRoot; + bool _searchedForWorkspaceRoot = false; + + /// Returns the workspace root project if this project is a member of a workspace. + /// + /// Returns null if this project is not part of a workspace or is itself the workspace root. + FlutterProject? get workspaceRoot { + if (_searchedForWorkspaceRoot) { + return _workspaceRoot; + } + _searchedForWorkspaceRoot = true; + _workspaceRoot = _findWorkspaceRoot(); + return _workspaceRoot; + } + + FlutterProject? _findWorkspaceRoot() { + final FileSystem fileSystem = directory.fileSystem; + final String normalizedPath = fileSystem.path.normalize(directory.absolute.path); + Directory candidate = fileSystem.directory(normalizedPath); + + while (true) { + final Directory parent = candidate.parent; + if (fileSystem.path.equals(parent.path, candidate.path)) { + break; + } + candidate = parent; + final File pubspec = candidate.childFile('pubspec.yaml'); + if (!pubspec.existsSync()) { + continue; + } + try { + final FlutterManifest manifest = FlutterProject._readManifest( + pubspec.path, + logger: globals.logger, + fileSystem: fileSystem, + ); + if (manifest.workspace.isNotEmpty) { + final String relativePath = fileSystem.path.relative( + normalizedPath, + from: candidate.path, + ); + final bool isMember = manifest.workspace.any((String entry) { + final glob = Glob(entry, context: fileSystem.path); + return glob.matches(relativePath); + }); + if (isMember) { + return FlutterProject.fromDirectory(candidate); + } + } + } on Exception catch (_) { + // Ignore manifest reading errors. + } + } + return null; + } + /// Returns a [FlutterProject] view of the given directory or a ToolExit error, /// if `pubspec.yaml` or `example/pubspec.yaml` is invalid. static FlutterProject fromDirectory(Directory directory) => @@ -142,17 +198,23 @@ class FlutterProject { void _setManifest(FlutterManifest manifest) { _manifest = manifest; + _searchedForWorkspaceRoot = false; + _workspaceRoot = null; // Update the workspace projects based on the new manifest. _workspaceProjects = []; for (final String entry in manifest.workspace) { - final glob = Glob(entry); + final glob = Glob(entry, context: directory.fileSystem.path); for (final Directory globResult in glob .listFileSystemSync(directory.fileSystem, root: directory.path) .whereType()) { if (globResult.childFile('pubspec.yaml').existsSync()) { - _workspaceProjects.add(FlutterProject.fromDirectory(globResult)); + try { + _workspaceProjects.add(FlutterProject.fromDirectory(globResult)); + } on Exception catch (_) { + // Ignore child projects with invalid manifests. + } } } } diff --git a/packages/flutter_tools/test/commands.shard/permeable/widget_preview/widget_preview_test.dart b/packages/flutter_tools/test/commands.shard/permeable/widget_preview/widget_preview_test.dart index 90b23c385699e..15b405cd69891 100644 --- a/packages/flutter_tools/test/commands.shard/permeable/widget_preview/widget_preview_test.dart +++ b/packages/flutter_tools/test/commands.shard/permeable/widget_preview/widget_preview_test.dart @@ -284,13 +284,7 @@ void main() { Future cleanWidgetPreview({required Directory rootProject}) async { await runWidgetPreviewCommand(['clean', rootProject.path]); - expect( - fs - .directory(rootProject) - .childDirectory('.dart_tool') - .childDirectory('widget_preview_scaffold'), - isNot(exists), - ); + expect(fs.directory(rootProject).childDirectory('.widget_preview'), isNot(exists)); } group('flutter widget-preview', () { @@ -389,6 +383,62 @@ void main() { ); }); + group('workspaces', () { + testUsingContext( + 'starts from workspace root when run from member package', + () async { + final File workspacePubspec = tempDir.childFile('pubspec.yaml'); + workspacePubspec.writeAsStringSync(''' +name: my_workspace +environment: + sdk: '>=3.0.0 <4.0.0' +workspace: + - my_app +'''); + + final String memberProjectPath = await createProject( + tempDir, + name: 'my_app', + arguments: ['--pub'], + ); + final Directory memberProjectDir = fs.directory(memberProjectPath); + + final File memberPubspec = memberProjectDir.childFile('pubspec.yaml'); + final String memberPubspecContent = memberPubspec.readAsStringSync(); + memberPubspec.writeAsStringSync(''' +$memberPubspecContent +resolution: workspace +'''); + + fs.currentDirectory = memberProjectDir; + + await startWidgetPreview(rootProject: null); + final Directory workspaceScaffoldDir = tempDir.childDirectory('.widget_preview'); + final Directory memberScaffoldDir = memberProjectDir.childDirectory('.widget_preview'); + + expect(workspaceScaffoldDir, exists); + expect(memberScaffoldDir, isNot(exists)); + + await cleanWidgetPreview(rootProject: tempDir); + }, + overrides: { + Analytics: () => fakeAnalytics, + DeviceManager: () => fakeDeviceManager, + FileSystem: () => fs, + ProcessManager: () => loggingProcessManager, + FeatureFlags: () => TestFeatureFlags(isWebEnabled: true), + Pub: () => Pub.test( + fileSystem: fs, + logger: logger, + processManager: loggingProcessManager, + botDetector: botDetector, + platform: platform, + stdio: mockStdio, + ), + }, + ); + }); + testUsingContext( 'start succeeds when no .dart_tool/ directory exists', () async { diff --git a/packages/flutter_tools/test/general.shard/project_test.dart b/packages/flutter_tools/test/general.shard/project_test.dart index f60fc8859aede..c847f893f35fa 100644 --- a/packages/flutter_tools/test/general.shard/project_test.dart +++ b/packages/flutter_tools/test/general.shard/project_test.dart @@ -2046,6 +2046,104 @@ resolution: workspace ['child1', 'child2'], ); }); + + _testInMemory('workspaceRoot returns root project for member projects', () async { + final Directory directory = globals.fs.directory('myproject'); + directory.childFile('pubspec.yaml') + ..createSync(recursive: true) + ..writeAsStringSync(''' +name: parent +flutter: +workspace: +- pkgs/* +'''); + final Directory child1Dir = directory.childDirectory('pkgs').childDirectory('child1'); + child1Dir.childFile('pubspec.yaml') + ..createSync(recursive: true) + ..writeAsStringSync(''' +name: child1 +flutter: +resolution: workspace +'''); + + final FlutterProject parentProject = FlutterProject.fromDirectory(directory); + final FlutterProject childProject = FlutterProject.fromDirectory(child1Dir); + + expect( + globals.fs.path.canonicalize(childProject.workspaceRoot!.directory.path), + globals.fs.path.canonicalize(parentProject.directory.path), + ); + expect(parentProject.workspaceRoot, null); + }); + + _testInMemory('workspaceRoot works with relative paths', () async { + final Directory directory = globals.fs.directory('myproject')..createSync(recursive: true); + directory.childFile('pubspec.yaml').writeAsStringSync(''' +name: parent +flutter: +workspace: +- child1 +'''); + final Directory child1Dir = directory.childDirectory('child1')..createSync(recursive: true); + child1Dir.childFile('pubspec.yaml').writeAsStringSync(''' +name: child1 +flutter: +resolution: workspace +'''); + + final Directory originalCwd = globals.fs.currentDirectory; + try { + globals.fs.currentDirectory = directory; + final FlutterProject childProject = FlutterProject.fromDirectory( + globals.fs.directory('child1'), + ); + expect( + globals.fs.path.canonicalize(childProject.workspaceRoot!.directory.path), + globals.fs.path.canonicalize(globals.fs.currentDirectory.path), + ); + } finally { + globals.fs.currentDirectory = originalCwd; + } + }); + + _testInMemory( + 'workspaceRoot is resilient to malformed sibling packages in workspace', + () async { + final Directory directory = globals.fs.directory('myproject'); + directory.childFile('pubspec.yaml') + ..createSync(recursive: true) + ..writeAsStringSync(''' +name: parent +flutter: +workspace: +- pkgs/* +'''); + final Directory validChildDir = directory + .childDirectory('pkgs') + .childDirectory('valid_child'); + validChildDir.childFile('pubspec.yaml') + ..createSync(recursive: true) + ..writeAsStringSync(''' +name: valid_child +flutter: +resolution: workspace +'''); + final Directory brokenChildDir = directory + .childDirectory('pkgs') + .childDirectory('broken_child'); + brokenChildDir.childFile('pubspec.yaml') + ..createSync(recursive: true) + ..writeAsStringSync('invalid: yaml: [broken'); + + final FlutterProject parentProject = FlutterProject.fromDirectory(directory); + final FlutterProject childProject = FlutterProject.fromDirectory(validChildDir); + + expect( + globals.fs.path.canonicalize(childProject.workspaceRoot!.directory.path), + globals.fs.path.canonicalize(parentProject.directory.path), + ); + }, + ); }); }); From 2483da25236e9a53550025b2b918a57d3ac5070d Mon Sep 17 00:00:00 2001 From: Ben Konyi Date: Mon, 24 Aug 2026 17:31:10 +0000 Subject: [PATCH 42/46] [flutter_tools] Do not treat 'daemon' argument as daemon command in logger initialization (#191442) ## Description `executable.dart` checked `args.contains('daemon')` and `args.contains(WidgetPreviewCommand.kWidgetPreview)` to configure the logger. When running commands where 'daemon' was supplied as an argument (such as `flutter create daemon`), this caused `NotifyingLogger` to be initialized instead of standard loggers, silencing command output. Use `commandName` from `findCommandName(args)` to accurately detect if the command is `daemon` or `widget-preview`. ## Related Issues Fixes https://github.com/flutter/flutter/issues/75876 ## Tests - Added unit test to `packages/flutter_tools/test/general.shard/executable_test.dart` - Added integration test to `packages/flutter_tools/test/integration.shard/command_output_test.dart` --- packages/flutter_tools/lib/executable.dart | 4 ++-- .../test/general.shard/executable_test.dart | 2 ++ .../integration.shard/command_output_test.dart | 18 ++++++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/flutter_tools/lib/executable.dart b/packages/flutter_tools/lib/executable.dart index 203b5df3308eb..5f9571e4dcc21 100644 --- a/packages/flutter_tools/lib/executable.dart +++ b/packages/flutter_tools/lib/executable.dart @@ -94,8 +94,8 @@ Future main(List args) async { (args.length == 1 && verbose); final bool muteCommandLogging = (help || doctor) && !veryVerbose; final bool verboseHelp = help && verbose; - final bool daemon = args.contains('daemon'); - final bool widgetPreviews = args.contains(WidgetPreviewCommand.kWidgetPreview); + final daemon = commandName == 'daemon'; + final widgetPreviews = commandName == WidgetPreviewCommand.kWidgetPreview; final bool runMachine = args.contains('--machine'); // Cache.flutterRoot must be set early because other features use it (e.g. diff --git a/packages/flutter_tools/test/general.shard/executable_test.dart b/packages/flutter_tools/test/general.shard/executable_test.dart index 8d278cc9a3c15..e9a32d3fd19b7 100644 --- a/packages/flutter_tools/test/general.shard/executable_test.dart +++ b/packages/flutter_tools/test/general.shard/executable_test.dart @@ -38,6 +38,8 @@ void main() { test('does not mistake an argument of the command for the command', () { expect(findCommandName(['create', 'doctor']), 'create'); + expect(findCommandName(['create', 'daemon']), 'create'); + expect(findCommandName(['create', 'widget-preview']), 'create'); expect(findCommandName(['help', 'doctor']), 'help'); }); diff --git a/packages/flutter_tools/test/integration.shard/command_output_test.dart b/packages/flutter_tools/test/integration.shard/command_output_test.dart index 4b85fcd2345ed..d064194035891 100644 --- a/packages/flutter_tools/test/integration.shard/command_output_test.dart +++ b/packages/flutter_tools/test/integration.shard/command_output_test.dart @@ -276,4 +276,22 @@ void main() { expect(result, const ProcessResultMatcher()); expect(result.stderr, isEmpty); }); + + // Regression test for https://github.com/flutter/flutter/issues/75876. + testWithoutContext('flutter create daemon produces output', () async { + final Directory directory = createResolvedTempDirectorySync('create_daemon_test.'); + + try { + final ProcessResult result = await processManager.run([ + flutterBin, + 'create', + '--no-pub', + 'daemon', + ], workingDirectory: directory.path); + expect(result.exitCode, 0); + expect(result.stdout, contains('Creating project daemon...')); + } finally { + tryToDelete(directory); + } + }); } From 0c0d0594def51d98e189361cb09ad4e7dae5daa7 Mon Sep 17 00:00:00 2001 From: Ben Konyi Date: Mon, 24 Aug 2026 17:31:10 +0000 Subject: [PATCH 43/46] [flutter_tools] Handle null version gracefully in CachedArtifact and MaterialFonts (#191494) When artifact version files (such as `bin/internal/material_fonts.version`) are missing or cannot be resolved, `CachedArtifact.version` evaluates to `null`. Previously, `CachedArtifact.update` invoked `updateInner` before checking if `version` was `null`, causing `MaterialFonts.updateInner` (and other artifact subclasses) to evaluate `version!` and throw an unhandled `TypeError` / `_CastError` with `Null check operator used on a null value`. This change: 1. Returns early with a warning in `CachedArtifact.update` before attempting `updateInner` or writing stamps when `version == null`. 2. Guards against `version == null` in `MaterialFonts`, `GradleWrapper`, and `FlutterRunnerDebugSymbols`. 3. Adds an automated regression test in `cache_test.dart`. Fixes #90092 --- packages/flutter_tools/lib/src/cache.dart | 19 ++++++------ .../test/general.shard/cache_test.dart | 29 +++++++++++++++++++ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/packages/flutter_tools/lib/src/cache.dart b/packages/flutter_tools/lib/src/cache.dart index 5b5cbc44a5df6..7ac0f0bdcf333 100644 --- a/packages/flutter_tools/lib/src/cache.dart +++ b/packages/flutter_tools/lib/src/cache.dart @@ -957,17 +957,18 @@ abstract class CachedArtifact extends ArtifactSet { ); } } + final String? version = this.version; + if (version == null) { + logger.printWarning( + 'No known version for the artifact name "$name". ' + 'Flutter can continue, but the artifact may be re-downloaded on ' + 'subsequent invocations until the problem is resolved.', + ); + return; + } await updateInner(artifactUpdater, fileSystem, operatingSystemUtils); try { - if (version == null) { - logger.printWarning( - 'No known version for the artifact name "$name". ' - 'Flutter can continue, but the artifact may be re-downloaded on ' - 'subsequent invocations until the problem is resolved.', - ); - } else { - cache.setStampFor(stampName, version!); - } + cache.setStampFor(stampName, version); } on FileSystemException catch (err) { logger.printWarning( 'The new artifact "$name" was downloaded, but Flutter failed to update ' diff --git a/packages/flutter_tools/test/general.shard/cache_test.dart b/packages/flutter_tools/test/general.shard/cache_test.dart index a2936dcc97e69..c87039d907b89 100644 --- a/packages/flutter_tools/test/general.shard/cache_test.dart +++ b/packages/flutter_tools/test/general.shard/cache_test.dart @@ -161,6 +161,35 @@ void main() { expect(logger.warningText, contains('No known version for the artifact name "fake"')); }); + testWithoutContext('MaterialFonts continues on missing version file', () async { + final FileSystem fileSystem = MemoryFileSystem.test(); + final logger = BufferLogger.test(); + final Directory artifactDir = fileSystem.systemTempDirectory.createTempSync( + 'flutter_cache_test_artifact.', + ); + final Directory downloadDir = fileSystem.systemTempDirectory.createTempSync( + 'flutter_cache_test_download.', + ); + final Cache cache = FakeSecondaryCache() + ..version = + null // version is missing. + ..artifactDirectory = artifactDir + ..downloadDir = downloadDir; + + final materialFonts = MaterialFonts(cache); + await materialFonts.update( + FakeArtifactUpdater(), + logger, + fileSystem, + FakeOperatingSystemUtils(), + ); + + expect( + logger.warningText, + contains('No known version for the artifact name "material_fonts"'), + ); + }); + testWithoutContext( 'Gradle wrapper should not be up to date, if some cached artifact is not available', () { From fbb2656385ada4b6b1ed0fc8f78fedb975fc12f4 Mon Sep 17 00:00:00 2001 From: Ben Konyi Date: Mon, 24 Aug 2026 17:32:49 +0000 Subject: [PATCH 44/46] [flutter_tools] Handle missing Xcode gracefully in getInfo and buildMacOS (#191180) Fixes https://github.com/flutter/flutter/issues/191176 ### Problem In Flutter 3.47.0 telemetry, an unhandled `ProcessException` when invoking `xcodebuild` emerged as a top crash signature. When running `flutter build macos` on a machine where Xcode is not installed, incomplete, or misconfigured, `flutter_tools` crashes with an unhandled `ProcessException` instead of providing a graceful `ToolExit` error message. ### Root Cause 1. `buildMacOS` in `packages/flutter_tools/lib/src/macos/build_macos.dart` lacked pre-flight toolchain validation (`globals.xcodeProjectInterpreter?.isInstalled != true`) before querying Xcode project information. 2. In `XcodeProjectInterpreter.getInfo` (`packages/flutter_tools/lib/src/ios/xcodeproj.dart`), `xcodebuild -list` is executed with `throwOnError: true` while only allowing exit codes 66 and 74. When `xcrun` returns exit code 72 (unable to find utility "xcodebuild"), `_processUtils.run` throws an uncaught `ProcessException`. 3. `buildMacOS` improperly used non-null assertions on `projectInfo!` when resolving build schemes. ### Fix - Added pre-flight Xcode toolchain check in `buildMacOS` to exit early with `globals.userMessages.xcodeMissing`. - Wrapped `XcodeProjectInterpreter.getInfo` in a `try ... on ProcessException` block to convert unexpected process failures to user-friendly `ToolExit` messages. - Added safe null checks on `projectInfo` in `buildMacOS`. --- .../flutter_tools/lib/src/ios/xcodeproj.dart | 48 +++++----- .../lib/src/macos/build_macos.dart | 13 ++- .../hermetic/build_macos_test.dart | 40 ++++++++ .../general.shard/ios/xcodeproj_test.dart | 93 +++++++++++++++++++ packages/flutter_tools/test/src/context.dart | 23 ++++- 5 files changed, 188 insertions(+), 29 deletions(-) diff --git a/packages/flutter_tools/lib/src/ios/xcodeproj.dart b/packages/flutter_tools/lib/src/ios/xcodeproj.dart index 51f55bf405ddf..59bcc3ced5781 100644 --- a/packages/flutter_tools/lib/src/ios/xcodeproj.dart +++ b/packages/flutter_tools/lib/src/ios/xcodeproj.dart @@ -414,29 +414,33 @@ class XcodeProjectInterpreter { // The exit code returned by 'xcodebuild -list' when the project is corrupted. const corruptedProjectExitCode = 74; bool allowedFailures(int c) => c == missingProjectExitCode || c == corruptedProjectExitCode; - final List xcodebuildCommandArgs = await fetchDependenciesAndGenerateXcodebuildArgs( - xcodeProject, - buildDirectory, - ); - final RunResult result = await _processUtils.run( - [ - ...xcodebuildCommandArgs, - '-list', - if (projectFilename != null) ...['-project', projectFilename], - ], - throwOnError: true, - allowedFailures: allowedFailures, - workingDirectory: xcodeProject.hostAppRoot.path, - ); - if (allowedFailures(result.exitCode)) { - // User configuration error, tool exit instead of crashing. - throwToolExit('Unable to get Xcode project information:\n ${result.stderr}'); + try { + final List xcodebuildCommandArgs = await fetchDependenciesAndGenerateXcodebuildArgs( + xcodeProject, + buildDirectory, + ); + final RunResult result = await _processUtils.run( + [ + ...xcodebuildCommandArgs, + '-list', + if (projectFilename != null) ...['-project', projectFilename], + ], + throwOnError: true, + allowedFailures: allowedFailures, + workingDirectory: xcodeProject.hostAppRoot.path, + ); + if (allowedFailures(result.exitCode)) { + // User configuration error, tool exit instead of crashing. + throwToolExit('Unable to get Xcode project information:\n ${result.stderr}'); + } + return XcodeProjectInfo.fromXcodeBuildOutput( + result.toString(), + _logger, + ignoredSchemes: await _ignoredSwiftPackageSchemes(xcodeProject, buildDirectory), + ); + } on ProcessException catch (exception) { + throwToolExit('Unable to get Xcode project information:\n $exception'); } - return XcodeProjectInfo.fromXcodeBuildOutput( - result.toString(), - _logger, - ignoredSchemes: await _ignoredSwiftPackageSchemes(xcodeProject, buildDirectory), - ); } /// Returns scheme-name candidates for Swift packages that should be excluded from diff --git a/packages/flutter_tools/lib/src/macos/build_macos.dart b/packages/flutter_tools/lib/src/macos/build_macos.dart index 916a7c3d36080..58d04ca07e647 100644 --- a/packages/flutter_tools/lib/src/macos/build_macos.dart +++ b/packages/flutter_tools/lib/src/macos/build_macos.dart @@ -96,6 +96,10 @@ Future buildMacOS({ ); } + if (globals.xcodeProjectInterpreter?.isInstalled != true) { + throwToolExit(globals.userMessages.xcodeMissing); + } + // The .xcworkspace may not exist (e.g. a project using Swift Package Manager // without CocoaPods). When absent, xcodebuild builds the .xcodeproj directly. final Directory? xcodeWorkspace = flutterProject.macos.xcodeWorkspace; @@ -155,11 +159,14 @@ Future buildMacOS({ projectFilename: xcodeProjectName, buildDirectory: flutterBuildDir, ); - final String? scheme = projectInfo?.schemeFor(buildInfo); + if (projectInfo == null) { + throwToolExit('Unable to get Xcode project information.'); + } + final String? scheme = projectInfo.schemeFor(buildInfo); if (scheme == null) { - projectInfo!.reportFlavorNotFoundAndExit(); + projectInfo.reportFlavorNotFoundAndExit(); } - final String? configuration = projectInfo?.buildConfigurationFor(buildInfo, scheme); + final String? configuration = projectInfo.buildConfigurationFor(buildInfo, scheme); if (configuration == null) { throwToolExit('Unable to find expected configuration in Xcode project.'); } diff --git a/packages/flutter_tools/test/commands.shard/hermetic/build_macos_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/build_macos_test.dart index 6416ef10b6dcd..debec0afb27aa 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/build_macos_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/build_macos_test.dart @@ -258,6 +258,46 @@ STDERR STUFF }, ); + testUsingContext( + 'macOS build fails when Xcode is not installed', + () async { + final command = BuildCommand( + androidSdk: FakeAndroidSdk(), + buildSystem: TestBuildSystem.all(BuildResult(success: true)), + fileSystem: fileSystem, + logger: logger, + osUtils: FakeOperatingSystemUtils(), + config: FakeConfig(), + platform: FakePlatform(), + fileSystemUtils: FakeFileSystemUtils(), + terminal: FakeTerminal(), + plistParser: FakePlistParser(), + processUtils: FakeProcessUtils(), + processManager: FakeProcessManager.any(), + templateRenderer: FakeTemplateRenderer(), + xcode: FakeXcode(), + artifacts: FakeArtifacts(), + cache: FakeCache(), + flutterVersion: FakeFlutterVersion(), + ); + createMinimalMockProjectFiles(); + + expect( + createTestCommandRunner(command).run(const ['build', 'macos', '--no-pub']), + throwsToolExit( + message: 'Xcode not installed; this is necessary for iOS and macOS development.', + ), + ); + }, + overrides: { + Platform: () => macosPlatform, + FileSystem: () => fileSystem, + ProcessManager: () => FakeProcessManager.any(), + FeatureFlags: () => TestFeatureFlags(isMacOSEnabled: true), + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreter(isInstalled: false), + }, + ); + testUsingContext( 'macOS build successfully with renamed .xcodeproj/.xcworkspace files', () async { diff --git a/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart b/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart index c1e4cb14a924b..ae04e16e69a72 100644 --- a/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart @@ -753,6 +753,99 @@ void main() { }, ); + testWithoutContext( + 'xcodebuild -list getInfo throws a tool exit when xcodebuild is missing (exit code 72)', + () async { + const workingDirectory = '/'; + final Directory buildDirectory = fileSystem.directory('build/ios'); + const stderr = + 'xcrun: error: unable to find utility "xcodebuild", not a developer tool or in PATH'; + + fakeProcessManager.addCommands(const [ + kWhichSysctlCommand, + kx64CheckCommand, + kResolvePackagesCommand, + FakeCommand( + command: [ + 'xcrun', + 'xcodebuild', + '-clonedSourcePackagesDirPath', + '/build/ios/SourcePackages', + '-skipPackagePluginValidation', + '-skipPackageSignatureValidation', + '-list', + ], + exitCode: 72, + stderr: stderr, + ), + ]); + + final xcodeProjectInterpreter = XcodeProjectInterpreter( + logger: logger, + fileSystem: fileSystem, + platform: platform, + processManager: fakeProcessManager, + analytics: const NoOpAnalytics(), + ); + + await expectLater( + () => xcodeProjectInterpreter.getInfo( + FakeXcodeBasedProject(workingDirectory, fileSystem), + buildDirectory: buildDirectory, + ), + throwsToolExit(message: stderr), + ); + expect(fakeProcessManager, hasNoRemainingExpectations); + }, + ); + + testWithoutContext( + 'xcodebuild -list getInfo throws a tool exit when process execution throws ProcessException', + () async { + const workingDirectory = '/'; + final Directory buildDirectory = fileSystem.directory('build/ios'); + const errorMessage = 'xcrun: error: unable to find utility "xcodebuild"'; + const processException = ProcessException('xcrun', ['xcodebuild'], errorMessage, 72); + + fakeProcessManager.addCommands([ + kWhichSysctlCommand, + kx64CheckCommand, + kResolvePackagesCommand, + FakeCommand( + command: const [ + 'xcrun', + 'xcodebuild', + '-clonedSourcePackagesDirPath', + '/build/ios/SourcePackages', + '-skipPackagePluginValidation', + '-skipPackageSignatureValidation', + '-list', + ], + onRun: (_) { + throw processException; + }, + ), + ]); + + final xcodeProjectInterpreter = XcodeProjectInterpreter( + logger: logger, + fileSystem: fileSystem, + platform: platform, + processManager: fakeProcessManager, + analytics: const NoOpAnalytics(), + ); + + await expectLater( + () => xcodeProjectInterpreter.getInfo( + FakeXcodeBasedProject(workingDirectory, fileSystem), + buildDirectory: buildDirectory, + ), + throwsToolExit(message: processException.toString()), + ); + expect(fakeProcessManager, hasNoRemainingExpectations); + }, + ); + testWithoutContext('Xcode project properties from default project can be parsed', () { const output = ''' Information about project "Runner": diff --git a/packages/flutter_tools/test/src/context.dart b/packages/flutter_tools/test/src/context.dart index 5bde0cd635294..179b1a74e56cf 100644 --- a/packages/flutter_tools/test/src/context.dart +++ b/packages/flutter_tools/test/src/context.dart @@ -351,17 +351,32 @@ class NoopIOSSimulatorUtils implements IOSSimulatorUtils { } class FakeXcodeProjectInterpreter implements XcodeProjectInterpreter { + FakeXcodeProjectInterpreter({ + bool isInstalled = true, + String? versionText = 'Xcode 15', + Version? version = const Version.withText(15, 0, 0, '15.0.0'), + String? build = '15A240D', + }) : _isInstalled = isInstalled, + _versionText = versionText, + _version = version, + _build = build; + + final bool _isInstalled; + final String? _versionText; + final Version? _version; + final String? _build; + @override - bool get isInstalled => true; + bool get isInstalled => _isInstalled; @override - String get versionText => 'Xcode 15'; + String? get versionText => _versionText; @override - Version get version => Version(15, 0, 0); + Version? get version => _version; @override - String get build => '15A240D'; + String? get build => _build; @override Future> getBuildSettings( From af06e99a293b858c5b99e838d34a2e0247ee887d Mon Sep 17 00:00:00 2001 From: Victoria Ashworth <15619084+vashworth@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:08:28 +0000 Subject: [PATCH 45/46] On iOS 27+ devices, manually process lldb stops (#191434) On iOS 27+ devices, `lldb` sometimes doesn't stop on breakpoints when auto-continue is enabled. To workaround this, we disable auto-continue and manually process stops. If a stop is caused by a breakpoint, we tell the process to continue. Otherwise, we print the backtrace and detach. Fixes https://github.com/flutter/flutter/issues/190307. ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [AI contribution guidelines] and understand my responsibilities, or I am not using AI tools. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. If this change needs to override an active code freeze, provide a comment explaining why. The code freeze workflow can be overridden by code reviewers. See pinned issues for any active code freezes with guidance. **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [AI contribution guidelines]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --- .../lib/src/ios/core_devices.dart | 5 +- packages/flutter_tools/lib/src/ios/lldb.dart | 121 +++- .../flutter_tools/lib/src/macos/xcdevice.dart | 1 + .../general.shard/ios/core_devices_test.dart | 23 + .../test/general.shard/ios/lldb_test.dart | 581 ++++++++++-------- 5 files changed, 453 insertions(+), 278 deletions(-) diff --git a/packages/flutter_tools/lib/src/ios/core_devices.dart b/packages/flutter_tools/lib/src/ios/core_devices.dart index abbe083013267..4d3e0cfd780d1 100644 --- a/packages/flutter_tools/lib/src/ios/core_devices.dart +++ b/packages/flutter_tools/lib/src/ios/core_devices.dart @@ -14,6 +14,7 @@ import '../base/logger.dart'; import '../base/process.dart'; import '../base/template.dart'; import '../base/utils.dart'; +import '../base/version.dart'; import '../build_info.dart'; import '../convert.dart'; import '../device.dart'; @@ -42,6 +43,7 @@ class IOSCoreDeviceLauncher { required FileSystem fileSystem, required ProcessUtils processUtils, required XcodeProjectInterpreter xcodeProjectInterpreter, + required Version? deviceVersion, @visibleForTesting LLDB? lldb, }) : _coreDeviceControl = coreDeviceControl, _logger = logger, @@ -53,6 +55,7 @@ class IOSCoreDeviceLauncher { logger: logger, processUtils: processUtils, xcodeProjectInterpreter: xcodeProjectInterpreter, + deviceVersion: deviceVersion, ); final IOSCoreDeviceControl _coreDeviceControl; @@ -865,7 +868,7 @@ class IOSCoreDeviceControl { unawaited( launchProcess.exitCode .then((int status) async { - _logger.printTrace('lldb exited with code $status'); + _logger.printTrace('devicectl exited with code $status'); await stdoutSubscription.cancel(); await stderrSubscription.cancel(); }) diff --git a/packages/flutter_tools/lib/src/ios/lldb.dart b/packages/flutter_tools/lib/src/ios/lldb.dart index 6df379111c05d..9a030ddbc7710 100644 --- a/packages/flutter_tools/lib/src/ios/lldb.dart +++ b/packages/flutter_tools/lib/src/ios/lldb.dart @@ -12,6 +12,7 @@ import '../base/io.dart'; import '../base/logger.dart'; import '../base/process.dart'; import '../base/utils.dart'; +import '../base/version.dart'; import '../build_info.dart'; import 'device_support.dart'; import 'xcodeproj.dart'; @@ -25,13 +26,16 @@ class LLDB { required Logger logger, required ProcessUtils processUtils, required XcodeProjectInterpreter xcodeProjectInterpreter, + required Version? deviceVersion, }) : _logger = logger, _processUtils = processUtils, - _xcodeProjectInterpreter = xcodeProjectInterpreter; + _xcodeProjectInterpreter = xcodeProjectInterpreter, + _deviceVersion = deviceVersion; final Logger _logger; final ProcessUtils _processUtils; final XcodeProjectInterpreter _xcodeProjectInterpreter; + final Version? _deviceVersion; _LLDBProcess? _lldbProcess; @@ -78,6 +82,20 @@ class LLDB { /// Example: "- Hook 1 (thread backtrace all)" static final _stopHookProcessedPattern = RegExp(r'- Hook \d+'); + /// Pattern of lldb log when the application has started. + /// + /// Example: Target 0: (Flutter Gallery) stopped. + static final _targetStoppedPattern = RegExp(r'Target .* stopped.'); + + /// LLDB command to continue execution of all threads in the current process. + static const _processContinueCommand = 'process continue'; + + /// LLDB command to show backtraces of all thread call stacks. + static const _threadBacktraceAllCommand = 'thread backtrace all'; + + /// LLDB command to detach from the current target process. + static const _detachCommand = 'detach'; + /// A list of log patterns to ignore. static final _ignorePatterns = [ RegExp(r'\d+ location added to breakpoint \d+'), @@ -110,8 +128,6 @@ if not error.Success(): print(f'Failed to write into {base}[+{page_len}]', error) return -# If the returned value is False, that tells LLDB not to stop at the breakpoint -return False '''; /// Starts an LLDB process and inputs commands to start debugging the [appProcessId]. @@ -144,9 +160,20 @@ return False } }); + // iOS 27+ devices sometimes fail to stop at the JIT breakpoint (only used in debug mode) + // when `auto-continue` is enabled. This causes the app to crash. To workaround this, we + // manually listen for stops and continue the process if the stop is due to the breakpoint. + var manualContinue = false; + if (mode == BuildMode.debug && + _deviceVersion != null && + _deviceVersion >= Version(27, 0, 0)) { + manualContinue = true; + } + final bool start = await _startLLDB( appProcessId: appProcessId, lldbLogForwarder: lldbLogForwarder, + manualContinue: manualContinue, ); if (!start) { return false; @@ -155,10 +182,15 @@ return False await _addSymbolSearchPaths(deviceSupport); if (mode == BuildMode.debug) { - await _setBreakpoint(); + await _setBreakpoint(manualContinue); } await _attachToAppProcess(appProcessId); - await _setupStopHooks(); + + // Stop hooks should only be used when manualContinue is false. Otherwise, it would run on + // every breakpoint stop instead of just crashes. + if (!manualContinue) { + await _setupStopHooks(); + } await _printDeviceSupportStatus(); await _resumeProcess(mode); _isAttached = true; @@ -180,6 +212,7 @@ return False Future _startLLDB({ required int appProcessId, required LLDBLogForwarder lldbLogForwarder, + required bool manualContinue, }) async { if (_lldbProcess != null) { _logger.printTrace( @@ -196,18 +229,63 @@ return False appProcessId: appProcessId, logger: _logger, ); + + void printLine(String line) { + if (_isAttached && !_ignoreLog(line)) { + // Only forwards logs after LLDB is attached. All logs before then are part of the + // attach process. + + lldbLogForwarder.addLog(line); + } else { + _logger.printTrace('[lldb]: $line'); + _logCompleter?.checkForMatch(line); + } + } + + var processIsStoppedAfterAttaching = false; + final List stopLogs = []; final StreamSubscription stdoutSubscription = _lldbProcess!.stdout .transform(utf8LineDecoder) - .listen((String line) { - if (_isAttached && !_ignoreLog(line)) { - // Only forwards logs after LLDB is attached. All logs before then are part of the - // attach process. + .listen((String line) async { + // If not manually handling stops or if not attached yet, print the line directly. + if (!manualContinue || !_isAttached) { + printLine(line); + return; + } - lldbLogForwarder.addLog(line); - } else { - _logger.printTrace('[lldb]: $line'); - _logCompleter?.checkForMatch(line); + // When manually continuing, listen for process stops and then handle the stop by + // either continuing the process or detaching. + // + // When the process stops, collect logs until the target has stopped. Once the target + // has stopped, check if it stopped due to a breakpoint. If it has, continue and + // process and discard the logs after the process resumes. If the stop is not caused by + // a breakpoint, print the collected logs, get the stack trace, and detach. + if (line.contains(_lldbProcessStopped)) { + processIsStoppedAfterAttaching = true; + } + if (processIsStoppedAfterAttaching) { + stopLogs.add(line); + } + if (line.contains(_targetStoppedPattern)) { + if (stopLogs.any((log) => log.contains('stop reason = breakpoint'))) { + await _lldbProcess?.stdinWriteln(_processContinueCommand); + } else { + stopLogs.forEach(printLine); + await _lldbProcess?.stdinWriteln(_threadBacktraceAllCommand); + await _lldbProcess?.stdinWriteln(_detachCommand); + processIsStoppedAfterAttaching = false; + } + return; } + if (processIsStoppedAfterAttaching) { + if (line.contains(_lldbProcessResuming)) { + processIsStoppedAfterAttaching = false; + stopLogs.clear(); + } + return; + } + + printLine(line); }); final StreamSubscription stderrSubscription = _lldbProcess!.stderr @@ -294,14 +372,17 @@ return False /// Sets a breakpoint, waits for it print the breakpoint id, and adds a python /// script command to be executed whenever the breakpoint is hit. - Future _setBreakpoint() async { + Future _setBreakpoint(bool manualContinue) async { final Future futureLog = _startWaitingForLog( _breakpointPattern, ).then((value) => value, onError: _handleAsyncError); - await _lldbProcess?.stdinWriteln( - r"breakpoint set --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'", - ); + final breakpointSetCommand = manualContinue + ? r"breakpoint set --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'" + : r"breakpoint set --auto-continue true --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'"; + + await _lldbProcess?.stdinWriteln(breakpointSetCommand); + final String log = await futureLog; final Match? match = _breakpointPattern.firstMatch(log); final String? breakpointId = match?.group(1); @@ -330,7 +411,7 @@ return False mode == BuildMode.debug ? _lldbBreakpointAdded : _lldbProcessResuming, ).then((value) => value, onError: _handleAsyncError); - await _lldbProcess?.stdinWriteln('process continue'); + await _lldbProcess?.stdinWriteln(_processContinueCommand); await futureLog; } @@ -342,7 +423,9 @@ return False final Future futureLog = _startWaitingForLog( _stopHookAddedPattern, ).then((value) => value, onError: _handleAsyncError); - await _lldbProcess?.stdinWriteln('target stop-hook add -o "thread backtrace all" -o "detach"'); + await _lldbProcess?.stdinWriteln( + 'target stop-hook add -o "$_threadBacktraceAllCommand" -o "$_detachCommand"', + ); await futureLog; } diff --git a/packages/flutter_tools/lib/src/macos/xcdevice.dart b/packages/flutter_tools/lib/src/macos/xcdevice.dart index 4a37d8304e7ef..a73bd7f2c5427 100644 --- a/packages/flutter_tools/lib/src/macos/xcdevice.dart +++ b/packages/flutter_tools/lib/src/macos/xcdevice.dart @@ -657,6 +657,7 @@ class XCDevice { fileSystem: globals.fs, processUtils: _processUtils, xcodeProjectInterpreter: globals.xcodeProjectInterpreter!, + deviceVersion: Version.parse(sdkVersionString), ), xcodeDebug: _xcodeDebug, xcode: _xcode, diff --git a/packages/flutter_tools/test/general.shard/ios/core_devices_test.dart b/packages/flutter_tools/test/general.shard/ios/core_devices_test.dart index ab37d23d50ade..16ba0671dbdca 100644 --- a/packages/flutter_tools/test/general.shard/ios/core_devices_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/core_devices_test.dart @@ -14,6 +14,7 @@ import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/base/platform.dart'; import 'package:flutter_tools/src/base/process.dart'; import 'package:flutter_tools/src/base/template.dart'; +import 'package:flutter_tools/src/base/version.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/device.dart'; import 'package:flutter_tools/src/ios/application_package.dart'; @@ -91,6 +92,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: fakeLLDB, ); @@ -118,6 +120,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), ); final bool result = await launcher.launchAppWithoutDebugger( @@ -147,6 +150,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), ); final bool result = await launcher.launchAppWithoutDebugger( @@ -172,6 +176,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), ); final bool result = await launcher.launchAppWithoutDebugger( @@ -220,6 +225,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: fakeLLDB, ); @@ -272,6 +278,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: fakeLLDB, ); final shutdownHooks = FakeShutdownHooks(); @@ -334,6 +341,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: fakeLLDB, ); final shutdownHooks = FakeShutdownHooks(); @@ -401,6 +409,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: fakeLLDB, ); @@ -455,6 +464,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: fakeLLDB, ); @@ -502,6 +512,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: fakeLLDB, ); @@ -555,6 +566,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: fakeLLDB, ); @@ -602,6 +614,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: fakeLLDB, ); @@ -651,6 +664,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: fakeLLDB, ); @@ -702,6 +716,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: fakeLLDB, ); @@ -740,6 +755,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: FakeLLDB(), ); final bool result = await launcher.launchAppWithXcodeDebugger( @@ -774,6 +790,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: FakeLLDB(), ); final bool result = await launcher.launchAppWithXcodeDebugger( @@ -808,6 +825,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: FakeLLDB(), ); final bool result = await launcher.launchAppWithXcodeDebugger( @@ -842,6 +860,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: FakeLLDB(), ); final bool result = await launcher.launchAppWithXcodeDebugger( @@ -875,6 +894,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: fakeLLDB, ); @@ -905,6 +925,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: fakeLLDB, ); @@ -934,6 +955,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: fakeLLDB, ); @@ -962,6 +984,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: fakeLLDB, ); diff --git a/packages/flutter_tools/test/general.shard/ios/lldb_test.dart b/packages/flutter_tools/test/general.shard/ios/lldb_test.dart index 3c869437d8133..46156eae3f879 100644 --- a/packages/flutter_tools/test/general.shard/ios/lldb_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/lldb_test.dart @@ -12,6 +12,7 @@ import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/base/io.dart'; import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/base/process.dart'; +import 'package:flutter_tools/src/base/version.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/ios/device_support.dart'; import 'package:flutter_tools/src/ios/lldb.dart'; @@ -21,11 +22,12 @@ import '../../src/common.dart'; import '../../src/context.dart'; import '../../src/fake_process_manager.dart'; +const _deviceId = '123'; +const _appProcessId = 5678; +const _breakpointId = 123; + void main() { testWithoutContext('attachAndStart fails if lldb fails', () async { - const deviceId = '123'; - const appProcessId = 5678; - final processCompleter = Completer(); final lldbCommand = FakeLLDBCommand( command: const ['xcrun', 'lldb'], @@ -45,11 +47,12 @@ void main() { logger: logger, processUtils: processUtils, xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + deviceVersion: Version(16, 0, 0), ); final bool success = await lldb.attachAndStart( - deviceId: deviceId, - appProcessId: appProcessId, + deviceId: _deviceId, + appProcessId: _appProcessId, lldbLogForwarder: FakeLLDBLogForwarder(), mode: BuildMode.debug, deviceSupport: createDeviceSupport(), @@ -62,22 +65,18 @@ void main() { }); testWithoutContext('attachAndStart returns true on success', () async { - const deviceId = '123'; - const appProcessId = 5678; - const breakpointId = 123; - final breakPointCompleter = Completer>(); final processAttachCompleter = Completer>(); final setupStopHooksCompleter = Completer>(); final platformStatusCompleter = Completer>(); - final processResumedCompleted = Completer>(); + final processResumedCompleter = Completer>(); final stdoutStream = Stream>.fromFutures([ breakPointCompleter.future, processAttachCompleter.future, setupStopHooksCompleter.future, platformStatusCompleter.future, - processResumedCompleted.future, + processResumedCompleter.future, ]); final stdinController = StreamController>(); @@ -99,88 +98,56 @@ void main() { logger: logger, processUtils: processUtils, xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + deviceVersion: Version(16, 0, 0), ); - const breakPointMatcher = r"breakpoint set --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'"; - const processAttachMatcher = 'device process attach --pid $appProcessId'; - const processResumedMatcher = 'process continue'; - const setupStopHooksMatcher = 'target stop-hook add -o "thread backtrace all" -o "detach"'; - const platformStatusMatcher = 'platform status'; - final expectedInputs = [ - 'device select $deviceId', - breakPointMatcher, - 'breakpoint command add --script-type python $breakpointId', - 'script lldb.debugger.SetAsync(False)', - processAttachMatcher, - setupStopHooksMatcher, - platformStatusMatcher, - processResumedMatcher, - ]; + final Map> completer, String out})?> + inputsAndOutputs = buildAttachInputsAndOutputs( + breakPointMatcher: + r"breakpoint set --auto-continue true --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'", + processResumingOutput: '1 location added to breakpoint 1\n', + breakPointCompleter: breakPointCompleter, + processAttachCompleter: processAttachCompleter, + setupStopHooksCompleter: setupStopHooksCompleter, + platformStatusCompleter: platformStatusCompleter, + processResumedCompleter: processResumedCompleter, + ); stdinController.stream.transform(utf8.decoder).transform(const LineSplitter()).listen(( String line, ) { - expectedInputs.remove(line); - if (line == breakPointMatcher) { - breakPointCompleter.complete( - utf8.encode('Breakpoint $breakpointId: no locations (pending).\n'), - ); - } - if (line == processAttachMatcher) { - processAttachCompleter.complete( - utf8.encode(''' -Process 568 stopped -* thread #1, stop reason = signal SIGSTOP - frame #0: 0x0000000102c7b240 dyld`_dyld_start -dyld`_dyld_start: --> 0x102c7b240 <+0>: mov x0, sp - 0x102c7b244 <+4>: and sp, x0, #0xfffffffffffffff0 - 0x102c7b248 <+8>: mov x29, #0x0 ; =0 - 0x102c7b24c <+12>: mov x30, #0x0 ; =0 -Target 0: (Runner) stopped. -'''), - ); - } - if (line == setupStopHooksMatcher) { - setupStopHooksCompleter.complete(utf8.encode('Stop hook #1 added.\n')); - } - if (line == platformStatusMatcher) { - platformStatusCompleter.complete(utf8.encode(' Platform: remote-ios\n')); - } - if (line == processResumedMatcher) { - processResumedCompleted.complete(utf8.encode('1 location added to breakpoint 1\n')); + final ({Completer> completer, String out})? x = inputsAndOutputs.remove(line); + if (x != null) { + x.completer.complete(utf8.encode(x.out)); } }); final bool success = await lldb.attachAndStart( - deviceId: deviceId, - appProcessId: appProcessId, + deviceId: _deviceId, + appProcessId: _appProcessId, lldbLogForwarder: FakeLLDBLogForwarder(), mode: BuildMode.debug, deviceSupport: createDeviceSupport(), ); expect(success, isTrue); expect(lldb.isRunning, isTrue); - expect(lldb.appProcessId, appProcessId); - expect(expectedInputs, isEmpty); + expect(lldb.appProcessId, _appProcessId); + expect(inputsAndOutputs, isEmpty); expect(processManager.hasRemainingExpectations, isFalse); expect(logger.errorText, isEmpty); }); testWithoutContext('attachAndStart returns true on success for profile mode', () async { - const deviceId = '123'; - const appProcessId = 5678; - final processAttachCompleter = Completer>(); final setupStopHooksCompleter = Completer>(); final platformStatusCompleter = Completer>(); - final processResumedCompleted = Completer>(); + final processResumedCompleter = Completer>(); final stdoutStream = Stream>.fromFutures([ processAttachCompleter.future, setupStopHooksCompleter.future, platformStatusCompleter.future, - processResumedCompleted.future, + processResumedCompleter.future, ]); final stdinController = StreamController>(); @@ -202,69 +169,46 @@ Target 0: (Runner) stopped. logger: logger, processUtils: processUtils, xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + deviceVersion: Version(27, 0, 0), ); - const processAttachMatcher = 'device process attach --pid $appProcessId'; - const processResumedMatcher = 'process continue'; - const setupStopHooksMatcher = 'target stop-hook add -o "thread backtrace all" -o "detach"'; - const platformStatusMatcher = 'platform status'; - final expectedInputs = [ - 'device select $deviceId', - processAttachMatcher, - setupStopHooksMatcher, - platformStatusMatcher, - processResumedMatcher, - ]; + final Map> completer, String out})?> + inputsAndOutputs = buildAttachInputsAndOutputs( + breakPointMatcher: + r"breakpoint set --auto-continue true --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'", + processResumingOutput: 'Process $_appProcessId resuming\n', + breakPointCompleter: null, + processAttachCompleter: processAttachCompleter, + setupStopHooksCompleter: setupStopHooksCompleter, + platformStatusCompleter: platformStatusCompleter, + processResumedCompleter: processResumedCompleter, + ); stdinController.stream.transform(utf8.decoder).transform(const LineSplitter()).listen(( String line, ) { - expectedInputs.remove(line); - if (line == processAttachMatcher) { - processAttachCompleter.complete( - utf8.encode(''' -Process 568 stopped -* thread #1, stop reason = signal SIGSTOP - frame #0: 0x0000000102c7b240 dyld`_dyld_start -dyld`_dyld_start: --> 0x102c7b240 <+0>: mov x0, sp - 0x102c7b244 <+4>: and sp, x0, #0xfffffffffffffff0 - 0x102c7b248 <+8>: mov x29, #0x0 ; =0 - 0x102c7b24c <+12>: mov x30, #0x0 ; =0 -Target 0: (Runner) stopped. -'''), - ); - } - if (line == setupStopHooksMatcher) { - setupStopHooksCompleter.complete(utf8.encode('Stop hook #1 added.\n')); - } - if (line == platformStatusMatcher) { - platformStatusCompleter.complete(utf8.encode(' Platform: remote-ios\n')); - } - if (line == processResumedMatcher) { - processResumedCompleted.complete(utf8.encode('Process 568 resuming\n')); + final ({Completer> completer, String out})? x = inputsAndOutputs.remove(line); + if (x != null) { + x.completer.complete(utf8.encode(x.out)); } }); final bool success = await lldb.attachAndStart( - deviceId: deviceId, - appProcessId: appProcessId, + deviceId: _deviceId, + appProcessId: _appProcessId, lldbLogForwarder: FakeLLDBLogForwarder(), mode: BuildMode.profile, deviceSupport: createDeviceSupport(), ); expect(success, isTrue); expect(lldb.isRunning, isTrue); - expect(lldb.appProcessId, appProcessId); - expect(expectedInputs, isEmpty); + expect(lldb.appProcessId, _appProcessId); + expect(inputsAndOutputs, isEmpty); expect(processManager.hasRemainingExpectations, isFalse); expect(logger.errorText, isEmpty); }); testWithoutContext('attachAndStart returns false when stderr during log waiter', () async { - const deviceId = '123'; - const appProcessId = 5678; - final breakPointCompleter = Completer>(); final errorCompleter = Completer>(); @@ -291,10 +235,12 @@ Target 0: (Runner) stopped. logger: logger, processUtils: processUtils, xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + deviceVersion: Version(16, 0, 0), ); - const breakPointMatcher = r"breakpoint set --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'"; - final expectedInputs = ['device select $deviceId', breakPointMatcher]; + const breakPointMatcher = + r"breakpoint set --auto-continue true --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'"; + final expectedInputs = ['device select $_deviceId', breakPointMatcher]; const errorText = "error: 'device' is not a valid command.\n"; stdinController.stream.transform(utf8.decoder).transform(const LineSplitter()).listen(( @@ -307,8 +253,8 @@ Target 0: (Runner) stopped. }); final bool success = await lldb.attachAndStart( - deviceId: deviceId, - appProcessId: appProcessId, + deviceId: _deviceId, + appProcessId: _appProcessId, lldbLogForwarder: FakeLLDBLogForwarder(), mode: BuildMode.debug, deviceSupport: createDeviceSupport(), @@ -322,9 +268,6 @@ Target 0: (Runner) stopped. }); testWithoutContext('attachAndStart returns false when stderr not during log waiter', () async { - const deviceId = '123'; - const appProcessId = 5678; - final breakPointCompleter = Completer>(); final errorCompleter = Completer>(); @@ -351,10 +294,11 @@ Target 0: (Runner) stopped. logger: logger, processUtils: processUtils, xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + deviceVersion: Version(16, 0, 0), ); final expectedInputs = [ - 'device select $deviceId', - r"breakpoint set --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'", + 'device select $_deviceId', + r"breakpoint set --auto-continue true --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'", ]; const errorText = "error: 'device' is not a valid command.\n"; @@ -368,8 +312,8 @@ Target 0: (Runner) stopped. }); final bool success = await lldb.attachAndStart( - deviceId: deviceId, - appProcessId: appProcessId, + deviceId: _deviceId, + appProcessId: _appProcessId, lldbLogForwarder: FakeLLDBLogForwarder(), mode: BuildMode.debug, deviceSupport: createDeviceSupport(), @@ -383,9 +327,6 @@ Target 0: (Runner) stopped. }); testWithoutContext('attachAndStart prints warning if takes too long', () async { - const deviceId = '123'; - const appProcessId = 5678; - final stdinController = StreamController>(); final processCompleter = Completer(); @@ -405,6 +346,7 @@ Target 0: (Runner) stopped. logger: logger, processUtils: processUtils, xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + deviceVersion: Version(16, 0, 0), ); final completer = Completer(); @@ -419,8 +361,8 @@ Target 0: (Runner) stopped. await FakeAsync().run((FakeAsync time) { lldb.attachAndStart( - deviceId: deviceId, - appProcessId: appProcessId, + deviceId: _deviceId, + appProcessId: _appProcessId, lldbLogForwarder: FakeLLDBLogForwarder(), mode: BuildMode.debug, deviceSupport: createDeviceSupport(), @@ -437,15 +379,11 @@ Target 0: (Runner) stopped. }); testWithoutContext('attachAndStart streams logs to LLDBLogForwarder', () async { - const deviceId = '123'; - const appProcessId = 5678; - const breakpointId = 123; - final breakPointCompleter = Completer>(); final processAttachCompleter = Completer>(); final setupStopHooksCompleter = Completer>(); final platformStatusCompleter = Completer>(); - final processResumedCompleted = Completer>(); + final processResumedCompleter = Completer>(); final logAfterAttachCompleter = Completer>(); final stdoutStream = Stream>.fromFutures([ @@ -453,7 +391,7 @@ Target 0: (Runner) stopped. processAttachCompleter.future, setupStopHooksCompleter.future, platformStatusCompleter.future, - processResumedCompleted.future, + processResumedCompleter.future, logAfterAttachCompleter.future, ]); @@ -476,56 +414,27 @@ Target 0: (Runner) stopped. logger: logger, processUtils: processUtils, xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + deviceVersion: Version(16, 0, 0), ); - const breakPointMatcher = r"breakpoint set --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'"; - const processAttachMatcher = 'device process attach --pid $appProcessId'; - const processResumedMatcher = 'process continue'; - const setupStopHooksMatcher = 'target stop-hook add -o "thread backtrace all" -o "detach"'; - const platformStatusMatcher = 'platform status'; - final expectedInputs = [ - 'device select $deviceId', - breakPointMatcher, - 'breakpoint command add --script-type python $breakpointId', - 'script lldb.debugger.SetAsync(False)', - processAttachMatcher, - setupStopHooksMatcher, - platformStatusMatcher, - processResumedMatcher, - ]; + final Map> completer, String out})?> + inputsAndOutputs = buildAttachInputsAndOutputs( + breakPointMatcher: + r"breakpoint set --auto-continue true --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'", + processResumingOutput: '1 location added to breakpoint 1\n', + breakPointCompleter: breakPointCompleter, + processAttachCompleter: processAttachCompleter, + setupStopHooksCompleter: setupStopHooksCompleter, + platformStatusCompleter: platformStatusCompleter, + processResumedCompleter: processResumedCompleter, + ); stdinController.stream.transform(utf8.decoder).transform(const LineSplitter()).listen(( String line, ) { - expectedInputs.remove(line); - if (line == breakPointMatcher) { - breakPointCompleter.complete( - utf8.encode('Breakpoint $breakpointId: no locations (pending).\n'), - ); - } - if (line == processAttachMatcher) { - processAttachCompleter.complete( - utf8.encode(''' -Process 568 stopped -* thread #1, stop reason = signal SIGSTOP - frame #0: 0x0000000102c7b240 dyld`_dyld_start -dyld`_dyld_start: --> 0x102c7b240 <+0>: mov x0, sp - 0x102c7b244 <+4>: and sp, x0, #0xfffffffffffffff0 - 0x102c7b248 <+8>: mov x29, #0x0 ; =0 - 0x102c7b24c <+12>: mov x30, #0x0 ; =0 -Target 0: (Runner) stopped. -'''), - ); - } - if (line == setupStopHooksMatcher) { - setupStopHooksCompleter.complete(utf8.encode('Stop hook #1 added.\n')); - } - if (line == platformStatusMatcher) { - platformStatusCompleter.complete(utf8.encode(' Platform: remote-ios\n')); - } - if (line == processResumedMatcher) { - processResumedCompleted.complete(utf8.encode('1 location added to breakpoint 1\n')); + final ({Completer> completer, String out})? x = inputsAndOutputs.remove(line); + if (x != null) { + x.completer.complete(utf8.encode(x.out)); } }); @@ -534,8 +443,8 @@ Target 0: (Runner) stopped. final lldbLogForwarder = FakeLLDBLogForwarder(expectedLog: expectedForwardedLog); final bool success = await lldb.attachAndStart( - deviceId: deviceId, - appProcessId: appProcessId, + deviceId: _deviceId, + appProcessId: _appProcessId, lldbLogForwarder: lldbLogForwarder, mode: BuildMode.debug, deviceSupport: createDeviceSupport(), @@ -546,8 +455,8 @@ Target 0: (Runner) stopped. expect(success, isTrue); expect(lldb.isRunning, isTrue); - expect(lldb.appProcessId, appProcessId); - expect(expectedInputs, isEmpty); + expect(lldb.appProcessId, _appProcessId); + expect(inputsAndOutputs, isEmpty); expect(processManager.hasRemainingExpectations, isFalse); expect(logger.errorText, isEmpty); expect(lldbLogForwarder.logs.length, 1); @@ -555,9 +464,6 @@ Target 0: (Runner) stopped. }); testWithoutContext('exit returns true and kills process', () async { - const deviceId = '123'; - const appProcessId = 5678; - final stdinController = StreamController>(); final processCompleter = Completer(); @@ -577,6 +483,7 @@ Target 0: (Runner) stopped. logger: logger, processUtils: processUtils, xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + deviceVersion: Version(16, 0, 0), ); final lldbStarted = Completer(); @@ -591,8 +498,8 @@ Target 0: (Runner) stopped. unawaited( lldb.attachAndStart( - deviceId: deviceId, - appProcessId: appProcessId, + deviceId: _deviceId, + appProcessId: _appProcessId, lldbLogForwarder: FakeLLDBLogForwarder(), mode: BuildMode.debug, deviceSupport: createDeviceSupport(), @@ -617,6 +524,7 @@ Target 0: (Runner) stopped. logger: logger, processUtils: processUtils, xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + deviceVersion: Version(16, 0, 0), ); expect(lldb.isRunning, isFalse); final bool exitStatus = lldb.exit(); @@ -642,21 +550,18 @@ Target 0: (Runner) stopped. .childDirectory('Symbols') ..createSync(recursive: true); - const deviceId = '123'; - const appProcessId = 5678; - final platformSelectCompleter = Completer>(); final processAttachCompleter = Completer>(); final setupStopHooksCompleter = Completer>(); final platformStatusCompleter = Completer>(); - final processResumedCompleted = Completer>(); + final processResumedCompleter = Completer>(); final stdoutStream = Stream>.fromFutures([ platformSelectCompleter.future, processAttachCompleter.future, setupStopHooksCompleter.future, platformStatusCompleter.future, - processResumedCompleted.future, + processResumedCompleter.future, ]); final stdinController = StreamController>(); @@ -678,49 +583,39 @@ Target 0: (Runner) stopped. logger: logger, processUtils: processUtils, xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + deviceVersion: Version(16, 0, 0), ); - final platformSelectMatcher = 'platform select remote-ios --sysroot "${archSymbols.path}"'; - const processAttachMatcher = 'device process attach --pid $appProcessId'; - const setupStopHooksMatcher = 'target stop-hook add -o "thread backtrace all" -o "detach"'; - const processResumedMatcher = 'process continue'; - const platformStatusMatcher = 'platform status'; - final expectedInputs = [ - 'device select $deviceId', - platformSelectMatcher, - processAttachMatcher, - setupStopHooksMatcher, - platformStatusMatcher, - processResumedMatcher, - ]; + final Map> completer, String out})?> + inputsAndOutputs = buildAttachInputsAndOutputs( + breakPointMatcher: + r"breakpoint set --auto-continue true --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'", + processResumingOutput: 'Process $_appProcessId resuming\n', + breakPointCompleter: null, + processAttachCompleter: processAttachCompleter, + setupStopHooksCompleter: setupStopHooksCompleter, + platformStatusCompleter: platformStatusCompleter, + processResumedCompleter: processResumedCompleter, + ); + inputsAndOutputs.addAll({ + 'platform select remote-ios --sysroot "${archSymbols.path}"': null, + }); stdinController.stream .transform(utf8.decoder) .transform(const LineSplitter()) .listen((String line) { - expectedInputs.remove(line); - if (line == platformSelectMatcher) { - platformSelectCompleter.complete(utf8.encode('\n')); - } - if (line == processAttachMatcher) { - processAttachCompleter.complete( - utf8.encode('Process 568 stopped\nTarget 0: (Runner) stopped.\n'), - ); - } - if (line == setupStopHooksMatcher) { - setupStopHooksCompleter.complete(utf8.encode('Stop hook #1 added.\n')); - } - if (line == platformStatusMatcher) { - platformStatusCompleter.complete(utf8.encode(' Platform: remote-ios\n')); - } - if (line == processResumedMatcher) { - processResumedCompleted.complete(utf8.encode('Process 568 resuming\n')); + final ({Completer> completer, String out})? x = inputsAndOutputs.remove( + line, + ); + if (x != null) { + x.completer.complete(utf8.encode(x.out)); } }); final bool success = await lldb.attachAndStart( - deviceId: deviceId, - appProcessId: appProcessId, + deviceId: _deviceId, + appProcessId: _appProcessId, lldbLogForwarder: FakeLLDBLogForwarder(), mode: BuildMode.profile, deviceSupport: createDeviceSupport( @@ -732,7 +627,7 @@ Target 0: (Runner) stopped. ); expect(success, isTrue); - expect(expectedInputs, isEmpty); + expect(inputsAndOutputs, isEmpty); }, ); @@ -751,21 +646,18 @@ Target 0: (Runner) stopped. .childDirectory('Symbols') ..createSync(recursive: true); - const deviceId = '123'; - const appProcessId = 5678; - final platformSelectCompleter = Completer>(); final processAttachCompleter = Completer>(); final setupStopHooksCompleter = Completer>(); final platformStatusCompleter = Completer>(); - final processResumedCompleted = Completer>(); + final processResumedCompleter = Completer>(); final stdoutStream = Stream>.fromFutures([ platformSelectCompleter.future, processAttachCompleter.future, setupStopHooksCompleter.future, platformStatusCompleter.future, - processResumedCompleted.future, + processResumedCompleter.future, ]); final stdinController = StreamController>(); @@ -787,49 +679,37 @@ Target 0: (Runner) stopped. logger: logger, processUtils: processUtils, xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + deviceVersion: Version(16, 0, 0), ); - final platformSelectMatcher = 'platform select remote-ios --sysroot "${symbols.path}"'; - const processAttachMatcher = 'device process attach --pid $appProcessId'; - const setupStopHooksMatcher = 'target stop-hook add -o "thread backtrace all" -o "detach"'; - const processResumedMatcher = 'process continue'; - const platformStatusMatcher = 'platform status'; - final expectedInputs = [ - 'device select $deviceId', - platformSelectMatcher, - processAttachMatcher, - setupStopHooksMatcher, - platformStatusMatcher, - processResumedMatcher, - ]; + final Map> completer, String out})?> + inputsAndOutputs = buildAttachInputsAndOutputs( + breakPointMatcher: + r"breakpoint set --auto-continue true --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'", + processResumingOutput: 'Process $_appProcessId resuming\n', + breakPointCompleter: null, + processAttachCompleter: processAttachCompleter, + setupStopHooksCompleter: setupStopHooksCompleter, + platformStatusCompleter: platformStatusCompleter, + processResumedCompleter: processResumedCompleter, + ); + inputsAndOutputs.addAll({'platform select remote-ios --sysroot "${symbols.path}"': null}); stdinController.stream .transform(utf8.decoder) .transform(const LineSplitter()) .listen((String line) { - expectedInputs.remove(line); - if (line == platformSelectMatcher) { - platformSelectCompleter.complete(utf8.encode('\n')); - } - if (line == processAttachMatcher) { - processAttachCompleter.complete( - utf8.encode('Process 568 stopped\nTarget 0: (Runner) stopped.\n'), - ); - } - if (line == setupStopHooksMatcher) { - setupStopHooksCompleter.complete(utf8.encode('Stop hook #1 added.\n')); - } - if (line == platformStatusMatcher) { - platformStatusCompleter.complete(utf8.encode(' Platform: remote-ios\n')); - } - if (line == processResumedMatcher) { - processResumedCompleted.complete(utf8.encode('Process 568 resuming\n')); + final ({Completer> completer, String out})? x = inputsAndOutputs.remove( + line, + ); + if (x != null) { + x.completer.complete(utf8.encode(x.out)); } }); final bool success = await lldb.attachAndStart( - deviceId: deviceId, - appProcessId: appProcessId, + deviceId: _deviceId, + appProcessId: _appProcessId, lldbLogForwarder: FakeLLDBLogForwarder(), mode: BuildMode.profile, deviceSupport: createDeviceSupport( @@ -841,7 +721,7 @@ Target 0: (Runner) stopped. ); expect(success, isTrue); - expect(expectedInputs, isEmpty); + expect(inputsAndOutputs, isEmpty); }, ); }); @@ -880,6 +760,145 @@ Target 0: (Runner) stopped. lldbLogForwarder.addLog('hello world'); }); }); + + testWithoutContext('Stops are handled manually for iOS 27+', () async { + final breakPointCompleter = Completer>(); + final processAttachCompleter = Completer>(); + final platformStatusCompleter = Completer>(); + final processResumedCompleter = Completer>(); + final breakpointStopCompleter = Completer>(); + final breakpointContinueCompleter = Completer>(); + final normalLogCompleter = Completer>(); + final crashCompleter = Completer>(); + final backtraceCompleter = Completer(); + final detachCompleter = Completer(); + + var isAttached = false; + + final stdoutStream = Stream>.fromFutures([ + breakPointCompleter.future, + processAttachCompleter.future, + platformStatusCompleter.future, + processResumedCompleter.future.whenComplete(() => isAttached = true), + breakpointStopCompleter.future, + breakpointContinueCompleter.future, + normalLogCompleter.future, + crashCompleter.future, + ]); + + final stdinController = StreamController>(); + + final processCompleter = Completer(); + final lldbCommand = FakeLLDBCommand( + command: const ['xcrun', 'lldb'], + completer: processCompleter, + stdin: io.IOSink(stdinController.sink), + stdout: stdoutStream, + stderr: const Stream.empty(), + ); + + final logger = BufferLogger.test(); + + final processManager = FakeLLDBProcessManager([lldbCommand]); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final lldb = LLDB( + logger: logger, + processUtils: processUtils, + xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + deviceVersion: Version(27, 0, 0), + ); + + const breakPointMatcher = r"breakpoint set --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'"; + final unexpectedInputs = ['Stop hook #1 added.\n']; + final Map> completer, String out})?> inputsAndOutputs = + buildAttachInputsAndOutputs( + breakPointMatcher: breakPointMatcher, + processResumingOutput: '1 location added to breakpoint 1\n', + breakPointCompleter: breakPointCompleter, + processAttachCompleter: processAttachCompleter, + platformStatusCompleter: platformStatusCompleter, + processResumedCompleter: processResumedCompleter, + setupStopHooksCompleter: null, + ); + + stdinController.stream.transform(utf8.decoder).transform(const LineSplitter()).listen(( + String line, + ) { + final ({Completer> completer, String out})? x = inputsAndOutputs.remove(line); + if (x != null) { + x.completer.complete(utf8.encode(x.out)); + } + expect(unexpectedInputs.contains(line), isFalse); + if (isAttached && line == 'process continue') { + breakpointContinueCompleter.complete(utf8.encode('Process $_appProcessId resuming\n')); + } + if (line == 'thread backtrace all') { + backtraceCompleter.complete(); + } + if (line == 'detach') { + detachCompleter.complete(); + } + }); + + final lldbLogForwarder = FakeLLDBLogForwarder(); + final bool success = await lldb.attachAndStart( + deviceId: _deviceId, + appProcessId: _appProcessId, + lldbLogForwarder: lldbLogForwarder, + mode: BuildMode.debug, + deviceSupport: createDeviceSupport(), + ); + expect(success, isTrue); + expect(inputsAndOutputs, isEmpty); + + // Simulate a breakpoint stop after attached. + breakpointStopCompleter.complete( + utf8.encode(''' +Process $_appProcessId stopped +* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1 + frame #0: 0x0000000107996d18 Flutter`NOTIFY_DEBUGGER_ABOUT_RX_PAGES +Flutter`NOTIFY_DEBUGGER_ABOUT_RX_PAGES: +-> 0x107996d18 <+0>: ret + +Flutter`dart::VirtualMemory::AllocateAligned: + 0x107996d1c <+0>: stp x28, x27, [sp, #-0x50]! + 0x107996d20 <+4>: stp x24, x23, [sp, #0x10] + 0x107996d24 <+8>: stp x22, x21, [sp, #0x20] +Target 0: (Flutter Gallery) stopped. +'''), + ); + await breakpointContinueCompleter.future; + + // Verify Breakpoint stop logs should not be printed. + expect(lldbLogForwarder.logs, isEmpty); + expect(logger.traceText, isNot(contains('NOTIFY_DEBUGGER_ABOUT_RX_PAGES'))); + + // Verify normal logs are printed + normalLogCompleter.complete(utf8.encode('Hello World\n')); + await pumpEventQueue(); + expect(lldbLogForwarder.logs, contains('Hello World')); + + // Simulate a crash stop while attached. + crashCompleter.complete( + utf8.encode(''' +Process $_appProcessId stopped +* thread #1, stop reason = EXC_BAD_ACCESS (code=1, address=0x0) + frame #0: 0x0000000102c7b240 my_crashed_code +Target 0: (Runner) stopped. +'''), + ); + + await backtraceCompleter.future; + await detachCompleter.future; + + // Verify crash logs are printed + expect(lldbLogForwarder.logs, contains('Process $_appProcessId stopped')); + expect( + lldbLogForwarder.logs, + contains('* thread #1, stop reason = EXC_BAD_ACCESS (code=1, address=0x0)'), + ); + expect(lldbLogForwarder.logs, contains('Target 0: (Runner) stopped.')); + }); } class FakeLLDBProcessManager extends Fake implements ProcessManager { @@ -1099,6 +1118,52 @@ IOSDeviceSupport createDeviceSupport({ modelCode: modelCode, operatingSystemVersion: operatingSystemVersion, cpuArchitectureString: cpuArchitectureString, - deviceId: deviceId, + deviceId: _deviceId, ); } + +/// Builds a map of expected stdin command inputs sent to LLDB during [LLDB.attachAndStart] +/// and their corresponding simulated stdout outputs and completers. +Map> completer, String out})?> buildAttachInputsAndOutputs({ + required String breakPointMatcher, + required String processResumingOutput, + required Completer>? breakPointCompleter, + required Completer> processAttachCompleter, + required Completer> platformStatusCompleter, + required Completer> processResumedCompleter, + required Completer>? setupStopHooksCompleter, +}) { + const processAttachMatcher = 'device process attach --pid $_appProcessId'; + const processContinueMatcher = 'process continue'; + const setupStopHooksMatcher = 'target stop-hook add -o "thread backtrace all" -o "detach"'; + const platformStatusMatcher = 'platform status'; + return { + 'device select $_deviceId': null, + if (breakPointCompleter != null) ...{ + breakPointMatcher: ( + out: 'Breakpoint $_breakpointId: no locations (pending).\n', + completer: breakPointCompleter, + ), + 'breakpoint command add --script-type python $_breakpointId': null, + 'script lldb.debugger.SetAsync(False)': null, + }, + processAttachMatcher: ( + out: ''' +Process 568 stopped +* thread #1, stop reason = signal SIGSTOP + frame #0: 0x0000000102c7b240 dyld`_dyld_start +dyld`_dyld_start: +-> 0x102c7b240 <+0>: mov x0, sp + 0x102c7b244 <+4>: and sp, x0, #0xfffffffffffffff0 + 0x102c7b248 <+8>: mov x29, #0x0 ; =0 + 0x102c7b24c <+12>: mov x30, #0x0 ; =0 +Target 0: (Runner) stopped. +''', + completer: processAttachCompleter, + ), + if (setupStopHooksCompleter != null) + setupStopHooksMatcher: (out: 'Stop hook #1 added.\n', completer: setupStopHooksCompleter), + platformStatusMatcher: (out: ' Platform: remote-ios\n', completer: platformStatusCompleter), + processContinueMatcher: (out: processResumingOutput, completer: processResumedCompleter), + }; +} From 9ad1211bcd16b21b0e793dd5a47c353c30e54f65 Mon Sep 17 00:00:00 2001 From: reidbaker-agent <269567208+reidbaker-agent@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:51:59 -0400 Subject: [PATCH 46/46] [AGP 9.1.0 Migration #4] Replace the plugin build-type copy with initWith on the public DSL - PluginHandler no longer imports com.android.build.gradle.internal.dsl.BuildType. The build-type copy block that shared live legacy BuildType instances (addAll) for app-type plugin projects and hand-copied two properties for library plugin projects is replaced by a single initWith-based copy on the public DSL containers: missing build types are created on the plugin project with initWith(appBuildType) (which carries matchingFallbacks), and isDebuggable is additionally copied when both sides are application build types. Library build types cannot receive app-specific properties through the public DSL - this is a documented behavior change of the migration. - Production sources are now free of com.android.build.gradle.internal imports; InternalAgpApiImportTest locks that in. - PluginHandlerTest: replaced legacy mock-only copy tests with tests that run configurePlugins and assert the initWith copy for both library and app plugin projects, including the custom-debuggable-build-type -> debug engine artifact mapping and skipping pre-existing build types. - Updated documentation with P3 pre-spike and finalizeDsl fallback details. --- ...Flutter-Gradle-Plugin-to-AGP-public-API.md | 8 + .../src/main/kotlin/plugins/PluginHandler.kt | 59 ++--- .../test/kotlin/InternalAgpApiImportTest.kt | 53 +++++ .../test/kotlin/plugins/PluginHandlerTest.kt | 206 ++++++++---------- .../kotlin/testing/AndroidExtensionMocks.kt | 38 +++- 5 files changed, 213 insertions(+), 151 deletions(-) create mode 100644 packages/flutter_tools/gradle/src/test/kotlin/InternalAgpApiImportTest.kt diff --git a/docs/platforms/android/Migrating-Flutter-Gradle-Plugin-to-AGP-public-API.md b/docs/platforms/android/Migrating-Flutter-Gradle-Plugin-to-AGP-public-API.md index 0b70424d1221a..73fb20507325f 100644 --- a/docs/platforms/android/Migrating-Flutter-Gradle-Plugin-to-AGP-public-API.md +++ b/docs/platforms/android/Migrating-Flutter-Gradle-Plugin-to-AGP-public-API.md @@ -84,6 +84,14 @@ projects. That opt-out dies with AGP 10. signal (the library-plugin build-type copy in `PluginHandler`). This preserves add-to-app custom-debuggable matching (a host `staging` debuggable build type maps to debug engine artifacts). +6. **P3 pre-spike / PR 4 (afterEvaluate DSL mutation under newDsl).** The planned scratch-app + spike (AGP 9.1 + `newDsl=true` + custom build type, verifying that build-type + creation from `pluginProject.afterEvaluate` still works) could not run in the + implementation sandbox (no AGP artifact access). The `initWith` copy landed on the + primary approach; the `android_plugin_example_app_build` integration test and a + custom-build-type scratch build must confirm it in CI. Documented fallback if + `afterEvaluate` mutation is rejected under newDsl: perform the copy in + `androidComponents.finalizeDsl` on the plugin project instead. ## Replacement map diff --git a/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt b/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt index b7390438856ba..649a4d7328168 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt @@ -4,6 +4,7 @@ package com.flutter.gradle.plugins +import com.android.build.api.dsl.ApplicationBuildType import com.flutter.gradle.CompileSdkVersion import com.flutter.gradle.FlutterExtension import com.flutter.gradle.FlutterPluginUtils @@ -11,8 +12,6 @@ import com.flutter.gradle.FlutterPluginUtils.addApiDependencies import com.flutter.gradle.FlutterPluginUtils.buildModeFor import com.flutter.gradle.FlutterPluginUtils.getAndroidExtension import com.flutter.gradle.FlutterPluginUtils.getCompileSdkFromProject -import com.flutter.gradle.FlutterPluginUtils.getLegacyAndroidExtension -import com.flutter.gradle.FlutterPluginUtils.isBuiltAsApp import com.flutter.gradle.FlutterPluginUtils.supportsBuildMode import com.flutter.gradle.NativePluginLoaderReflectionBridge import org.gradle.api.Project @@ -132,12 +131,41 @@ class PluginHandler( ) } + copyAppBuildTypesToPlugin(project, pluginProject) + getAndroidExtension(project).buildTypes.forEach { buildType -> addEmbeddingDependencyToPlugin(project, pluginProject, buildType, engineVersion) } } } + private fun copyAppBuildTypesToPlugin( + project: Project, + pluginProject: Project + ) { + if (!pluginProject.hasProperty("android")) { + return + } + + // Copy the app project's build types onto the plugin project so that its variants + // resolve. These are `initWith` copies, not live aliases: `initWith` copies the + // properties both build types understand (matchingFallbacks included), and + // app-specific properties are additionally copied when both sides are application + // build types. Library build types cannot receive app-specific properties (such + // as isDebuggable) through the public DSL. + val pluginProjectBuildTypes = getAndroidExtension(pluginProject).buildTypes + getAndroidExtension(project).buildTypes.forEach { appBuildType -> + if (pluginProjectBuildTypes.findByName(appBuildType.name) == null) { + pluginProjectBuildTypes.create(appBuildType.name) { + initWith(appBuildType) + if (this is ApplicationBuildType && appBuildType is ApplicationBuildType) { + isDebuggable = appBuildType.isDebuggable + } + } + } + } + } + private fun addEmbeddingDependencyToPlugin( project: Project, pluginProject: Project, @@ -157,33 +185,6 @@ class PluginHandler( return } - // Copy build types from the app to the plugin. - // This allows to build apps with plugins and custom build types or flavors. - // However, only copy if the plugin is also an app project, since library projects - // cannot have applicationIdSuffix and other app-specific properties. - if (isBuiltAsApp(pluginProject)) { - getLegacyAndroidExtension(project).buildTypes.forEach { appBuildType -> - val pluginBuildTypes = getLegacyAndroidExtension(pluginProject).buildTypes - if (pluginBuildTypes.findByName(appBuildType.name) == null) { - pluginBuildTypes.create(appBuildType.name) { - initWith(appBuildType) - } - } - } - } else { - // For library projects, create compatible build types without app-specific properties - getLegacyAndroidExtension(project).buildTypes.forEach { appBuildType -> - if (getLegacyAndroidExtension(pluginProject).buildTypes.findByName(appBuildType.name) == null) { - getLegacyAndroidExtension(pluginProject).buildTypes.create(appBuildType.name) { - // Copy library-compatible properties only - isDebuggable = appBuildType.isDebuggable - isMinifyEnabled = appBuildType.isMinifyEnabled - // Note: applicationIdSuffix and other app-specific properties are intentionally not copied - } - } - } - } - // The embedding is API dependency of the plugin, so the AGP is able to desugar // default method implementations when the interface is implemented by a plugin. // diff --git a/packages/flutter_tools/gradle/src/test/kotlin/InternalAgpApiImportTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/InternalAgpApiImportTest.kt new file mode 100644 index 0000000000000..a5e06a3d224e5 --- /dev/null +++ b/packages/flutter_tools/gradle/src/test/kotlin/InternalAgpApiImportTest.kt @@ -0,0 +1,53 @@ +// Copyright 2014 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. + +package com.flutter.gradle + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Guards the AGP public-API migration (https://github.com/flutter/flutter/issues/180137): + * production sources must not use AGP internals. AGP 10 removes access to internals + * entirely, and the Flutter Gradle Plugin will compile against the `gradle-api` artifact, + * where they do not exist. Test sources may still reference internal types until the + * dependency swap. + */ +class InternalAgpApiImportTest { + @Test + fun `main sources do not import AGP internals`() { + val workingDir = File(".") + val mainSources = + listOf( + File("src/main"), + File("packages/flutter_tools/gradle/src/main") + ).firstOrNull { it.isDirectory } + assertTrue( + mainSources != null, + "Expected to find src/main relative to test working directory (${workingDir.absolutePath})." + ) + val internalApiPattern = Regex("""\bcom\.android\.build\.gradle\.internal\b|\bcom\.android\.builder\.internal\b""") + val offendingLines = + mainSources + .walkTopDown() + .filter { it.isFile && it.extension in setOf("kt", "java", "groovy", "gradle") } + .flatMap { file -> + file.readLines().mapIndexedNotNull { index, line -> + if (internalApiPattern.containsMatchIn(line)) { + "${file.path}:${index + 1}: ${line.trim()}" + } else { + null + } + } + }.toList() + assertTrue( + offendingLines.isEmpty(), + "AGP internal APIs must not be used in production sources; they are removed in " + + "AGP 10. Use the public com.android.build.api surface (see " + + "docs/platforms/android/Migrating-Flutter-Gradle-Plugin-to-AGP-public-API.md).\n" + + offendingLines.joinToString("\n") + ) + } +} diff --git a/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt index aba2117c80f3e..4d50aa1886908 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt @@ -4,9 +4,9 @@ package com.flutter.gradle.plugins -import com.android.build.gradle.BaseExtension +import com.android.build.api.dsl.ApplicationBuildType +import com.android.build.api.dsl.LibraryBuildType import com.flutter.gradle.FlutterExtension -import com.flutter.gradle.FlutterPluginUtils import com.flutter.gradle.FlutterPluginUtilsTest.Companion.EXAMPLE_ENGINE_VERSION import com.flutter.gradle.FlutterPluginUtilsTest.Companion.cameraDependency import com.flutter.gradle.FlutterPluginUtilsTest.Companion.flutterPluginAndroidLifecycleDependency @@ -14,6 +14,7 @@ import com.flutter.gradle.FlutterPluginUtilsTest.Companion.pluginListWithDevDepe import com.flutter.gradle.FlutterPluginUtilsTest.Companion.pluginListWithoutDevDependency import com.flutter.gradle.NativePluginLoaderReflectionBridge import com.flutter.gradle.testing.setUpMockAndroidExtension +import com.flutter.gradle.testing.setUpMockLibraryAndroidExtension import io.mockk.called import io.mockk.every import io.mockk.mockk @@ -33,7 +34,6 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue -import com.android.build.gradle.internal.dsl.BuildType as InternalDslBuildType class PluginHandlerTest { // getPluginListWithoutDevDependencies @@ -144,7 +144,7 @@ class PluginHandlerTest { val pluginProject = mockk() val pluginDependencyProject = mockk() val mockBuildType = - mockk { + mockk { every { name } returns "debug" every { isDebuggable } returns true } @@ -158,12 +158,8 @@ class PluginHandlerTest { dependencyProject = pluginDependencyProject ) - val pluginProjectBuildTypes = mockk>(relaxed = true) - val projectBuildTypes = mockk>(relaxed = true) - setupBaseExtensionBuildTypeContainers(project, pluginProject, projectBuildTypes, pluginProjectBuildTypes) - setUpMockAndroidExtension(project, compileSdk = 35, buildTypes = listOf(mockBuildType)) - setUpMockAndroidExtension(pluginProject, compileSdk = 35) + setUpMockLibraryAndroidExtension(pluginProject, compileSdk = 35) val captureActionSlot = slot>() val capturePluginActionSlot = mutableListOf>() @@ -193,7 +189,6 @@ class PluginHandlerTest { } verify { project.dependencies.add("debugApi", pluginProject) } verify { mockLogger wasNot called } - verify(exactly = 0) { pluginProjectBuildTypes.addAll(any()) } verify { pluginProject.dependencies.add("implementation", pluginDependencyProject) } } @@ -204,7 +199,7 @@ class PluginHandlerTest { val project = mockk() val pluginProject = mockk() val mockBuildType = - mockk { + mockk { every { name } returns "debug" every { isDebuggable } returns true } @@ -212,14 +207,8 @@ class PluginHandlerTest { setupMockProjectDir(project, tempDir) setupMockPluginProject(project, pluginProject) - setupBaseExtensionBuildTypeContainers( - project, - pluginProject, - mockk(relaxed = true), - mockk(relaxed = true) - ) setUpMockAndroidExtension(project, compileSdk = 35, buildTypes = listOf(mockBuildType)) - setUpMockAndroidExtension(pluginProject, compileSdk = 35) + setUpMockLibraryAndroidExtension(pluginProject, compileSdk = 35) val pluginWithNullDependencies: MutableMap = cameraDependency.toMutableMap() pluginWithNullDependencies["dependencies"] = null @@ -234,137 +223,138 @@ class PluginHandlerTest { } @Test - fun `configurePlugins mirrors build types using initWith for app plugins`( + fun `configurePlugins copies missing app build types onto library plugin projects using initWith`( @TempDir tempDir: Path ) { val project = mockk() val pluginProject = mockk() - val mockBuildType = - mockk { - every { name } returns "debug" + val appBuildType = + mockk { + every { name } returns "staging" every { isDebuggable } returns true } every { project.logger } returns mockk(relaxed = true) setupMockProjectDir(project, tempDir) - setupMockPluginProject(project, pluginProject, isAppPlugin = true) + setupMockPluginProject(project, pluginProject, isAppPlugin = false) - val pluginProjectBuildTypes = mockk>(relaxed = true) - val projectBuildTypes = mockk>(relaxed = true) - every { projectBuildTypes.iterator() } returns mutableListOf(mockBuildType).iterator() - setupBaseExtensionBuildTypeContainers(project, pluginProject, projectBuildTypes, pluginProjectBuildTypes) + val pluginProjectBuildTypes = mockk>(relaxed = true) + val mockLibraryExtension = setUpMockLibraryAndroidExtension(pluginProject) + every { mockLibraryExtension.buildTypes } returns pluginProjectBuildTypes - setUpMockAndroidExtension(project, compileSdk = 35, buildTypes = listOf(mockBuildType)) - setUpMockAndroidExtension(pluginProject, compileSdk = 35) + setUpMockAndroidExtension(project, compileSdk = 35, buildTypes = listOf(appBuildType)) setupMockPluginLoader(project, listOf(cameraDependency)) - mockkObject(FlutterPluginUtils) - every { FlutterPluginUtils.isBuiltAsApp(pluginProject) } returns true - every { FlutterPluginUtils.getLegacyAndroidExtension(project) } returns - project.extensions.findByType(BaseExtension::class.java)!! - every { FlutterPluginUtils.getLegacyAndroidExtension(pluginProject) } returns - pluginProject.extensions.findByType(BaseExtension::class.java)!! - - val capturePluginActionSlot = mutableListOf>() - - val createdBuildType = mockk(relaxed = true) - every { pluginProjectBuildTypes.findByName("debug") } returns null + every { pluginProjectBuildTypes.findByName("staging") } returns null + val createdBuildType = mockk(relaxed = true) + val createActionSlot = slot>() every { - pluginProjectBuildTypes.create( - "debug", - any>() - ) - } answers { - val action = secondArg>() - action.execute(createdBuildType) - createdBuildType - } + pluginProjectBuildTypes.create("staging", capture(createActionSlot)) + } returns createdBuildType val pluginHandler = PluginHandler(project) pluginHandler.configurePlugins( engineVersionValue = EXAMPLE_ENGINE_VERSION ) + val capturePluginActionSlot = mutableListOf>() verify { pluginProject.afterEvaluate(capture(capturePluginActionSlot)) } capturePluginActionSlot.forEach { it.execute(pluginProject) } - // App plugins mirror project build types using initWith. + createActionSlot.captured.execute(createdBuildType) + verify { createdBuildType.initWith(appBuildType) } + // The custom debuggable build type maps to the debug engine artifacts. verify { - pluginProjectBuildTypes.create( - "debug", - any>() + pluginProject.dependencies.add( + "stagingApi", + "io.flutter:flutter_embedding_debug:$EXAMPLE_ENGINE_VERSION" ) } - verify { createdBuildType.initWith(mockBuildType) } } @Test - fun `configurePlugins creates individual build types for library plugins`( + fun `configurePlugins skips creating build types that already exist on the plugin project`( @TempDir tempDir: Path ) { val project = mockk() val pluginProject = mockk() - val mockBuildType = - mockk { - every { name } returns "debug" + val appBuildType = + mockk { + every { name } returns "staging" every { isDebuggable } returns true } + val existingPluginBuildType = + mockk { + every { name } returns "staging" + } every { project.logger } returns mockk(relaxed = true) setupMockProjectDir(project, tempDir) setupMockPluginProject(project, pluginProject, isAppPlugin = false) - val pluginProjectBuildTypes = mockk>(relaxed = true) - val projectBuildTypes = mockk>() - val testBuildType = - mockk { - every { name } returns "debug" + val pluginProjectBuildTypes = mockk>(relaxed = true) + val mockLibraryExtension = setUpMockLibraryAndroidExtension(pluginProject) + every { mockLibraryExtension.buildTypes } returns pluginProjectBuildTypes + + setUpMockAndroidExtension(project, compileSdk = 35, buildTypes = listOf(appBuildType)) + setupMockPluginLoader(project, listOf(cameraDependency)) + + every { pluginProjectBuildTypes.findByName("staging") } returns existingPluginBuildType + + val pluginHandler = PluginHandler(project) + pluginHandler.configurePlugins( + engineVersionValue = EXAMPLE_ENGINE_VERSION + ) + + val capturePluginActionSlot = mutableListOf>() + verify { pluginProject.afterEvaluate(capture(capturePluginActionSlot)) } + capturePluginActionSlot.forEach { it.execute(pluginProject) } + + verify(exactly = 0) { pluginProjectBuildTypes.create(any(), any>()) } + } + + @Test + fun `configurePlugins copies app-specific properties when the plugin project is an app`( + @TempDir tempDir: Path + ) { + val project = mockk() + val pluginProject = mockk() + val appBuildType = + mockk { + every { name } returns "staging" every { isDebuggable } returns true - every { isMinifyEnabled } returns false } - every { projectBuildTypes.iterator() } returns mutableListOf(testBuildType).iterator() + every { project.logger } returns mockk(relaxed = true) - setupBaseExtensionBuildTypeContainers(project, pluginProject, projectBuildTypes, pluginProjectBuildTypes) + setupMockProjectDir(project, tempDir) + setupMockPluginProject(project, pluginProject, isAppPlugin = true) - setUpMockAndroidExtension(project, compileSdk = 35, buildTypes = listOf(mockBuildType)) - setUpMockAndroidExtension(pluginProject, compileSdk = 35) - setupMockPluginLoader(project, listOf(cameraDependency)) + val pluginProjectBuildTypes = mockk>(relaxed = true) + val mockAppExtension = setUpMockAndroidExtension(pluginProject) + every { mockAppExtension.buildTypes } returns pluginProjectBuildTypes - mockkObject(FlutterPluginUtils) - every { FlutterPluginUtils.isBuiltAsApp(pluginProject) } returns false + setUpMockAndroidExtension(project, compileSdk = 35, buildTypes = listOf(appBuildType)) + setupMockPluginLoader(project, listOf(cameraDependency)) - val mockCreatedBuildType = mockk(relaxed = true) - every { pluginProjectBuildTypes.findByName("debug") } returns null + every { pluginProjectBuildTypes.findByName("staging") } returns null + val createdBuildType = mockk(relaxed = true) + val createActionSlot = slot>() every { - pluginProjectBuildTypes.create( - "debug", - any>() - ) - } returns mockCreatedBuildType - - every { FlutterPluginUtils.getLegacyAndroidExtension(project) } returns - project.extensions.findByType(BaseExtension::class.java)!! - every { FlutterPluginUtils.getLegacyAndroidExtension(pluginProject) } returns - pluginProject.extensions.findByType(BaseExtension::class.java)!! - - val capturePluginActionSlot = mutableListOf>() + pluginProjectBuildTypes.create("staging", capture(createActionSlot)) + } returns createdBuildType val pluginHandler = PluginHandler(project) pluginHandler.configurePlugins( engineVersionValue = EXAMPLE_ENGINE_VERSION ) + val capturePluginActionSlot = mutableListOf>() verify { pluginProject.afterEvaluate(capture(capturePluginActionSlot)) } capturePluginActionSlot.forEach { it.execute(pluginProject) } - // For library plugins, individual missing build types must be created explicitly rather than bulk-copied. - verify { - pluginProjectBuildTypes.create( - "debug", - any>() - ) - } - verify(exactly = 0) { pluginProjectBuildTypes.addAll(any()) } + createActionSlot.captured.execute(createdBuildType) + verify { createdBuildType.initWith(appBuildType) } + verify { createdBuildType.isDebuggable = true } } @Test @@ -374,7 +364,7 @@ class PluginHandlerTest { val project = mockk() val pluginProject = mockk() val mockBuildType = - mockk { + mockk { every { name } returns "debug" every { isDebuggable } returns true } @@ -383,14 +373,8 @@ class PluginHandlerTest { setupMockProjectDir(project, tempDir) setupMockPluginProject(project, pluginProject) - setupBaseExtensionBuildTypeContainers( - project, - pluginProject, - mockk(relaxed = true), - mockk(relaxed = true) - ) setUpMockAndroidExtension(project, compileSdk = 34, buildTypes = listOf(mockBuildType)) - setUpMockAndroidExtension(pluginProject, compileSdk = 35) + setUpMockLibraryAndroidExtension(pluginProject, compileSdk = 35) setupMockPluginLoader(project, listOf(cameraDependency)) val capturePluginActionSlot = mutableListOf>() @@ -401,7 +385,7 @@ class PluginHandlerTest { ) verify { pluginProject.afterEvaluate(capture(capturePluginActionSlot)) } - capturePluginActionSlot[0].execute(pluginProject) + capturePluginActionSlot.forEach { it.execute(pluginProject) } verify { mockLogger.quiet( @@ -458,22 +442,4 @@ class PluginHandlerTest { every { pluginProject.dependencies.add(any(), any()) } returns mockk() every { project.dependencies.add(any(), any()) } returns mockk() } - - private fun setupBaseExtensionBuildTypeContainers( - project: Project, - pluginProject: Project, - projectBuildTypes: NamedDomainObjectContainer, - pluginBuildTypes: NamedDomainObjectContainer - ) { - val projectBaseExt = - mockk(relaxed = true) { - every { buildTypes } returns projectBuildTypes - } - val pluginBaseExt = - mockk(relaxed = true) { - every { buildTypes } returns pluginBuildTypes - } - every { project.extensions.findByType(BaseExtension::class.java) } returns projectBaseExt - every { pluginProject.extensions.findByType(BaseExtension::class.java) } returns pluginBaseExt - } } diff --git a/packages/flutter_tools/gradle/src/test/kotlin/testing/AndroidExtensionMocks.kt b/packages/flutter_tools/gradle/src/test/kotlin/testing/AndroidExtensionMocks.kt index c1280a181fb99..fe0b3c6050ef1 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/testing/AndroidExtensionMocks.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/testing/AndroidExtensionMocks.kt @@ -6,6 +6,8 @@ package com.flutter.gradle.testing import com.android.build.api.dsl.ApplicationBuildType import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.dsl.LibraryBuildType +import com.android.build.api.dsl.LibraryExtension import io.mockk.every import io.mockk.mockk import org.gradle.api.NamedDomainObjectContainer @@ -15,7 +17,7 @@ import org.gradle.api.Project * Mocks the Android extension for unit tests reading `compileSdk`, `ndkVersion`, or `buildTypes` via the public DSL. * * Default parameter values (e.g. `compileSdk = 35`, `ndkVersion = "29.0.13846066"`) serve as representative - * test fixtures reflecting modern AGP project configurations to avoid NPEs when tested logic reads project extensions. + * test fixtures reflecting AGP 8.x and 9.x project configurations to avoid NPEs when tested logic reads project extensions. * In actual generated Flutter projects, these values are populated from template properties. * * Note: This helper also stubs `project.gradle.startParameter.taskNames` (empty) and @@ -37,7 +39,7 @@ fun setUpMockAndroidExtension( every { mockAndroidExtension.ndkVersion } returns ndkVersion } - val container = mockk>() + val container = mockk>(relaxed = true) // A fresh iterator per call: the container is iterated by multiple loops. every { container.iterator() } answers { buildTypes.toMutableList().iterator() } every { mockAndroidExtension.buildTypes } returns container @@ -50,3 +52,35 @@ fun setUpMockAndroidExtension( return mockAndroidExtension } + +/** + * Mocks the Library Android extension for unit tests reading library project configurations via the public DSL. + */ +fun setUpMockLibraryAndroidExtension( + project: Project, + compileSdk: Int? = 35, + compileSdkPreview: String? = null, + ndkVersion: String? = "29.0.13846066", + buildTypes: List = emptyList() +): LibraryExtension { + val mockLibraryExtension = mockk() + + every { mockLibraryExtension.compileSdk } returns compileSdk + every { mockLibraryExtension.compileSdkPreview } returns compileSdkPreview + if (ndkVersion != null) { + every { mockLibraryExtension.ndkVersion } returns ndkVersion + } + + val container = mockk>(relaxed = true) + // A fresh iterator per call: the container is iterated by multiple loops. + every { container.iterator() } answers { buildTypes.toMutableList().iterator() } + every { mockLibraryExtension.buildTypes } returns container + + every { project.extensions.findByType(LibraryExtension::class.java) } returns mockLibraryExtension + every { project.extensions.findByName("android") } returns mockLibraryExtension + + every { project.gradle.startParameter.taskNames } returns emptyList() + every { project.gradle.startParameter.isOffline } returns false + + return mockLibraryExtension +}