Support Xcode patch version comparison (#61484)

diff --git a/packages/flutter_tools/lib/src/base/user_messages.dart b/packages/flutter_tools/lib/src/base/user_messages.dart
index b4b7275..8730ebc 100644
--- a/packages/flutter_tools/lib/src/base/user_messages.dart
+++ b/packages/flutter_tools/lib/src/base/user_messages.dart
@@ -138,8 +138,8 @@
 
   // Messages used in XcodeValidator
   String xcodeLocation(String location) => 'Xcode at $location';
-  String xcodeOutdated(int versionMajor, int versionMinor) =>
-      'Flutter requires a minimum Xcode version of $versionMajor.$versionMinor.0.\n'
+  String xcodeOutdated(int versionMajor, int versionMinor, int versionPatch) =>
+      'Flutter requires a minimum Xcode version of $versionMajor.$versionMinor.$versionPatch.\n'
       'Download the latest version or update via the Mac App Store.';
   String get xcodeEula => "Xcode end user license agreement not signed; open Xcode or run the command 'sudo xcodebuild -license'.";
   String get xcodeMissingSimct =>
diff --git a/packages/flutter_tools/lib/src/ios/mac.dart b/packages/flutter_tools/lib/src/ios/mac.dart
index 1223b2e..abaf1d8 100644
--- a/packages/flutter_tools/lib/src/ios/mac.dart
+++ b/packages/flutter_tools/lib/src/ios/mac.dart
@@ -600,7 +600,7 @@
   final Map<String, String> buildSettings;
 }
 
-const String _xcodeRequirement = 'Xcode $kXcodeRequiredVersionMajor.$kXcodeRequiredVersionMinor or greater is required to develop for iOS.';
+const String _xcodeRequirement = 'Xcode $kXcodeRequiredVersionMajor.$kXcodeRequiredVersionMinor.$kXcodeRequiredVersionPatch or greater is required to develop for iOS.';
 
 bool _checkXcodeVersion() {
   if (!globals.platform.isMacOS) {
diff --git a/packages/flutter_tools/lib/src/ios/xcodeproj.dart b/packages/flutter_tools/lib/src/ios/xcodeproj.dart
index 8b09b35..7d0c532 100644
--- a/packages/flutter_tools/lib/src/ios/xcodeproj.dart
+++ b/packages/flutter_tools/lib/src/ios/xcodeproj.dart
@@ -261,13 +261,15 @@
       return;
     }
     try {
-      final RunResult result = _processUtils.runSync(
-        <String>[_executable, '-version'],
-      );
-      if (result.exitCode != 0) {
-        return;
+      if (_versionText == null) {
+        final RunResult result = _processUtils.runSync(
+          <String>[_executable, '-version'],
+        );
+        if (result.exitCode != 0) {
+          return;
+        }
+        _versionText = result.stdout.trim().replaceAll('\n', ', ');
       }
-      _versionText = result.stdout.trim().replaceAll('\n', ', ');
       final Match match = _versionRegex.firstMatch(versionText);
       if (match == null) {
         return;
@@ -275,7 +277,8 @@
       final String version = match.group(1);
       final List<String> components = version.split('.');
       _majorVersion = int.parse(components[0]);
-      _minorVersion = components.length == 1 ? 0 : int.parse(components[1]);
+      _minorVersion = components.length < 2 ? 0 : int.parse(components[1]);
+      _patchVersion = components.length < 3 ? 0 : int.parse(components[2]);
     } on ProcessException {
       // Ignored, leave values null.
     }
@@ -307,6 +310,14 @@
     return _minorVersion;
   }
 
+  int _patchVersion;
+  int get patchVersion {
+    if (_patchVersion == null) {
+      _updateVersion();
+    }
+    return _patchVersion;
+  }
+
   /// Asynchronously retrieve xcode build settings. This one is preferred for
   /// new call-sites.
   ///
diff --git a/packages/flutter_tools/lib/src/macos/xcode.dart b/packages/flutter_tools/lib/src/macos/xcode.dart
index 81a430c..9d93670 100644
--- a/packages/flutter_tools/lib/src/macos/xcode.dart
+++ b/packages/flutter_tools/lib/src/macos/xcode.dart
@@ -27,6 +27,7 @@
 
 const int kXcodeRequiredVersionMajor = 11;
 const int kXcodeRequiredVersionMinor = 0;
+const int kXcodeRequiredVersionPatch = 0;
 
 enum SdkType {
   iPhone,
@@ -97,8 +98,8 @@
   }
 
   int get majorVersion => _xcodeProjectInterpreter.majorVersion;
-
   int get minorVersion => _xcodeProjectInterpreter.minorVersion;
+  int get patchVersion => _xcodeProjectInterpreter.patchVersion;
 
   String get versionText => _xcodeProjectInterpreter.versionText;
 
@@ -151,6 +152,9 @@
       return true;
     }
     if (majorVersion == kXcodeRequiredVersionMajor) {
+      if (minorVersion == kXcodeRequiredVersionMinor) {
+        return patchVersion >= kXcodeRequiredVersionPatch;
+      }
       return minorVersion >= kXcodeRequiredVersionMinor;
     }
     return false;
diff --git a/packages/flutter_tools/lib/src/macos/xcode_validator.dart b/packages/flutter_tools/lib/src/macos/xcode_validator.dart
index 7aeb5ac..72086ad 100644
--- a/packages/flutter_tools/lib/src/macos/xcode_validator.dart
+++ b/packages/flutter_tools/lib/src/macos/xcode_validator.dart
@@ -39,7 +39,7 @@
       if (!_xcode.isInstalledAndMeetsVersionCheck) {
         xcodeStatus = ValidationType.partial;
         messages.add(ValidationMessage.error(
-          _userMessages.xcodeOutdated(kXcodeRequiredVersionMajor, kXcodeRequiredVersionMinor)
+          _userMessages.xcodeOutdated(kXcodeRequiredVersionMajor, kXcodeRequiredVersionMinor, kXcodeRequiredVersionPatch)
         ));
       }
 
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 e64779e..09278a7 100644
--- a/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart
+++ b/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart
@@ -136,49 +136,38 @@
     expect(fakeProcessManager.hasRemainingExpectations, isFalse);
   });
 
-  testWithoutContext('xcodebuild majorVersion returns major version', () {
+  testWithoutContext('xcodebuild version parts can be parsed', () {
     fakeProcessManager.addCommand(const FakeCommand(
       command: <String>[xcodebuild, '-version'],
       stdout: 'Xcode 11.4.1\nBuild version 11N111s',
     ));
 
     expect(xcodeProjectInterpreter.majorVersion, 11);
+    expect(xcodeProjectInterpreter.minorVersion, 4);
+    expect(xcodeProjectInterpreter.patchVersion, 1);
     expect(fakeProcessManager.hasRemainingExpectations, isFalse);
   });
 
-  testWithoutContext('xcodebuild majorVersion is null when version has unexpected format', () {
+  testWithoutContext('xcodebuild minor and patch version default to 0', () {
+    fakeProcessManager.addCommand(const FakeCommand(
+      command: <String>[xcodebuild, '-version'],
+      stdout: 'Xcode 11\nBuild version 11N111s',
+    ));
+
+    expect(xcodeProjectInterpreter.majorVersion, 11);
+    expect(xcodeProjectInterpreter.minorVersion, 0);
+    expect(xcodeProjectInterpreter.patchVersion, 0);
+    expect(fakeProcessManager.hasRemainingExpectations, isFalse);
+  });
+
+  testWithoutContext('xcodebuild version parts is null when version has unexpected format', () {
     fakeProcessManager.addCommand(const FakeCommand(
       command: <String>[xcodebuild, '-version'],
       stdout: 'Xcode Ultra5000\nBuild version 8E3004b',
     ));
     expect(xcodeProjectInterpreter.majorVersion, isNull);
-    expect(fakeProcessManager.hasRemainingExpectations, isFalse);
-  });
-
-  testWithoutContext('xcodebuild minorVersion returns minor version', () {
-    fakeProcessManager.addCommand(const FakeCommand(
-      command: <String>[xcodebuild, '-version'],
-      stdout: 'Xcode 8.3.3\nBuild version 8E3004b',
-    ));
-    expect(xcodeProjectInterpreter.minorVersion, 3);
-    expect(fakeProcessManager.hasRemainingExpectations, isFalse);
-  });
-
-  testWithoutContext('xcodebuild minorVersion returns 0 when minor version is unspecified', () {
-    fakeProcessManager.addCommand(const FakeCommand(
-      command: <String>[xcodebuild, '-version'],
-      stdout: 'Xcode 8\nBuild version 8E3004b',
-    ));
-    expect(xcodeProjectInterpreter.minorVersion, 0);
-    expect(fakeProcessManager.hasRemainingExpectations, isFalse);
-  });
-
-  testWithoutContext('xcodebuild minorVersion is null when version has unexpected format', () {
-    fakeProcessManager.addCommand(const FakeCommand(
-      command: <String>[xcodebuild, '-version'],
-      stdout: 'Xcode Ultra5000\nBuild version 8E3004b',
-    ));
     expect(xcodeProjectInterpreter.minorVersion, isNull);
+    expect(xcodeProjectInterpreter.patchVersion, isNull);
     expect(fakeProcessManager.hasRemainingExpectations, isFalse);
   });
 
diff --git a/packages/flutter_tools/test/general.shard/macos/xcode_test.dart b/packages/flutter_tools/test/general.shard/macos/xcode_test.dart
index 4f9008c..b7ad10c 100644
--- a/packages/flutter_tools/test/general.shard/macos/xcode_test.dart
+++ b/packages/flutter_tools/test/general.shard/macos/xcode_test.dart
@@ -157,6 +157,7 @@
         when(mockXcodeProjectInterpreter.isInstalled).thenReturn(true);
         when(mockXcodeProjectInterpreter.majorVersion).thenReturn(9);
         when(mockXcodeProjectInterpreter.minorVersion).thenReturn(0);
+        when(mockXcodeProjectInterpreter.patchVersion).thenReturn(0);
 
         expect(xcode.isVersionSatisfactory, isFalse);
       });
@@ -171,6 +172,7 @@
         when(mockXcodeProjectInterpreter.isInstalled).thenReturn(true);
         when(mockXcodeProjectInterpreter.majorVersion).thenReturn(11);
         when(mockXcodeProjectInterpreter.minorVersion).thenReturn(0);
+        when(mockXcodeProjectInterpreter.patchVersion).thenReturn(0);
 
         expect(xcode.isVersionSatisfactory, isTrue);
       });
@@ -179,6 +181,7 @@
         when(mockXcodeProjectInterpreter.isInstalled).thenReturn(true);
         when(mockXcodeProjectInterpreter.majorVersion).thenReturn(12);
         when(mockXcodeProjectInterpreter.minorVersion).thenReturn(0);
+        when(mockXcodeProjectInterpreter.patchVersion).thenReturn(0);
 
         expect(xcode.isVersionSatisfactory, isTrue);
       });
@@ -187,6 +190,16 @@
         when(mockXcodeProjectInterpreter.isInstalled).thenReturn(true);
         when(mockXcodeProjectInterpreter.majorVersion).thenReturn(11);
         when(mockXcodeProjectInterpreter.minorVersion).thenReturn(3);
+        when(mockXcodeProjectInterpreter.patchVersion).thenReturn(0);
+
+        expect(xcode.isVersionSatisfactory, isTrue);
+      });
+
+      testWithoutContext('xcodeVersionSatisfactory is true when patch version exceeds minimum', () {
+        when(mockXcodeProjectInterpreter.isInstalled).thenReturn(true);
+        when(mockXcodeProjectInterpreter.majorVersion).thenReturn(11);
+        when(mockXcodeProjectInterpreter.minorVersion).thenReturn(0);
+        when(mockXcodeProjectInterpreter.patchVersion).thenReturn(1);
 
         expect(xcode.isVersionSatisfactory, isTrue);
       });
@@ -219,6 +232,7 @@
         when(mockXcodeProjectInterpreter.isInstalled).thenReturn(true);
         when(mockXcodeProjectInterpreter.majorVersion).thenReturn(11);
         when(mockXcodeProjectInterpreter.minorVersion).thenReturn(0);
+        when(mockXcodeProjectInterpreter.patchVersion).thenReturn(0);
 
         expect(xcode.isInstalledAndMeetsVersionCheck, isFalse);
         expect(fakeProcessManager.hasRemainingExpectations, isFalse);
@@ -233,6 +247,7 @@
         when(mockXcodeProjectInterpreter.isInstalled).thenReturn(true);
         when(mockXcodeProjectInterpreter.majorVersion).thenReturn(10);
         when(mockXcodeProjectInterpreter.minorVersion).thenReturn(2);
+        when(mockXcodeProjectInterpreter.patchVersion).thenReturn(0);
 
         expect(xcode.isInstalledAndMeetsVersionCheck, isFalse);
         expect(fakeProcessManager.hasRemainingExpectations, isFalse);
@@ -247,6 +262,7 @@
         when(mockXcodeProjectInterpreter.isInstalled).thenReturn(true);
         when(mockXcodeProjectInterpreter.majorVersion).thenReturn(11);
         when(mockXcodeProjectInterpreter.minorVersion).thenReturn(0);
+        when(mockXcodeProjectInterpreter.patchVersion).thenReturn(0);
 
         expect(xcode.isInstalledAndMeetsVersionCheck, isTrue);
         expect(fakeProcessManager.hasRemainingExpectations, isFalse);
diff --git a/packages/flutter_tools/test/src/context.dart b/packages/flutter_tools/test/src/context.dart
index efd0385..64cf97f 100644
--- a/packages/flutter_tools/test/src/context.dart
+++ b/packages/flutter_tools/test/src/context.dart
@@ -395,6 +395,9 @@
   int get minorVersion => 0;
 
   @override
+  int get patchVersion => 0;
+
+  @override
   Future<Map<String, String>> getBuildSettings(
     String projectPath, {
     String scheme,