diff --git a/.ci.yaml b/.ci.yaml index 240be0ff75f6c..bd3dc360cf0ed 100755 --- a/.ci.yaml +++ b/.ci.yaml @@ -129,7 +129,7 @@ platform_properties: ] os: Mac-12 cpu: x86 - device_os: iOS-15.1 + device_os: iOS-15 xcode: 13a233 windows: properties: diff --git a/.cirrus.yml b/.cirrus.yml index 58cac0a0970fb..35599b7d85d73 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -12,7 +12,7 @@ environment: # dependency on precisely how Cirrus is detected by our tools. BOT: "true" -gcp_credentials: ENCRYPTED[!2c88dee9c9d9805b214c9f7ad8f3bc8fae936cdb0f881d562101151c408c7e024a41222677d5831df90c60d2dd6cd80a!] +gcp_credentials: ENCRYPTED[!ebad0a1f4f7a446b77944c33651460a7ab010b4617273cb016cf354eb8fc22aa92e37a3c58bfa4a0c40a799351e027a6!] # LINUX SHARDS task: diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 12ae6dfd420da..97b924e2daa08 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -d1b9a6938ad77326ac3a94d92bbc77933ed829ed +ffe7b86a1e5b5cb63c8385ae1adc759e372ee8f4 diff --git a/packages/flutter/lib/src/material/ink_sparkle.dart b/packages/flutter/lib/src/material/ink_sparkle.dart index b44b710626745..2a8225ef27424 100644 --- a/packages/flutter/lib/src/material/ink_sparkle.dart +++ b/packages/flutter/lib/src/material/ink_sparkle.dart @@ -134,7 +134,9 @@ class InkSparkle extends InteractiveInkFeature { _animationController = AnimationController( duration: _animationDuration, vsync: controller.vsync, - )..addListener(controller.markNeedsPaint)..forward(); + )..addListener(controller.markNeedsPaint) + ..addStatusListener(_handleStatusChanged) + ..forward(); _radiusScale = TweenSequence( >[ @@ -208,6 +210,11 @@ class InkSparkle extends InteractiveInkFeature { _turbulenceSeed = turbulenceSeed ?? math.Random().nextDouble() * 1000.0; } + void _handleStatusChanged(AnimationStatus status) { + if (status == AnimationStatus.completed) + dispose(); + } + static const Duration _animationDuration = Duration(milliseconds: 617); static const double _targetRadiusMultiplier = 2.3; static const double _rotateRight = math.pi * 0.0078125; @@ -258,6 +265,8 @@ class InkSparkle extends InteractiveInkFeature { @override void paintFeature(Canvas canvas, Matrix4 transform) { + assert(_animationController.isAnimating); + // InkSparkle can only paint if its shader has been compiled. if (_InkSparkleFactory._shaderManager == null) { // Skipping paintFeature because the shader it relies on is not ready to diff --git a/packages/flutter/lib/src/material/material.dart b/packages/flutter/lib/src/material/material.dart index 1e0c1dcd235dd..b386671a480bf 100644 --- a/packages/flutter/lib/src/material/material.dart +++ b/packages/flutter/lib/src/material/material.dart @@ -558,6 +558,12 @@ class _RenderInkFeatures extends RenderProxyBox implements MaterialInkController bool absorbHitTest; + @visibleForTesting + List? get debugInkFeatures { + if (kDebugMode) + return _inkFeatures; + return null; + } List? _inkFeatures; @override diff --git a/packages/flutter/lib/src/material/slider.dart b/packages/flutter/lib/src/material/slider.dart index 39f75a513639b..898d602ff3636 100644 --- a/packages/flutter/lib/src/material/slider.dart +++ b/packages/flutter/lib/src/material/slider.dart @@ -863,6 +863,7 @@ class _SliderRenderObjectWidget extends LeafRenderObjectWidget { platform: Theme.of(context).platform, hasFocus: hasFocus, hovering: hovering, + gestureSettings: MediaQuery.of(context).gestureSettings, ); } @@ -884,7 +885,8 @@ class _SliderRenderObjectWidget extends LeafRenderObjectWidget { ..semanticFormatterCallback = semanticFormatterCallback ..platform = Theme.of(context).platform ..hasFocus = hasFocus - ..hovering = hovering; + ..hovering = hovering + ..gestureSettings = MediaQuery.of(context).gestureSettings; // Ticker provider cannot change since there's a 1:1 relationship between // the _SliderRenderObjectWidget object and the _SliderState object. } @@ -907,6 +909,7 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin { required TextDirection textDirection, required bool hasFocus, required bool hovering, + required DeviceGestureSettings gestureSettings, }) : assert(value != null && value >= 0.0 && value <= 1.0), assert(state != null), assert(textDirection != null), @@ -930,11 +933,13 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin { ..onStart = _handleDragStart ..onUpdate = _handleDragUpdate ..onEnd = _handleDragEnd - ..onCancel = _endInteraction; + ..onCancel = _endInteraction + ..gestureSettings = gestureSettings; _tap = TapGestureRecognizer() ..team = team ..onTapDown = _handleTapDown - ..onTapUp = _handleTapUp; + ..onTapUp = _handleTapUp + ..gestureSettings = gestureSettings; _overlayAnimation = CurvedAnimation( parent: _state.overlayController, curve: Curves.fastOutSlowIn, @@ -1019,6 +1024,12 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin { markNeedsSemanticsUpdate(); } + DeviceGestureSettings? get gestureSettings => _drag.gestureSettings; + set gestureSettings(DeviceGestureSettings? gestureSettings) { + _drag.gestureSettings = gestureSettings; + _tap.gestureSettings = gestureSettings; + } + TargetPlatform _platform; TargetPlatform get platform => _platform; set platform(TargetPlatform value) { diff --git a/packages/flutter/lib/src/widgets/reorderable_list.dart b/packages/flutter/lib/src/widgets/reorderable_list.dart index 9af083cacd880..ebebca1c38e92 100644 --- a/packages/flutter/lib/src/widgets/reorderable_list.dart +++ b/packages/flutter/lib/src/widgets/reorderable_list.dart @@ -1272,11 +1272,13 @@ class ReorderableDragStartListener extends StatelessWidget { } void _startDragging(BuildContext context, PointerDownEvent event) { + final DeviceGestureSettings? gestureSettings = MediaQuery.maybeOf(context)?.gestureSettings; final SliverReorderableListState? list = SliverReorderableList.maybeOf(context); list?.startItemDragReorder( index: index, event: event, - recognizer: createRecognizer(), + recognizer: createRecognizer() + ..gestureSettings = gestureSettings, ); } } diff --git a/packages/flutter/test/material/ink_sparkle_test.dart b/packages/flutter/test/material/ink_sparkle_test.dart index ff7c88018e0f6..5d71e9b42f501 100644 --- a/packages/flutter/test/material/ink_sparkle_test.dart +++ b/packages/flutter/test/material/ink_sparkle_test.dart @@ -48,16 +48,23 @@ void main() { final Finder buttonFinder = find.text('Sparkle!'); await tester.tap(buttonFinder); await tester.pump(); - await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 200)); final MaterialInkController material = Material.of(tester.element(buttonFinder))!; - await tester.pump(const Duration(milliseconds: 200)); expect(material, paintsExactlyCountTimes(#drawRect, 1)); + + // ignore: avoid_dynamic_calls + expect((material as dynamic).debugInkFeatures, hasLength(1)); + + await tester.pumpAndSettle(); + // ink feature is disposed. + // ignore: avoid_dynamic_calls + expect((material as dynamic).debugInkFeatures, isEmpty); }, skip: kIsWeb, // [intended] SPIR-V shaders are not yet supported for web. ); - testWidgets('InkSparkle default splashFactory paints with drawPaint when unbounded', (WidgetTester tester) async { + testWidgets('InkSparkle default splashFactory paints with drawPaint when unbounded', (WidgetTester tester) async { await tester.pumpWidget(MaterialApp( home: Scaffold( body: Center( @@ -72,10 +79,9 @@ void main() { final Finder buttonFinder = find.text('Sparkle!'); await tester.tap(buttonFinder); await tester.pump(); - await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 200)); final MaterialInkController material = Material.of(tester.element(buttonFinder))!; - await tester.pump(const Duration(milliseconds: 200)); expect(material, paintsExactlyCountTimes(#drawPaint, 1)); }, skip: kIsWeb, // [intended] SPIR-V shaders are not yet supported for web. diff --git a/packages/flutter/test/material/slider_test.dart b/packages/flutter/test/material/slider_test.dart index 354ddde960265..363817fae5a91 100644 --- a/packages/flutter/test/material/slider_test.dart +++ b/packages/flutter/test/material/slider_test.dart @@ -6,6 +6,7 @@ import 'dart:ui'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; @@ -2841,4 +2842,111 @@ void main() { paints..rrect()..rrect()..rrect()..rrect()..rrect()..rrect(color: color), ); }); + + // Regression test for https://github.com/flutter/flutter/issues/103566 + testWidgets('Drag gesture uses provided gesture settings', (WidgetTester tester) async { + double value = 0.5; + bool dragStarted = false; + final Key sliderKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: GestureDetector( + behavior: HitTestBehavior.deferToChild, + onHorizontalDragStart: (DragStartDetails details) { + dragStarted = true; + }, + child: MediaQuery( + data: MediaQuery.of(context).copyWith(gestureSettings: const DeviceGestureSettings(touchSlop: 20)), + child: Slider( + value: value, + key: sliderKey, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + + TestGesture drag = await tester.startGesture(tester.getCenter(find.byKey(sliderKey))); + await tester.pump(kPressTimeout); + + // Less than configured touch slop, more than default touch slop + await drag.moveBy(const Offset(19.0, 0)); + await tester.pump(); + + expect(value, 0.5); + expect(dragStarted, true); + + dragStarted = false; + + await drag.up(); + await tester.pumpAndSettle(); + + drag = await tester.startGesture(tester.getCenter(find.byKey(sliderKey))); + await tester.pump(kPressTimeout); + + bool sliderEnd = false; + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: GestureDetector( + behavior: HitTestBehavior.deferToChild, + onHorizontalDragStart: (DragStartDetails details) { + dragStarted = true; + }, + child: MediaQuery( + data: MediaQuery.of(context).copyWith(gestureSettings: const DeviceGestureSettings(touchSlop: 10)), + child: Slider( + value: value, + key: sliderKey, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + onChangeEnd: (double endValue) { + sliderEnd = true; + }, + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + + // More than touch slop. + await drag.moveBy(const Offset(12.0, 0)); + + await drag.up(); + await tester.pumpAndSettle(); + + expect(sliderEnd, true); + expect(dragStarted, false); + }); } diff --git a/packages/flutter/test/widgets/reorderable_list_test.dart b/packages/flutter/test/widgets/reorderable_list_test.dart index e6a07f136fa9c..b3bf2a4fdb556 100644 --- a/packages/flutter/test/widgets/reorderable_list_test.dart +++ b/packages/flutter/test/widgets/reorderable_list_test.dart @@ -7,6 +7,63 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + testWidgets('SliverReorderableList works well when having gestureSettings', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/103404 + const int itemCount = 5; + int onReorderCallCount = 0; + final List items = List.generate(itemCount, (int index) => index); + + void handleReorder(int fromIndex, int toIndex) { + onReorderCallCount += 1; + if (toIndex > fromIndex) { + toIndex -= 1; + } + items.insert(toIndex, items.removeAt(fromIndex)); + } + // The list has five elements of height 100 + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(gestureSettings: DeviceGestureSettings(touchSlop: 8.0)), + child: CustomScrollView( + slivers: [ + SliverReorderableList( + itemCount: itemCount, + itemBuilder: (BuildContext context, int index) { + return SizedBox( + key: ValueKey(items[index]), + height: 100, + child: ReorderableDragStartListener( + index: index, + child: Text('item ${items[index]}'), + ), + ); + }, + onReorder: handleReorder, + ) + ], + ), + ), + ), + ); + + // Start gesture on first item + final TestGesture drag = await tester.startGesture(tester.getCenter(find.text('item 0'))); + await tester.pump(kPressTimeout); + + // Drag a little bit to make `ImmediateMultiDragGestureRecognizer` compete with `VerticalDragGestureRecognizer` + await drag.moveBy(const Offset(0, 10)); + await tester.pump(); + // Drag enough to move down the first item + await drag.moveBy(const Offset(0, 40)); + await tester.pump(); + await drag.up(); + await tester.pumpAndSettle(); + + expect(onReorderCallCount, 1); + expect(items, orderedEquals([1, 0, 2, 3, 4])); + }); + // Regression test for https://github.com/flutter/flutter/issues/100451 testWidgets('SliverReorderableList.builder respects findChildIndexCallback', (WidgetTester tester) async { bool finderCalled = false; diff --git a/packages/flutter_tools/lib/src/convert.dart b/packages/flutter_tools/lib/src/convert.dart index 70ac4ce227dd1..caec2776aa1f9 100644 --- a/packages/flutter_tools/lib/src/convert.dart +++ b/packages/flutter_tools/lib/src/convert.dart @@ -26,10 +26,14 @@ const Encoding utf8ForTesting = cnv.utf8; /// that aren't UTF-8 and we're not quite sure how this is happening. /// This tells people to report a bug when they see this. class Utf8Codec extends Encoding { - const Utf8Codec(); + const Utf8Codec({this.reportErrors = true}); + + final bool reportErrors; @override - Converter, String> get decoder => const Utf8Decoder(); + Converter, String> get decoder => reportErrors + ? const Utf8Decoder() + : const Utf8Decoder(reportErrors: false); @override Converter> get encoder => cnv.utf8.encoder; diff --git a/packages/flutter_tools/lib/src/web/flutter_js.dart b/packages/flutter_tools/lib/src/web/flutter_js.dart index af8950b1bcf19..6a5f761087e88 100644 --- a/packages/flutter_tools/lib/src/web/flutter_js.dart +++ b/packages/flutter_tools/lib/src/web/flutter_js.dart @@ -27,14 +27,23 @@ _flutter.loader = null; (function() { "use strict"; class FlutterLoader { - // TODO: Move the below methods to "#private" once supported by all the browsers - // we support. In the meantime, we use the "revealing module" pattern. + /** + * Creates a FlutterLoader, and initializes its instance methods. + */ + constructor() { + // TODO: Move the below methods to "#private" once supported by all the browsers + // we support. In the meantime, we use the "revealing module" pattern. - // Watchdog to prevent injecting the main entrypoint multiple times. - _scriptLoaded = null; + // Watchdog to prevent injecting the main entrypoint multiple times. + this._scriptLoaded = null; - // Resolver for the pending promise returned by loadEntrypoint. - _didCreateEngineInitializerResolve = null; + // Resolver for the pending promise returned by loadEntrypoint. + this._didCreateEngineInitializerResolve = null; + + // Called by Flutter web. + // Bound to `this` now, so "this" is preserved across JS <-> Flutter jumps. + this.didCreateEngineInitializer = this._didCreateEngineInitializer.bind(this); + } /** * Initializes the main.dart.js with/without serviceWorker. @@ -51,19 +60,19 @@ _flutter.loader = null; } /** - * Resolves the promise created by loadEntrypoint. Called by Flutter. - * Needs to be weirdly bound like it is, so "this" is preserved across - * the JS <-> Flutter jumps. + * Resolves the promise created by loadEntrypoint. + * Called by Flutter through the public `didCreateEngineInitializer` method, + * which is bound to the correct instance of the FlutterLoader on the page. * @param {*} engineInitializer */ - didCreateEngineInitializer = (function(engineInitializer) { + _didCreateEngineInitializer(engineInitializer) { if (typeof this._didCreateEngineInitializerResolve != "function") { console.warn("Do not call didCreateEngineInitializer by hand. Start with loadEntrypoint instead."); } this._didCreateEngineInitializerResolve(engineInitializer); - // Remove this method after it's done, so Flutter Web can hot restart. + // Remove the public method after it's done, so Flutter Web can hot restart. delete this.didCreateEngineInitializer; - }).bind(this); + } _loadEntrypoint(entrypointUrl) { if (!this._scriptLoaded) { @@ -71,7 +80,11 @@ _flutter.loader = null; let scriptTag = document.createElement("script"); scriptTag.src = entrypointUrl; scriptTag.type = "application/javascript"; - this._didCreateEngineInitializerResolve = resolve; // Cache the resolve, so it can be called from Flutter. + // Cache the resolve, so it can be called from Flutter. + // Note: Flutter hot restart doesn't re-create this promise, so this + // can only be called once. Instead, we need to model this as a stream + // of `engineCreated` events coming from Flutter that are handled by JS. + this._didCreateEngineInitializerResolve = resolve; scriptTag.addEventListener("error", reject); document.body.append(scriptTag); }); diff --git a/packages/flutter_tools/lib/src/windows/visual_studio.dart b/packages/flutter_tools/lib/src/windows/visual_studio.dart index 5c0c928786b36..e271f12c25f5d 100644 --- a/packages/flutter_tools/lib/src/windows/visual_studio.dart +++ b/packages/flutter_tools/lib/src/windows/visual_studio.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:meta/meta.dart'; import 'package:process/process.dart'; import '../base/common.dart'; @@ -33,7 +34,7 @@ class VisualStudio { /// Versions older than 2017 Update 2 won't be detected, so error messages to /// users should take into account that [false] may mean that the user may /// have an old version rather than no installation at all. - bool get isInstalled => _bestVisualStudioDetails.isNotEmpty; + bool get isInstalled => _bestVisualStudioDetails != null; bool get isAtLeastMinimumVersion { final int? installedMajorVersion = _majorVersion; @@ -42,30 +43,27 @@ class VisualStudio { /// True if there is a version of Visual Studio with all the components /// necessary to build the project. - bool get hasNecessaryComponents => _usableVisualStudioDetails.isNotEmpty; + bool get hasNecessaryComponents => _bestVisualStudioDetails?.isUsable ?? false; /// The name of the Visual Studio install. /// - /// For instance: "Visual Studio Community 2019". - String? get displayName => _bestVisualStudioDetails[_displayNameKey] as String?; + /// For instance: "Visual Studio Community 2019". This should only be used for + /// display purposes. + String? get displayName => _bestVisualStudioDetails?.displayName; /// The user-friendly version number of the Visual Studio install. /// - /// For instance: "15.4.0". - String? get displayVersion { - if (_bestVisualStudioDetails[_catalogKey] == null) { - return null; - } - return (_bestVisualStudioDetails[_catalogKey] as Map)[_catalogDisplayVersionKey] as String?; - } + /// For instance: "15.4.0". This should only be used for display purposes. + /// Logic based off the installation's version should use the `fullVersion`. + String? get displayVersion => _bestVisualStudioDetails?.catalogDisplayVersion; /// The directory where Visual Studio is installed. - String? get installLocation => _bestVisualStudioDetails[_installationPathKey] as String?; + String? get installLocation => _bestVisualStudioDetails?.installationPath; /// The full version of the Visual Studio install. /// /// For instance: "15.4.27004.2002". - String? get fullVersion => _bestVisualStudioDetails[_fullVersionKey] as String?; + String? get fullVersion => _bestVisualStudioDetails?.fullVersion; // Properties that determine the status of the installation. There might be // Visual Studio versions that don't include them, so default to a "valid" value to @@ -75,27 +73,27 @@ class VisualStudio { /// /// False if installation is not found. bool get isComplete { - if (_bestVisualStudioDetails.isEmpty) { + if (_bestVisualStudioDetails == null) { return false; } - return _bestVisualStudioDetails[_isCompleteKey] as bool? ?? true; + return _bestVisualStudioDetails!.isComplete ?? true; } /// True if Visual Studio is launchable. /// /// False if installation is not found. bool get isLaunchable { - if (_bestVisualStudioDetails.isEmpty) { + if (_bestVisualStudioDetails == null) { return false; } - return _bestVisualStudioDetails[_isLaunchableKey] as bool? ?? true; + return _bestVisualStudioDetails!.isLaunchable ?? true; } - /// True if the Visual Studio installation is as pre-release version. - bool get isPrerelease => _bestVisualStudioDetails[_isPrereleaseKey] as bool? ?? false; + /// True if the Visual Studio installation is a pre-release version. + bool get isPrerelease => _bestVisualStudioDetails?.isPrerelease ?? false; /// True if a reboot is required to complete the Visual Studio installation. - bool get isRebootRequired => _bestVisualStudioDetails[_isRebootRequiredKey] as bool? ?? false; + bool get isRebootRequired => _bestVisualStudioDetails?.isRebootRequired ?? false; /// The name of the recommended Visual Studio installer workload. String get workloadDescription => 'Desktop development with C++'; @@ -150,12 +148,13 @@ class VisualStudio { /// The path to CMake, or null if no Visual Studio installation has /// the components necessary to build. String? get cmakePath { - final Map details = _usableVisualStudioDetails; - if (details.isEmpty || _usableVisualStudioDetails[_installationPathKey] == null) { + final VswhereDetails? details = _bestVisualStudioDetails; + if (details == null || !details.isUsable || details.installationPath == null) { return null; } + return _fileSystem.path.joinAll([ - _usableVisualStudioDetails[_installationPathKey] as String, + details.installationPath!, 'Common7', 'IDE', 'CommonExtensions', @@ -253,33 +252,6 @@ class VisualStudio { /// vswhere argument to allow prerelease versions. static const String _vswherePrereleaseArgument = '-prerelease'; - // Keys in a VS details dictionary returned from vswhere. - - /// The root directory of the Visual Studio installation. - static const String _installationPathKey = 'installationPath'; - - /// The user-friendly name of the installation. - static const String _displayNameKey = 'displayName'; - - /// The complete version. - static const String _fullVersionKey = 'installationVersion'; - - /// Keys for the status of the installation. - static const String _isCompleteKey = 'isComplete'; - static const String _isLaunchableKey = 'isLaunchable'; - static const String _isRebootRequiredKey = 'isRebootRequired'; - - /// The 'catalog' entry containing more details. - static const String _catalogKey = 'catalog'; - - /// The key for a pre-release version. - static const String _isPrereleaseKey = 'isPrerelease'; - - /// The user-friendly version. - /// - /// This key is under the 'catalog' entry. - static const String _catalogDisplayVersionKey = 'productDisplayVersion'; - /// The registry path for Windows 10 SDK installation details. static const String _windows10SdkRegistryPath = r'HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Microsoft\Microsoft SDKs\Windows\v10.0'; @@ -287,10 +259,11 @@ class VisualStudio { /// SDKs are installed. static const String _windows10SdkRegistryKey = 'InstallationFolder'; - /// Returns the details dictionary for the newest version of Visual Studio. + /// Returns the details of the newest version of Visual Studio. + /// /// If [validateRequirements] is set, the search will be limited to versions /// that have all of the required workloads and components. - Map? _visualStudioDetails({ + VswhereDetails? _visualStudioDetails({ bool validateRequirements = false, List? additionalArguments, String? requiredWorkload @@ -311,17 +284,20 @@ class VisualStudio { '-utf8', '-latest', ]; + // Ignore replacement characters as vswhere.exe is known to output them. + // See: https://github.com/flutter/flutter/issues/102451 + const Encoding encoding = Utf8Codec(reportErrors: false); final RunResult whereResult = _processUtils.runSync([ _vswherePath, ...defaultArguments, ...?additionalArguments, ...requirementArguments, - ], encoding: utf8); + ], encoding: encoding); if (whereResult.exitCode == 0) { final List> installations = (json.decode(whereResult.stdout) as List).cast>(); if (installations.isNotEmpty) { - return installations[0]; + return VswhereDetails.fromJson(validateRequirements, installations[0]); } } } on ArgumentError { @@ -334,90 +310,39 @@ class VisualStudio { return null; } - /// Checks if the given installation has issues that the user must resolve. + /// Returns the details of the best available version of Visual Studio. /// - /// Returns false if the required information is missing since older versions - /// of Visual Studio might not include them. - bool installationHasIssues(MapinstallationDetails) { - assert(installationDetails != null); - if (installationDetails[_isCompleteKey] != null && !(installationDetails[_isCompleteKey] as bool)) { - return true; - } - - if (installationDetails[_isLaunchableKey] != null && !(installationDetails[_isLaunchableKey] as bool)) { - return true; - } - - if (installationDetails[_isRebootRequiredKey] != null && installationDetails[_isRebootRequiredKey] as bool) { - return true; - } - - return false; - } - - /// Returns the details dictionary for the latest version of Visual Studio - /// that has all required components and is a supported version, or {} if - /// there is no such installation. - /// - /// If no installation is found, the cached VS details are set to an empty map - /// to avoid repeating vswhere queries that have already not found an installation. - late final Map _usableVisualStudioDetails = (){ + /// If there's a version that has all the required components, that + /// will be returned, otherwise returns the latest installed version regardless + /// of components and version, or null if no such installation is found. + late final VswhereDetails? _bestVisualStudioDetails = () { + // First, attempt to find the latest version of Visual Studio that satifies + // both the minimum supported version and the required workloads. + // Check in the order of stable VS, stable BT, pre-release VS, pre-release BT. final List minimumVersionArguments = [ _vswhereMinVersionArgument, _minimumSupportedVersion.toString(), ]; - Map? visualStudioDetails; - // Check in the order of stable VS, stable BT, pre-release VS, pre-release BT for (final bool checkForPrerelease in [false, true]) { for (final String requiredWorkload in _requiredWorkloads) { - visualStudioDetails ??= _visualStudioDetails( + final VswhereDetails? result = _visualStudioDetails( validateRequirements: true, additionalArguments: checkForPrerelease ? [...minimumVersionArguments, _vswherePrereleaseArgument] : minimumVersionArguments, requiredWorkload: requiredWorkload); - } - } - Map? usableVisualStudioDetails; - if (visualStudioDetails != null) { - if (installationHasIssues(visualStudioDetails)) { - _cachedAnyVisualStudioDetails = visualStudioDetails; - } else { - usableVisualStudioDetails = visualStudioDetails; + if (result != null) { + return result; + } } } - return usableVisualStudioDetails ?? {}; - }(); - /// Returns the details dictionary of the latest version of Visual Studio, - /// regardless of components and version, or {} if no such installation is - /// found. - /// - /// If no installation is found, the cached VS details are set to an empty map - /// to avoid repeating vswhere queries that have already not found an - /// installation. - Map? _cachedAnyVisualStudioDetails; - Map get _anyVisualStudioDetails { - // Search for all types of installations. - _cachedAnyVisualStudioDetails ??= _visualStudioDetails( + // An installation that satifies requirements could not be found. + // Fallback to the latest Visual Studio installation. + return _visualStudioDetails( additionalArguments: [_vswherePrereleaseArgument, '-all']); - // Add a sentinel empty value to avoid querying vswhere again. - _cachedAnyVisualStudioDetails ??= {}; - return _cachedAnyVisualStudioDetails!; - } - - /// Returns the details dictionary of the best available version of Visual - /// Studio. - /// - /// If there's a version that has all the required components, that - /// will be returned, otherwise returns the latest installed version (if any). - Map get _bestVisualStudioDetails { - if (_usableVisualStudioDetails.isNotEmpty) { - return _usableVisualStudioDetails; - } - return _anyVisualStudioDetails; - } + }(); /// Returns the installation location of the Windows 10 SDKs, or null if the /// registry doesn't contain that information. @@ -471,3 +396,110 @@ class VisualStudio { return highestVersion == null ? null : '10.$highestVersion'; } } + +/// The details of a Visual Studio installation according to vswhere. +@visibleForTesting +class VswhereDetails { + const VswhereDetails({ + required this.meetsRequirements, + required this.installationPath, + required this.displayName, + required this.fullVersion, + required this.isComplete, + required this.isLaunchable, + required this.isRebootRequired, + required this.isPrerelease, + required this.catalogDisplayVersion, + }); + + /// Create a `VswhereDetails` from the JSON output of vswhere.exe. + factory VswhereDetails.fromJson( + bool meetsRequirements, + Map details + ) { + final Map? catalog = details['catalog'] as Map?; + + return VswhereDetails( + meetsRequirements: meetsRequirements, + isComplete: details['isComplete'] as bool?, + isLaunchable: details['isLaunchable'] as bool?, + isRebootRequired: details['isRebootRequired'] as bool?, + isPrerelease: details['isPrerelease'] as bool?, + + // Below are strings that must be well-formed without replacement characters. + installationPath: _validateString(details['installationPath'] as String?), + fullVersion: _validateString(details['installationVersion'] as String?), + + // Below are strings that are used only for display purposes and are allowed to + // contain replacement characters. + displayName: details['displayName'] as String?, + catalogDisplayVersion: catalog == null ? null : catalog['productDisplayVersion'] as String?, + ); + } + + /// Verify JSON strings from vswhere.exe output are valid. + /// + /// The output of vswhere.exe is known to output replacement characters. + /// Use this to ensure values that must be well-formed are valid. Strings that + /// are only used for display purposes should skip this check. + /// See: https://github.com/flutter/flutter/issues/102451 + static String? _validateString(String? value) { + if (value != null && value.contains('\u{FFFD}')) { + throwToolExit( + 'Bad UTF-8 encoding (U+FFFD; REPLACEMENT CHARACTER) found in string: $value. ' + 'The Flutter team would greatly appreciate if you could file a bug explaining ' + 'exactly what you were doing when this happened:\n' + 'https://github.com/flutter/flutter/issues/new/choose\n'); + } + + return value; + } + + /// Whether the installation satisfies the required workloads and minimum version. + final bool meetsRequirements; + + /// The root directory of the Visual Studio installation. + final String? installationPath; + + /// The user-friendly name of the installation. + final String? displayName; + + /// The complete version. + final String? fullVersion; + + /// Keys for the status of the installation. + final bool? isComplete; + final bool? isLaunchable; + final bool? isRebootRequired; + + /// The key for a pre-release version. + final bool? isPrerelease; + + /// The user-friendly version. + final String? catalogDisplayVersion; + + /// Checks if the Visual Studio installation can be used by Flutter. + /// + /// Returns false if the installation has issues the user must resolve. + /// This may return true even if required information is missing as older + /// versions of Visual Studio might not include them. + bool get isUsable { + if (!meetsRequirements) { + return false; + } + + if (!(isComplete ?? true)) { + return false; + } + + if (!(isLaunchable ?? true)) { + return false; + } + + if (isRebootRequired ?? false) { + return false; + } + + return true; + } +} diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_port_forwarder_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_port_forwarder_test.dart index a1c2ac48e5206..f8e1e0525e438 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_port_forwarder_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_port_forwarder_test.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart = 2.8 - import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/ios/devices.dart'; @@ -25,8 +23,8 @@ void main() { // the FakeCommands below expect an exitCode of 0. const FakeCommand( command: ['iproxy', '12345:456', '--udid', '1234'], - stdout: null, // no stdout indicates failure. environment: kDyLdLibEntry, + // Empty stdout indicates failure. ), const FakeCommand( command: ['iproxy', '12346:456', '--udid', '1234'], diff --git a/packages/flutter_tools/test/general.shard/windows/visual_studio_test.dart b/packages/flutter_tools/test/general.shard/windows/visual_studio_test.dart index cb428c69e13ff..f63fce5ddb3a4 100644 --- a/packages/flutter_tools/test/general.shard/windows/visual_studio_test.dart +++ b/packages/flutter_tools/test/general.shard/windows/visual_studio_test.dart @@ -863,6 +863,177 @@ void main() { expect(visualStudio.getWindows10SDKVersion(), null); }); }); + + // The output of vswhere.exe is known to contain bad UTF8. + // See: https://github.com/flutter/flutter/issues/102451 + group('Correctly handles bad UTF-8 from vswhere.exe output', () { + late VisualStudioFixture fixture; + late VisualStudio visualStudio; + + setUp(() { + fixture = setUpVisualStudio(); + visualStudio = fixture.visualStudio; + }); + + testWithoutContext('Ignores unicode replacement char in unused properties', () { + final Map response = Map.of(_defaultResponse) + ..['unused'] = 'Bad UTF8 \u{FFFD}'; + + setMockCompatibleVisualStudioInstallation( + response, + fixture.fileSystem, + fixture.processManager, + ); + + expect(visualStudio.isInstalled, true); + expect(visualStudio.isAtLeastMinimumVersion, true); + expect(visualStudio.hasNecessaryComponents, true); + expect(visualStudio.cmakePath, equals(cmakePath)); + expect(visualStudio.cmakeGenerator, equals('Visual Studio 16 2019')); + }); + + testWithoutContext('Throws ToolExit on bad UTF-8 in installationPath', () { + final Map response = Map.of(_defaultResponse) + ..['installationPath'] = '\u{FFFD}'; + + setMockCompatibleVisualStudioInstallation(response, fixture.fileSystem, fixture.processManager); + + expect(() => visualStudio.isInstalled, + throwsToolExit(message: 'Bad UTF-8 encoding (U+FFFD; REPLACEMENT CHARACTER) found in string')); + }); + + testWithoutContext('Throws ToolExit on bad UTF-8 in installationVersion', () { + final Map response = Map.of(_defaultResponse) + ..['installationVersion'] = '\u{FFFD}'; + + setMockCompatibleVisualStudioInstallation(response, fixture.fileSystem, fixture.processManager); + + expect(() => visualStudio.isInstalled, + throwsToolExit(message: 'Bad UTF-8 encoding (U+FFFD; REPLACEMENT CHARACTER) found in string')); + }); + + testWithoutContext('Ignores bad UTF-8 in displayName', () { + final Map response = Map.of(_defaultResponse) + ..['displayName'] = '\u{FFFD}'; + + setMockCompatibleVisualStudioInstallation(response, fixture.fileSystem, fixture.processManager); + + expect(visualStudio.isInstalled, true); + expect(visualStudio.isAtLeastMinimumVersion, true); + expect(visualStudio.hasNecessaryComponents, true); + expect(visualStudio.cmakePath, equals(cmakePath)); + expect(visualStudio.cmakeGenerator, equals('Visual Studio 16 2019')); + expect(visualStudio.displayName, equals('\u{FFFD}')); + }); + + testWithoutContext("Ignores bad UTF-8 in catalog's productDisplayVersion", () { + final Map catalog = Map.of(_defaultResponse['catalog'] as Map) + ..['productDisplayVersion'] = '\u{FFFD}'; + final Map response = Map.of(_defaultResponse) + ..['catalog'] = catalog; + + setMockCompatibleVisualStudioInstallation(response, fixture.fileSystem, fixture.processManager); + + expect(visualStudio.isInstalled, true); + expect(visualStudio.isAtLeastMinimumVersion, true); + expect(visualStudio.hasNecessaryComponents, true); + expect(visualStudio.cmakePath, equals(cmakePath)); + expect(visualStudio.cmakeGenerator, equals('Visual Studio 16 2019')); + expect(visualStudio.displayVersion, equals('\u{FFFD}')); + }); + }); + + group(VswhereDetails, () { + test('Accepts empty JSON', () { + const bool meetsRequirements = true; + final Map json = {}; + + final VswhereDetails result = VswhereDetails.fromJson(meetsRequirements, json); + + expect(result.installationPath, null); + expect(result.displayName, null); + expect(result.fullVersion, null); + expect(result.isComplete, null); + expect(result.isLaunchable, null); + expect(result.isRebootRequired, null); + expect(result.isPrerelease, null); + expect(result.catalogDisplayVersion, null); + expect(result.isUsable, isTrue); + }); + + test('Ignores unknown JSON properties', () { + const bool meetsRequirements = true; + final Map json = { + 'hello': 'world', + }; + + final VswhereDetails result = VswhereDetails.fromJson(meetsRequirements, json); + + expect(result.installationPath, null); + expect(result.displayName, null); + expect(result.fullVersion, null); + expect(result.isComplete, null); + expect(result.isLaunchable, null); + expect(result.isRebootRequired, null); + expect(result.isPrerelease, null); + expect(result.catalogDisplayVersion, null); + expect(result.isUsable, isTrue); + }); + + test('Accepts JSON', () { + const bool meetsRequirements = true; + + final VswhereDetails result = VswhereDetails.fromJson(meetsRequirements, _defaultResponse); + + expect(result.installationPath, visualStudioPath); + expect(result.displayName, 'Visual Studio Community 2019'); + expect(result.fullVersion, '16.2.29306.81'); + expect(result.isComplete, true); + expect(result.isLaunchable, true); + expect(result.isRebootRequired, false); + expect(result.isPrerelease, false); + expect(result.catalogDisplayVersion, '16.2.5'); + expect(result.isUsable, isTrue); + }); + + test('Installation that does not satisfy requirements is not usable', () { + const bool meetsRequirements = false; + + final VswhereDetails result = VswhereDetails.fromJson(meetsRequirements, _defaultResponse); + + expect(result.isUsable, isFalse); + }); + + test('Incomplete installation is not usable', () { + const bool meetsRequirements = true; + final Map json = Map.of(_defaultResponse) + ..['isComplete'] = false; + + final VswhereDetails result = VswhereDetails.fromJson(meetsRequirements, json); + + expect(result.isUsable, isFalse); + }); + + test('Unlaunchable installation is not usable', () { + const bool meetsRequirements = true; + final Map json = Map.of(_defaultResponse) + ..['isLaunchable'] = false; + + final VswhereDetails result = VswhereDetails.fromJson(meetsRequirements, json); + + expect(result.isUsable, isFalse); + }); + + test('Installation that requires reboot is not usable', () { + const bool meetsRequirements = true; + final Map json = Map.of(_defaultResponse) + ..['isRebootRequired'] = true; + + final VswhereDetails result = VswhereDetails.fromJson(meetsRequirements, json); + + expect(result.isUsable, isFalse); + }); + }); } class VisualStudioFixture { diff --git a/packages/flutter_tools/test/src/fake_process_manager.dart b/packages/flutter_tools/test/src/fake_process_manager.dart index fb2bc7465c794..760249ec5d024 100644 --- a/packages/flutter_tools/test/src/fake_process_manager.dart +++ b/packages/flutter_tools/test/src/fake_process_manager.dart @@ -100,8 +100,8 @@ class FakeCommand { /// If provided, this exception will be thrown when the fake command is run. final Object? exception; - /// Indicates that output will only be emitted after the `exitCode` [Future] - /// on [io.Process] completes. + /// When true, stdout and stderr will only be emitted after the `exitCode` + /// [Future] on [io.Process] completes. final bool outputFollowsExit; void _matches( @@ -141,24 +141,26 @@ class _FakeProcess implements io.Process { }), stdin = stdin ?? IOSink(StreamController>().sink) { - if (_stderr == null) { + if (_stderr.isEmpty) { stderr = const Stream>.empty(); } else if (outputFollowsExit) { + // Wait for the process to exit before emitting stderr. stderr = Stream>.fromFuture(exitCode.then((_) { - return Future>(() => utf8.encode(_stderr)); + return Future>(() => _stderr); })); } else { - stderr = Stream>.value(utf8.encode(_stderr)); + stderr = Stream>.value(_stderr); } - if (_stdout == null) { + if (_stdout.isEmpty) { stdout = const Stream>.empty(); } else if (outputFollowsExit) { + // Wait for the process to exit before emitting stdout. stdout = Stream>.fromFuture(exitCode.then((_) { - return Future>(() => utf8.encode(_stdout)); + return Future>(() => _stdout); })); } else { - stdout = Stream>.value(utf8.encode(_stdout)); + stdout = Stream>.value(_stdout); } } @@ -171,7 +173,7 @@ class _FakeProcess implements io.Process { @override final int pid; - final String _stderr; + final List _stderr; @override late final Stream> stderr; @@ -182,7 +184,7 @@ class _FakeProcess implements io.Process { @override late final Stream> stdout; - final String _stdout; + final List _stdout; @override bool kill([io.ProcessSignal signal = io.ProcessSignal.sigterm]) { @@ -268,9 +270,9 @@ abstract class FakeProcessManager implements ProcessManager { fakeCommand.exitCode, fakeCommand.duration, _pid, - fakeCommand.stderr, + encoding?.encode(fakeCommand.stderr) ?? fakeCommand.stderr.codeUnits, fakeCommand.stdin, - fakeCommand.stdout, + encoding?.encode(fakeCommand.stdout) ?? fakeCommand.stdout.codeUnits, fakeCommand.completer, fakeCommand.outputFollowsExit, ); @@ -310,8 +312,8 @@ abstract class FakeProcessManager implements ProcessManager { return io.ProcessResult( process.pid, process._exitCode, - stdoutEncoding == null ? process.stdout : await stdoutEncoding.decodeStream(process.stdout), - stderrEncoding == null ? process.stderr : await stderrEncoding.decodeStream(process.stderr), + stdoutEncoding == null ? process._stdout : await stdoutEncoding.decodeStream(process.stdout), + stderrEncoding == null ? process._stderr : await stderrEncoding.decodeStream(process.stderr), ); } @@ -322,15 +324,15 @@ abstract class FakeProcessManager implements ProcessManager { Map? environment, bool includeParentEnvironment = true, // ignored bool runInShell = false, // ignored - Encoding? stdoutEncoding = io.systemEncoding, // actual encoder is ignored - Encoding? stderrEncoding = io.systemEncoding, // actual encoder is ignored + Encoding? stdoutEncoding = io.systemEncoding, + Encoding? stderrEncoding = io.systemEncoding, }) { final _FakeProcess process = _runCommand(command.cast(), workingDirectory, environment, stdoutEncoding); return io.ProcessResult( process.pid, process._exitCode, - stdoutEncoding == null ? utf8.encode(process._stdout) : process._stdout, - stderrEncoding == null ? utf8.encode(process._stderr) : process._stderr, + stdoutEncoding == null ? process._stdout : stdoutEncoding.decode(process._stdout), + stderrEncoding == null ? process._stderr : stderrEncoding.decode(process._stderr), ); }