warn about outdated Flutter installations (#9163)

diff --git a/packages/flutter_tools/lib/src/version.dart b/packages/flutter_tools/lib/src/version.dart
index c15db62..f61327e 100644
--- a/packages/flutter_tools/lib/src/version.dart
+++ b/packages/flutter_tools/lib/src/version.dart
@@ -2,11 +2,19 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:meta/meta.dart';
+import 'package:quiver/time.dart';
+
+import 'base/common.dart';
 import 'base/context.dart';
 import 'base/io.dart';
 import 'base/process.dart';
 import 'base/process_manager.dart';
 import 'cache.dart';
+import 'globals.dart';
 
 final Set<String> kKnownBranchNames = new Set<String>.from(<String>[
   'master',
@@ -17,7 +25,8 @@
 ]);
 
 class FlutterVersion {
-  FlutterVersion._() {
+  @visibleForTesting
+  FlutterVersion(this._clock) {
     _channel = _runGit('git rev-parse --abbrev-ref --symbolic @{u}');
 
     final int slash = _channel.indexOf('/');
@@ -33,6 +42,8 @@
     _frameworkAge = _runGit('git log -n 1 --pretty=format:%ar');
   }
 
+  final Clock _clock;
+
   String _repositoryUrl;
   String get repositoryUrl => _repositoryUrl;
 
@@ -72,20 +83,64 @@
   }
 
   /// A date String describing the last framework commit.
-  static String get frameworkCommitDate {
-    return _runSync(<String>['git', 'log', '-n', '1', '--pretty=format:%ad', '--date=format:%Y-%m-%d %H:%M:%S'], Cache.flutterRoot);
+  String get frameworkCommitDate => _latestGitCommitDate();
+
+  static String _latestGitCommitDate([String branch]) {
+    final List<String> args = <String>['git', 'log'];
+
+    if (branch != null)
+      args.add(branch);
+
+    args.addAll(<String>['-n', '1', '--pretty=format:%ad', '--date=iso']);
+    return _runSync(args, lenient: false);
   }
 
-  static FlutterVersion get instance => context.putIfAbsent(FlutterVersion, () => new FlutterVersion._());
+  /// The name of the temporary git remote used to check for the latest
+  /// available Flutter framework version.
+  ///
+  /// In the absence of bugs and crashes a Flutter developer should never see
+  /// this remote appear in their `git remote` list, but also if it happens to
+  /// persist we do the proper clean-up for extra robustness.
+  static const String _kVersionCheckRemote = '__flutter_version_check__';
+
+  /// The date of the latest framework commit in the remote repository.
+  ///
+  /// Throws [ToolExit] if a git command fails, for example, when the remote git
+  /// repository is not reachable due to a network issue.
+  static Future<String> fetchRemoteFrameworkCommitDate() async {
+    await _removeVersionCheckRemoteIfExists();
+    try {
+      await _run(<String>[
+        'git',
+        'remote',
+        'add',
+        _kVersionCheckRemote,
+        'https://github.com/flutter/flutter.git',
+      ]);
+      await _run(<String>['git', 'fetch', _kVersionCheckRemote, 'master']);
+      return _latestGitCommitDate('$_kVersionCheckRemote/master');
+    } finally {
+      await _removeVersionCheckRemoteIfExists();
+    }
+  }
+
+  static Future<Null> _removeVersionCheckRemoteIfExists() async {
+    final List<String> remotes = (await _run(<String>['git', 'remote']))
+        .split('\n')
+        .map((String name) => name.trim())  // to account for OS-specific line-breaks
+        .toList();
+    if (remotes.contains(_kVersionCheckRemote))
+      await _run(<String>['git', 'remote', 'remove', _kVersionCheckRemote]);
+  }
+
+  static FlutterVersion get instance => context.putIfAbsent(FlutterVersion, () => new FlutterVersion(const Clock()));
 
   /// Return a short string for the version (`alpha/a76bc8e22b`).
   static String getVersionString({ bool whitelistBranchName: false }) {
-    final String cwd = Cache.flutterRoot;
-
-    String commit = _shortGitRevision(_runSync(<String>['git', 'rev-parse', 'HEAD'], cwd));
+    String commit = _shortGitRevision(_runSync(<String>['git', 'rev-parse', 'HEAD']));
     commit = commit.isEmpty ? 'unknown' : commit;
 
-    String branch = _runSync(<String>['git', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd);
+    String branch = _runSync(<String>['git', 'rev-parse', '--abbrev-ref', 'HEAD']);
     branch = branch == 'HEAD' ? 'master' : branch;
 
     if (whitelistBranchName || branch.isEmpty) {
@@ -96,11 +151,148 @@
 
     return '$branch/$commit';
   }
+
+  /// The amount of time we wait before pinging the server to check for the
+  /// availability of a newer version of Flutter.
+  @visibleForTesting
+  static const Duration kCheckAgeConsideredUpToDate = const Duration(days: 7);
+
+  /// We warn the user if the age of their Flutter installation is greater than
+  /// this duration.
+  @visibleForTesting
+  static final Duration kVersionAgeConsideredUpToDate = kCheckAgeConsideredUpToDate * 4;
+
+  /// The prefix of the stamp file where we cache Flutter version check data.
+  @visibleForTesting
+  static const String kFlutterVersionCheckStampFile = 'flutter_version_check';
+
+  /// Checks if the currently installed version of Flutter is up-to-date, and
+  /// warns the user if it isn't.
+  ///
+  /// This function must run while [Cache.lock] is acquired because it reads and
+  /// writes shared cache files.
+  Future<Null> checkFlutterVersionFreshness() async {
+    final DateTime localFrameworkCommitDate = DateTime.parse(frameworkCommitDate);
+    final Duration frameworkAge = _clock.now().difference(localFrameworkCommitDate);
+    final bool installationSeemsOutdated = frameworkAge > kVersionAgeConsideredUpToDate;
+
+    Future<bool> newerFrameworkVersionAvailable() async {
+      final DateTime latestFlutterCommitDate = await _getLatestAvailableFlutterVersion();
+
+      if (latestFlutterCommitDate == null)
+        return false;
+
+      return latestFlutterCommitDate.isAfter(localFrameworkCommitDate);
+    }
+
+    if (installationSeemsOutdated && await newerFrameworkVersionAvailable())
+      printStatus(versionOutOfDateMessage(frameworkAge), emphasis: true);
+  }
+
+  @visibleForTesting
+  static String versionOutOfDateMessage(Duration frameworkAge) {
+    String warning = 'WARNING: your installation of Flutter is ${frameworkAge.inDays} days old.';
+    // Append enough spaces to match the message box width.
+    warning += ' ' * (74 - warning.length);
+
+    return '''
+  ╔════════════════════════════════════════════════════════════════════════════╗
+  ║ $warning ║
+  ║                                                                            ║
+  ║ To update to the latest version, run "flutter upgrade".                    ║
+  ╚════════════════════════════════════════════════════════════════════════════╝
+''';
+  }
+
+  /// Gets the release date of the latest available Flutter version.
+  ///
+  /// This method sends a server request if it's been more than
+  /// [kCheckAgeConsideredUpToDate] since the last version check.
+  ///
+  /// Returns `null` if the cached version is out-of-date or missing, and we are
+  /// unable to reach the server to get the latest version.
+  Future<DateTime> _getLatestAvailableFlutterVersion() async {
+    Cache.checkLockAcquired();
+    const JsonEncoder kPrettyJsonEncoder = const JsonEncoder.withIndent('  ');
+    final String versionCheckStamp = Cache.instance.getStampFor(kFlutterVersionCheckStampFile);
+
+    if (versionCheckStamp != null) {
+      final Map<String, String> data = JSON.decode(versionCheckStamp);
+      final DateTime lastTimeVersionWasChecked = DateTime.parse(data['lastTimeVersionWasChecked']);
+      final Duration timeSinceLastCheck = _clock.now().difference(lastTimeVersionWasChecked);
+
+      // Don't ping the server too often. Return cached value if it's fresh.
+      if (timeSinceLastCheck < kCheckAgeConsideredUpToDate)
+        return DateTime.parse(data['lastKnownRemoteVersion']);
+    }
+
+    // Cache is empty or it's been a while since the last server ping. Ping the server.
+    try {
+      final DateTime remoteFrameworkCommitDate = DateTime.parse(await FlutterVersion.fetchRemoteFrameworkCommitDate());
+      Cache.instance.setStampFor(kFlutterVersionCheckStampFile, kPrettyJsonEncoder.convert(<String, String>{
+        'lastTimeVersionWasChecked': '${_clock.now()}',
+        'lastKnownRemoteVersion': '$remoteFrameworkCommitDate',
+      }));
+      return remoteFrameworkCommitDate;
+    } on VersionCheckError catch (error) {
+      // This happens when any of the git commands fails, which can happen when
+      // there's no Internet connectivity. Remote version check is best effort
+      // only. We do not prevent the command from running when it fails.
+      printTrace('Failed to check Flutter version in the remote repository: $error');
+      return null;
+    }
+  }
 }
 
-String _runSync(List<String> command, String cwd) {
-  final ProcessResult results = processManager.runSync(command, workingDirectory: cwd);
-  return results.exitCode == 0 ? results.stdout.trim() : '';
+/// Thrown when we fail to check Flutter version.
+///
+/// This can happen when we attempt to `git fetch` but there is no network, or
+/// when the installation is not git-based (e.g. a user clones the repo but
+/// then removes .git).
+class VersionCheckError implements Exception {
+
+  VersionCheckError(this.message);
+
+  final String message;
+
+  @override
+  String toString() => '$VersionCheckError: $message';
+}
+
+/// Runs [command] and returns the standard output as a string.
+///
+/// If [lenient] is `true` and the command fails, returns an empty string.
+/// Otherwise, throws a [ToolExit] exception.
+String _runSync(List<String> command, {bool lenient: true}) {
+  final ProcessResult results = processManager.runSync(command, workingDirectory: Cache.flutterRoot);
+
+  if (results.exitCode == 0)
+    return results.stdout.trim();
+
+  if (!lenient) {
+    throw new VersionCheckError(
+      'Command exited with code ${results.exitCode}: ${command.join(' ')}\n'
+      'Standard error: ${results.stderr}'
+    );
+  }
+
+  return '';
+}
+
+/// Runs [command] in the root of the Flutter installation and returns the
+/// standard output as a string.
+///
+/// If the command fails, throws a [ToolExit] exception.
+Future<String> _run(List<String> command) async {
+  final ProcessResult results = await processManager.run(command, workingDirectory: Cache.flutterRoot);
+
+  if (results.exitCode == 0)
+    return results.stdout.trim();
+
+  throw new VersionCheckError(
+    'Command exited with code ${results.exitCode}: ${command.join(' ')}\n'
+    'Standard error: ${results.stderr}'
+  );
 }
 
 String _shortGitRevision(String revision) {