Ignore invalid description property in vswhere.exe JSON output (#106836)

The `flutter doctor` command uses `vswhere.exe` to verify the Visual Studio installation. This `vswhere.exe` is known to encode its output incorrectly. This is problematic as the `description` property is localized, and in certain languages this results in invalid JSON due to the incorrect encoding.

This change introduces a fallback to our `vswhere.exe` output parsing logic: if parsing JSON fails, remove the `description` property and retry parsing the JSON.

This fix was also tested on the outputs provided here: https://github.com/flutter/flutter/issues/106601#issuecomment-1170138123

Addresses https://github.com/flutter/flutter/issues/106601
diff --git a/packages/flutter_tools/lib/src/windows/visual_studio.dart b/packages/flutter_tools/lib/src/windows/visual_studio.dart
index e5bef84..3d9fe48 100644
--- a/packages/flutter_tools/lib/src/windows/visual_studio.dart
+++ b/packages/flutter_tools/lib/src/windows/visual_studio.dart
@@ -23,11 +23,16 @@
     required Logger logger,
   }) : _platform = platform,
        _fileSystem = fileSystem,
-       _processUtils = ProcessUtils(processManager: processManager, logger: logger);
+       _processUtils = ProcessUtils(processManager: processManager, logger: logger),
+       _logger = logger;
 
   final FileSystem _fileSystem;
   final Platform _platform;
   final ProcessUtils _processUtils;
+  final Logger _logger;
+
+  /// Matches the description property from the vswhere.exe JSON output.
+  final RegExp _vswhereDescriptionProperty = RegExp(r'\s*"description"\s*:\s*".*"\s*,?');
 
   /// True if Visual Studio installation was found.
   ///
@@ -294,9 +299,8 @@
         ...requirementArguments,
       ], encoding: encoding);
       if (whereResult.exitCode == 0) {
-        final List<Map<String, dynamic>> installations =
-            (json.decode(whereResult.stdout) as List<dynamic>).cast<Map<String, dynamic>>();
-        if (installations.isNotEmpty) {
+        final List<Map<String, dynamic>>? installations = _tryDecodeVswhereJson(whereResult.stdout);
+        if (installations != null && installations.isNotEmpty) {
           return VswhereDetails.fromJson(validateRequirements, installations[0]);
         }
       }
@@ -304,12 +308,41 @@
       // Thrown if vswhere doesn't exist; ignore and return null below.
     } on ProcessException {
       // Ignored, return null below.
-    } on FormatException {
-      // may be thrown if invalid JSON is returned.
     }
     return null;
   }
 
+  List<Map<String, dynamic>>? _tryDecodeVswhereJson(String vswhereJson) {
+    List<dynamic>? result;
+    FormatException? originalError;
+    try {
+      // Some versions of vswhere.exe are known to encode their output incorrectly,
+      // resulting in invalid JSON in the 'description' property when interpreted
+      // as UTF-8. First, try to decode without any pre-processing.
+      try {
+        result = json.decode(vswhereJson) as List<dynamic>;
+      } on FormatException catch (error) {
+        // If that fails, remove the 'description' property and try again.
+        // See: https://github.com/flutter/flutter/issues/106601
+        vswhereJson = vswhereJson.replaceFirst(_vswhereDescriptionProperty, '');
+
+        _logger.printTrace('Failed to decode vswhere.exe JSON output. $error'
+          'Retrying after removing the unused description property:\n$vswhereJson');
+
+        originalError = error;
+        result = json.decode(vswhereJson) as List<dynamic>;
+      }
+    } on FormatException {
+      // Removing the description property didn't help.
+      // Report the original decoding error on the unprocessed JSON.
+      _logger.printWarning('Warning: Unexpected vswhere.exe JSON output. $originalError'
+        'To see the full JSON, run flutter doctor -vv.');
+      return null;
+    }
+
+    return result.cast<Map<String, dynamic>>();
+  }
+
   /// Returns the details of the best available version of Visual Studio.
   ///
   /// If there's a version that has all the required components, that
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 f63fce5..6f2aa45 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
@@ -92,6 +92,23 @@
   },
 };
 
+const String _malformedDescriptionResponse = r'''
+[
+  {
+    "installationPath": "C:\\Program Files (x86)\\Microsoft Visual Studio\\2017\\Community",
+    "displayName": "Visual Studio Community 2019",
+    "description": "This description has too many "quotes",
+    "installationVersion": "16.2.29306.81",
+    "isRebootRequired": false,
+    "isComplete": true,
+    "isPrerelease": false,
+    "catalog": {
+      "productDisplayVersion": "16.2.5"
+    }
+  }
+]
+''';
+
 // Arguments for a vswhere query to search for an installation with the
 // requirements.
 const List<String> _requirements = <String>[
@@ -124,7 +141,7 @@
   fileSystem.file(vswherePath).createSync(recursive: true);
   fileSystem.file(cmakePath).createSync(recursive: true);
   final String finalResponse = responseOverride
-    ?? (response != null ? json.encode(<Map<String, dynamic>>[response]) : '');
+    ?? (response != null ? json.encode(<Map<String, dynamic>>[response]) : '[]');
   final List<String> requirementArguments = requiredComponents == null
     ? <String>[]
     : <String>['-requires', ...requiredComponents];
@@ -323,7 +340,7 @@
     logger: logger,
     processManager: processManager,
   );
-  return VisualStudioFixture(visualStudio, fileSystem, processManager);
+  return VisualStudioFixture(visualStudio, fileSystem, processManager, logger);
 }
 
 // Set all vswhere query with the required components return null.
@@ -717,7 +734,7 @@
       expect(visualStudio.fullVersion, equals('16.2.29306.81'));
     });
 
-    testWithoutContext('cmakePath returns null when VS is present but when vswhere returns invalid JSON', () {
+    testWithoutContext('Warns and returns no installation when VS is present but vswhere returns invalid JSON', () {
       final VisualStudioFixture fixture = setUpVisualStudio();
       final VisualStudio visualStudio = fixture.visualStudio;
 
@@ -729,7 +746,19 @@
         fixture.processManager,
       );
 
+      expect(visualStudio.isInstalled, isFalse);
+      expect(visualStudio.isComplete, isFalse);
+      expect(visualStudio.isLaunchable, isFalse);
+      expect(visualStudio.isPrerelease, isFalse);
+      expect(visualStudio.isRebootRequired, isFalse);
+      expect(visualStudio.hasNecessaryComponents, isFalse);
+      expect(visualStudio.displayName, isNull);
+      expect(visualStudio.displayVersion, isNull);
+      expect(visualStudio.installLocation, isNull);
+      expect(visualStudio.fullVersion, isNull);
       expect(visualStudio.cmakePath, isNull);
+
+      expect(fixture.logger.warningText, contains('Warning: Unexpected vswhere.exe JSON output'));
     });
 
     testWithoutContext('Everything returns good values when VS is present with all components', () {
@@ -941,6 +970,26 @@
       expect(visualStudio.cmakeGenerator, equals('Visual Studio 16 2019'));
       expect(visualStudio.displayVersion, equals('\u{FFFD}'));
     });
+
+    testWithoutContext('Ignores malformed JSON in description property', () {
+      setMockVswhereResponse(
+        fixture.fileSystem,
+        fixture.processManager,
+        _requirements,
+        <String>['-version', '16'],
+        null,
+        _malformedDescriptionResponse,
+      );
+
+      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('16.2.5'));
+
+      expect(fixture.logger.warningText, isEmpty);
+    });
   });
 
   group(VswhereDetails, () {
@@ -1037,9 +1086,10 @@
 }
 
 class VisualStudioFixture {
-  VisualStudioFixture(this.visualStudio, this.fileSystem, this.processManager);
+  VisualStudioFixture(this.visualStudio, this.fileSystem, this.processManager, this.logger);
 
   final VisualStudio visualStudio;
   final FileSystem fileSystem;
   final FakeProcessManager processManager;
+  final BufferLogger logger;
 }