[flutter_tools] delegate first run message re-display to new class, only if changed (#73353)

diff --git a/packages/flutter_tools/lib/src/context_runner.dart b/packages/flutter_tools/lib/src/context_runner.dart
index f220d7b..02ca1a7 100644
--- a/packages/flutter_tools/lib/src/context_runner.dart
+++ b/packages/flutter_tools/lib/src/context_runner.dart
@@ -46,6 +46,7 @@
 import 'macos/xcode.dart';
 import 'mdns_discovery.dart';
 import 'persistent_tool_state.dart';
+import 'reporting/first_run.dart';
 import 'reporting/reporting.dart';
 import 'resident_runner.dart';
 import 'run_hot.dart';
@@ -278,6 +279,7 @@
       SystemClock: () => const SystemClock(),
       Usage: () => Usage(
         runningOnBot: runningOnBot,
+        firstRunMessenger: FirstRunMessenger(persistentToolState: globals.persistentToolState),
       ),
       UserMessages: () => UserMessages(),
       VisualStudioValidator: () => VisualStudioValidator(
diff --git a/packages/flutter_tools/lib/src/persistent_tool_state.dart b/packages/flutter_tools/lib/src/persistent_tool_state.dart
index d596390..2fc1699 100644
--- a/packages/flutter_tools/lib/src/persistent_tool_state.dart
+++ b/packages/flutter_tools/lib/src/persistent_tool_state.dart
@@ -47,6 +47,9 @@
   /// Update the last active version for a given [channel].
   void updateLastActiveVersion(String fullGitHash, Channel channel);
 
+  /// Return the hash of the last active license terms.
+  String lastActiveLicenseTerms;
+
   /// Whether this client was already determined to be or not be a bot.
   bool isRunningOnBot;
 }
@@ -82,6 +85,7 @@
     Channel.stable: 'last-active-stable-version'
   };
   static const String _kBotKey = 'is-bot';
+  static const String _kLicenseHash = 'license-hash';
 
   final Config _config;
 
@@ -109,6 +113,15 @@
     _config.setValue(versionKey, fullGitHash);
   }
 
+  @override
+  String get lastActiveLicenseTerms => _config.getValue(_kLicenseHash) as String;
+
+  @override
+  set lastActiveLicenseTerms(String value) {
+    assert(value != null);
+    _config.setValue(_kLicenseHash, value);
+  }
+
   String _versionKeyFor(Channel channel) {
     return _lastActiveVersionKeys[channel];
   }
diff --git a/packages/flutter_tools/lib/src/reporting/disabled_usage.dart b/packages/flutter_tools/lib/src/reporting/disabled_usage.dart
index b908bea..b77303b 100644
--- a/packages/flutter_tools/lib/src/reporting/disabled_usage.dart
+++ b/packages/flutter_tools/lib/src/reporting/disabled_usage.dart
@@ -6,9 +6,6 @@
 
 class DisabledUsage implements Usage {
   @override
-  bool get isFirstRun => false;
-
-  @override
   bool get suppressAnalytics => true;
 
   @override
diff --git a/packages/flutter_tools/lib/src/reporting/first_run.dart b/packages/flutter_tools/lib/src/reporting/first_run.dart
new file mode 100644
index 0000000..49acb1e
--- /dev/null
+++ b/packages/flutter_tools/lib/src/reporting/first_run.dart
@@ -0,0 +1,79 @@
+// 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:convert/convert.dart';
+import 'package:crypto/crypto.dart';
+import 'package:meta/meta.dart';
+
+import '../convert.dart';
+import '../persistent_tool_state.dart';
+
+/// This message is displayed on the first run of the Flutter tool, or anytime
+/// that the contents of this string change.
+const String _kFlutterFirstRunMessage = '''
+  ╔════════════════════════════════════════════════════════════════════════════╗
+  ║                 Welcome to Flutter! - https://flutter.dev                  ║
+  ║                                                                            ║
+  ║ The Flutter tool uses Google Analytics to anonymously report feature usage ║
+  ║ statistics and basic crash reports. This data is used to help improve      ║
+  ║ Flutter tools over time.                                                   ║
+  ║                                                                            ║
+  ║ Flutter tool analytics are not sent on the very first run. To disable      ║
+  ║ reporting, type 'flutter config --no-analytics'. To display the current    ║
+  ║ setting, type 'flutter config'. If you opt out of analytics, an opt-out    ║
+  ║ event will be sent, and then no further information will be sent by the    ║
+  ║ Flutter tool.                                                              ║
+  ║                                                                            ║
+  ║ By downloading the Flutter SDK, you agree to the Google Terms of Service.  ║
+  ║ Note: The Google Privacy Policy describes how data is handled in this      ║
+  ║ service.                                                                   ║
+  ║                                                                            ║
+  ║ Moreover, Flutter includes the Dart SDK, which may send usage metrics and  ║
+  ║ crash reports to Google.                                                   ║
+  ║                                                                            ║
+  ║ Read about data we send with crash reports:                                ║
+  ║ https://flutter.dev/docs/reference/crash-reporting                         ║
+  ║                                                                            ║
+  ║ See Google's privacy policy:                                               ║
+  ║ https://policies.google.com/privacy                                        ║
+  ╚════════════════════════════════════════════════════════════════════════════╝
+''';
+
+/// The first run messenger determines whether the first run license terms
+/// need to be displayed.
+class FirstRunMessenger {
+  FirstRunMessenger({
+    @required PersistentToolState persistentToolState
+  }) : _persistentToolState = persistentToolState;
+
+  final PersistentToolState _persistentToolState;
+
+  /// Whether the license terms should be displayed.
+  ///
+  /// This is implemented by caching a hash of the previous license terms. This
+  /// does not update the cache hash value.
+  ///
+  /// The persistent tool state setting [PersistentToolState.redisplayWelcomeMessage]
+  /// can also be used to make this return false. This is primarily used to ensure
+  /// that the license terms are not printed during a `flutter upgrade`, until the
+  /// user manually runs the tool.
+  bool shouldDisplayLicenseTerms() {
+    if (_persistentToolState.redisplayWelcomeMessage == false) {
+      return false;
+    }
+    final String oldHash = _persistentToolState.lastActiveLicenseTerms;
+    return oldHash != _currentHash;
+  }
+
+  /// Update the cached license terms hash once the new terms have been displayed.
+  void confirmLicenseTermsDisplayed() {
+    _persistentToolState.lastActiveLicenseTerms = _currentHash;
+  }
+
+  /// The hash of the current license representation.
+  String get _currentHash =>  hex.encode(md5.convert(utf8.encode(licenseTerms)).bytes);
+
+  /// The current license terms.
+  String get licenseTerms => _kFlutterFirstRunMessage;
+}
diff --git a/packages/flutter_tools/lib/src/reporting/reporting.dart b/packages/flutter_tools/lib/src/reporting/reporting.dart
index 5185c68..7439a31 100644
--- a/packages/flutter_tools/lib/src/reporting/reporting.dart
+++ b/packages/flutter_tools/lib/src/reporting/reporting.dart
@@ -35,6 +35,7 @@
 import '../project.dart';
 import '../runner/flutter_command.dart';
 import '../version.dart';
+import 'first_run.dart';
 
 part 'crash_reporting.dart';
 part 'disabled_usage.dart';
diff --git a/packages/flutter_tools/lib/src/reporting/usage.dart b/packages/flutter_tools/lib/src/reporting/usage.dart
index 1aacd1e..972f038 100644
--- a/packages/flutter_tools/lib/src/reporting/usage.dart
+++ b/packages/flutter_tools/lib/src/reporting/usage.dart
@@ -79,13 +79,15 @@
     String configDirOverride,
     String logFile,
     AnalyticsFactory analyticsIOFactory,
+    FirstRunMessenger firstRunMessenger,
     @required bool runningOnBot,
   }) => _DefaultUsage(settingsName: settingsName,
                       versionOverride: versionOverride,
                       configDirOverride: configDirOverride,
                       logFile: logFile,
                       analyticsIOFactory: analyticsIOFactory,
-                      runningOnBot: runningOnBot);
+                      runningOnBot: runningOnBot,
+                      firstRunMessenger: firstRunMessenger);
 
   factory Usage.test() => _DefaultUsage.test();
 
@@ -94,9 +96,6 @@
     Map<CustomDimensions, Object> parameters,
   }) => globals.flutterUsage.sendCommand(command, parameters: _useCdKeys(parameters));
 
-  /// Whether this is the first run of the tool.
-  bool get isFirstRun;
-
   /// Whether analytics reporting should be suppressed.
   bool get suppressAnalytics;
 
@@ -191,6 +190,7 @@
     String configDirOverride,
     String logFile,
     AnalyticsFactory analyticsIOFactory,
+    @required this.firstRunMessenger,
     @required bool runningOnBot,
   }) {
     final FlutterVersion flutterVersion = globals.flutterVersion;
@@ -202,7 +202,7 @@
     analyticsIOFactory ??= _defaultAnalyticsIOFactory;
     _clock = globals.systemClock;
 
-    if (// To support testing, only allow other signals to supress analytics
+    if (// To support testing, only allow other signals to suppress analytics
         // when analytics are not being shunted to a file.
         !usingLogFile && (
         // Ignore local user branches.
@@ -277,18 +277,17 @@
   _DefaultUsage.test() :
       _suppressAnalytics = false,
       _analytics = AnalyticsMock(true),
+      firstRunMessenger = null,
       _clock = SystemClock.fixed(DateTime(2020, 10, 8));
 
   Analytics _analytics;
+  final FirstRunMessenger firstRunMessenger;
 
   bool _printedWelcome = false;
   bool _suppressAnalytics = false;
   SystemClock _clock;
 
   @override
-  bool get isFirstRun => _analytics.firstRun;
-
-  @override
   bool get suppressAnalytics => _suppressAnalytics || _analytics.firstRun;
 
   @override
@@ -383,52 +382,19 @@
     await _analytics.waitForLastPing(timeout: const Duration(milliseconds: 250));
   }
 
-  void _printWelcome() {
-    globals.printStatus('');
-    globals.printStatus('''
-  ╔════════════════════════════════════════════════════════════════════════════╗
-  ║                 Welcome to Flutter! - https://flutter.dev                  ║
-  ║                                                                            ║
-  ║ The Flutter tool uses Google Analytics to anonymously report feature usage ║
-  ║ statistics and basic crash reports. This data is used to help improve      ║
-  ║ Flutter tools over time.                                                   ║
-  ║                                                                            ║
-  ║ Flutter tool analytics are not sent on the very first run. To disable      ║
-  ║ reporting, type 'flutter config --no-analytics'. To display the current    ║
-  ║ setting, type 'flutter config'. If you opt out of analytics, an opt-out    ║
-  ║ event will be sent, and then no further information will be sent by the    ║
-  ║ Flutter tool.                                                              ║
-  ║                                                                            ║
-  ║ By downloading the Flutter SDK, you agree to the Google Terms of Service.  ║
-  ║ Note: The Google Privacy Policy describes how data is handled in this      ║
-  ║ service.                                                                   ║
-  ║                                                                            ║
-  ║ Moreover, Flutter includes the Dart SDK, which may send usage metrics and  ║
-  ║ crash reports to Google.                                                   ║
-  ║                                                                            ║
-  ║ Read about data we send with crash reports:                                ║
-  ║ https://flutter.dev/docs/reference/crash-reporting                         ║
-  ║                                                                            ║
-  ║ See Google's privacy policy:                                               ║
-  ║ https://policies.google.com/privacy                                        ║
-  ╚════════════════════════════════════════════════════════════════════════════╝
-  ''', emphasis: true);
-  }
-
   @override
   void printWelcome() {
     // Only print once per run.
     if (_printedWelcome) {
       return;
     }
-    if (// Display the welcome message if this is the first run of the tool.
-        isFirstRun ||
-        // Display the welcome message if we are not on master, and if the
-        // persistent tool state instructs that we should.
-        (globals.persistentToolState.redisplayWelcomeMessage ?? true)) {
-      _printWelcome();
+    // Display the welcome message if this is the first run of the tool or if
+    // the license terms have changed since it was last displayed.
+    if (firstRunMessenger != null && firstRunMessenger.shouldDisplayLicenseTerms() ?? true) {
+      globals.printStatus('');
+      globals.printStatus(firstRunMessenger.licenseTerms, emphasis: true);
       _printedWelcome = true;
-      globals.persistentToolState.redisplayWelcomeMessage = false;
+      firstRunMessenger.confirmLicenseTermsDisplayed();
     }
   }
 }
diff --git a/packages/flutter_tools/test/commands.shard/hermetic/config_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/config_test.dart
index 9ab3d78..aa1299a 100644
--- a/packages/flutter_tools/test/commands.shard/hermetic/config_test.dart
+++ b/packages/flutter_tools/test/commands.shard/hermetic/config_test.dart
@@ -34,8 +34,6 @@
     mockAndroidSdk = MockAndroidSdk();
     mockFlutterVersion = MockFlutterVersion();
     mockUsage = MockUsage();
-
-    when(mockUsage.isFirstRun).thenReturn(false);
   });
 
   void verifyNoAnalytics() {
diff --git a/packages/flutter_tools/test/commands.shard/hermetic/doctor_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/doctor_test.dart
index 709d1e7..e9f4164 100644
--- a/packages/flutter_tools/test/commands.shard/hermetic/doctor_test.dart
+++ b/packages/flutter_tools/test/commands.shard/hermetic/doctor_test.dart
@@ -223,7 +223,6 @@
 
     setUp(() {
       mockUsage = MockUsage();
-      when(mockUsage.isFirstRun).thenReturn(true);
     });
 
     testUsingContext('contains installed', () async {
diff --git a/packages/flutter_tools/test/commands.shard/permeable/build_aar_test.dart b/packages/flutter_tools/test/commands.shard/permeable/build_aar_test.dart
index 1b49c3a..eb78656 100644
--- a/packages/flutter_tools/test/commands.shard/permeable/build_aar_test.dart
+++ b/packages/flutter_tools/test/commands.shard/permeable/build_aar_test.dart
@@ -207,12 +207,8 @@
     ProcessManager mockProcessManager;
     Directory tempDir;
     AndroidSdk mockAndroidSdk;
-    Usage mockUsage;
 
     setUp(() {
-      mockUsage = MockUsage();
-      when(mockUsage.isFirstRun).thenReturn(true);
-
       tempDir = globals.fs.systemTempDirectory.createTempSync('flutter_tools_packages_test.');
 
       mockProcessManager = MockProcessManager();
diff --git a/packages/flutter_tools/test/commands.shard/permeable/build_apk_test.dart b/packages/flutter_tools/test/commands.shard/permeable/build_apk_test.dart
index 2750d67..421e1db 100644
--- a/packages/flutter_tools/test/commands.shard/permeable/build_apk_test.dart
+++ b/packages/flutter_tools/test/commands.shard/permeable/build_apk_test.dart
@@ -124,7 +124,6 @@
 
     setUp(() {
       mockUsage = MockUsage();
-      when(mockUsage.isFirstRun).thenReturn(true);
 
       tempDir = globals.fs.systemTempDirectory.createTempSync('flutter_tools_packages_test.');
       gradlew = globals.fs.path.join(tempDir.path, 'flutter_project', 'android',
diff --git a/packages/flutter_tools/test/commands.shard/permeable/build_appbundle_test.dart b/packages/flutter_tools/test/commands.shard/permeable/build_appbundle_test.dart
index e11dd8d..decfc46 100644
--- a/packages/flutter_tools/test/commands.shard/permeable/build_appbundle_test.dart
+++ b/packages/flutter_tools/test/commands.shard/permeable/build_appbundle_test.dart
@@ -106,8 +106,6 @@
 
     setUp(() {
       mockUsage = MockUsage();
-      when(mockUsage.isFirstRun).thenReturn(true);
-
       tempDir = globals.fs.systemTempDirectory.createTempSync('flutter_tools_packages_test.');
       gradlew = globals.fs.path.join(tempDir.path, 'flutter_project', 'android',
           globals.platform.isWindows ? 'gradlew.bat' : 'gradlew');
diff --git a/packages/flutter_tools/test/general.shard/analytics_test.dart b/packages/flutter_tools/test/general.shard/analytics_test.dart
index 5551845..82cdbd0 100644
--- a/packages/flutter_tools/test/general.shard/analytics_test.dart
+++ b/packages/flutter_tools/test/general.shard/analytics_test.dart
@@ -160,7 +160,6 @@
       memoryFileSystem = MemoryFileSystem.test();
       mockStdio = MockStdio();
       mockUsage = MockUsage();
-      when(mockUsage.isFirstRun).thenReturn(false);
       mockClock = MockClock();
       mockDoctor = MockDoctor();
       when(mockClock.now()).thenAnswer(
diff --git a/packages/flutter_tools/test/general.shard/reporting/first_run_test.dart b/packages/flutter_tools/test/general.shard/reporting/first_run_test.dart
new file mode 100644
index 0000000..4459410
--- /dev/null
+++ b/packages/flutter_tools/test/general.shard/reporting/first_run_test.dart
@@ -0,0 +1,66 @@
+// 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:file/memory.dart';
+import 'package:flutter_tools/src/base/logger.dart';
+import 'package:flutter_tools/src/persistent_tool_state.dart';
+import 'package:flutter_tools/src/reporting/first_run.dart';
+
+import '../../src/common.dart';
+
+void main() {
+  testWithoutContext('FirstRunMessenger delegates to the first run message', () {
+    final FirstRunMessenger messenger = setUpFirstRunMessenger();
+
+    expect(messenger.licenseTerms, contains('Welcome to Flutter'));
+  });
+
+  testWithoutContext('FirstRunMessenger requires redisplay if it has never been run before', () {
+    final FirstRunMessenger messenger = setUpFirstRunMessenger();
+
+    expect(messenger.shouldDisplayLicenseTerms(), true);
+    expect(messenger.shouldDisplayLicenseTerms(), true);
+
+    // Once terms have been confirmed, then it will return false.
+    messenger.confirmLicenseTermsDisplayed();
+
+    expect(messenger.shouldDisplayLicenseTerms(), false);
+  });
+
+  testWithoutContext('FirstRunMessenger requires redisplay if the license terms have changed', () {
+    final TestFirstRunMessenger messenger = setUpFirstRunMessenger(test: true) as TestFirstRunMessenger;
+    messenger.confirmLicenseTermsDisplayed();
+
+    expect(messenger.shouldDisplayLicenseTerms(), false);
+
+    messenger.overrideLicenseTerms = 'This is a new license';
+
+    expect(messenger.shouldDisplayLicenseTerms(), true);
+  });
+
+  testWithoutContext('FirstRunMessenger does not require re-display if the persistent tool state disables it', () {
+    final FirstRunMessenger messenger = setUpFirstRunMessenger(redisplayWelcomeMessage: false);
+
+    expect(messenger.shouldDisplayLicenseTerms(), false);
+  });
+}
+
+FirstRunMessenger setUpFirstRunMessenger({bool redisplayWelcomeMessage, bool test = false }) {
+  final MemoryFileSystem fileSystem = MemoryFileSystem.test();
+  final PersistentToolState state = PersistentToolState.test(directory: fileSystem.currentDirectory, logger: BufferLogger.test())
+    ..redisplayWelcomeMessage = redisplayWelcomeMessage;
+  if (test) {
+    return TestFirstRunMessenger(state);
+  }
+  return FirstRunMessenger(persistentToolState: state);
+}
+
+class TestFirstRunMessenger extends FirstRunMessenger {
+  TestFirstRunMessenger(PersistentToolState persistentToolState) : super(persistentToolState: persistentToolState);
+
+  String overrideLicenseTerms;
+
+  @override
+  String get licenseTerms => overrideLicenseTerms ?? super.licenseTerms;
+}
diff --git a/packages/flutter_tools/test/general.shard/runner/flutter_command_test.dart b/packages/flutter_tools/test/general.shard/runner/flutter_command_test.dart
index 2eebf96..e5f46f7 100644
--- a/packages/flutter_tools/test/general.shard/runner/flutter_command_test.dart
+++ b/packages/flutter_tools/test/general.shard/runner/flutter_command_test.dart
@@ -40,7 +40,6 @@
       clock = MockClock();
       mockProcessInfo = MockProcessInfo();
 
-      when(usage.isFirstRun).thenReturn(false);
       when(clock.now()).thenAnswer(
         (Invocation _) => DateTime.fromMillisecondsSinceEpoch(mockTimes.removeAt(0))
       );
diff --git a/packages/flutter_tools/test/general.shard/runner/runner_test.dart b/packages/flutter_tools/test/general.shard/runner/runner_test.dart
index 8c6dfb1..cbb5328 100644
--- a/packages/flutter_tools/test/general.shard/runner/runner_test.dart
+++ b/packages/flutter_tools/test/general.shard/runner/runner_test.dart
@@ -260,9 +260,6 @@
   }
 
   @override
-  bool get isFirstRun => _impl.isFirstRun;
-
-  @override
   bool get suppressAnalytics => _impl.suppressAnalytics;
 
   @override
diff --git a/packages/flutter_tools/test/src/context.dart b/packages/flutter_tools/test/src/context.dart
index b1616fe..c86e182 100644
--- a/packages/flutter_tools/test/src/context.dart
+++ b/packages/flutter_tools/test/src/context.dart
@@ -332,9 +332,6 @@
 
 class FakeUsage implements Usage {
   @override
-  bool get isFirstRun => false;
-
-  @override
   bool get suppressAnalytics => false;
 
   @override
diff --git a/packages/flutter_tools/test/src/testbed.dart b/packages/flutter_tools/test/src/testbed.dart
index 94cd23e..b430777 100644
--- a/packages/flutter_tools/test/src/testbed.dart
+++ b/packages/flutter_tools/test/src/testbed.dart
@@ -175,9 +175,6 @@
   }
 
   @override
-  bool get isFirstRun => false;
-
-  @override
   Stream<Map<String, Object>> get onSend => const Stream<Map<String, Object>>.empty();
 
   @override