Add a RestartableTimer class.

This is useful for heartbeat-style timeouts where a timeout is reset
when certain actions occur.

R=lrn@google.com

Review URL: https://codereview.chromium.org//1417373004 .
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7f9ec48..5801872 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,9 @@
 - Added `AsyncMemoizer.future`, which allows the result to be accessed before
   `runOnce()` is called.
 
+- Added `RestartableTimer`, a non-periodic timer that can be reset over and
+  over.
+
 ## 1.3.0
 
 - Added `StreamCompleter` class for creating a stream now and providing its
diff --git a/lib/async.dart b/lib/async.dart
index 07b418b..9da3552 100644
--- a/lib/async.dart
+++ b/lib/async.dart
@@ -14,6 +14,7 @@
 export "src/delegate/stream_sink.dart";
 export "src/delegate/stream_subscription.dart";
 export "src/future_group.dart";
+export "src/restartable_timer.dart";
 export "src/result_future.dart";
 export "src/stream_completer.dart";
 export "src/stream_group.dart";
diff --git a/lib/src/restartable_timer.dart b/lib/src/restartable_timer.dart
new file mode 100644
index 0000000..05196d2
--- /dev/null
+++ b/lib/src/restartable_timer.dart
@@ -0,0 +1,48 @@
+// Copyright (c) 2015, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+library async.restartable_timer;
+
+import 'dart:async';
+
+/// A non-periodic timer that can be restarted any number of times.
+///
+/// Once restarted (via [reset]), the timer counts down from its original
+/// duration again.
+class RestartableTimer implements Timer {
+  /// The duration of the timer.
+  final Duration _duration;
+
+  /// The callback to call when the timer fires.
+  final ZoneCallback _callback;
+
+  /// The timer for the current or most recent countdown.
+  ///
+  /// This timer is canceled and overwritten every time this [RestartableTimer]
+  /// is reset.
+  Timer _timer;
+
+  /// Creates a new timer.
+  ///
+  /// The [callback] function is invoked after the given [duration]. Unlike a
+  /// normal non-periodic [Timer], [callback] may be called more than once.
+  RestartableTimer(this._duration, this._callback) {
+    _timer = new Timer(_duration, _callback);
+  }
+
+  bool get isActive => _timer.isActive;
+
+  /// Restarts the timer so that it counts down from its original duration
+  /// again.
+  ///
+  /// This restarts the timer even if it has already fired or has been canceled.
+  void reset() {
+    _timer.cancel();
+    _timer = new Timer(_duration, _callback);
+  }
+
+  void cancel() {
+    _timer.cancel();
+  }
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index 7062a99..91d752d 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,9 +1,10 @@
 name: async
-version: 1.4.0-dev
+version: 1.4.0
 author: Dart Team <misc@dartlang.org>
 description: Utility functions and classes related to the 'dart:async' library.
 homepage: https://www.github.com/dart-lang/async
 dev_dependencies:
+  fake_async: "^0.1.2"
   stack_trace: "^1.0.0"
   test: "^0.12.0"
 environment:
diff --git a/test/restartable_timer_test.dart b/test/restartable_timer_test.dart
new file mode 100644
index 0000000..6732b81
--- /dev/null
+++ b/test/restartable_timer_test.dart
@@ -0,0 +1,110 @@
+// Copyright (c) 2015, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+
+import 'package:async/async.dart';
+import 'package:fake_async/fake_async.dart';
+import 'package:test/test.dart';
+
+main() {
+  test("runs the callback once the duration has elapsed", () {
+    new FakeAsync().run((async) {
+      var fired = false;
+      var timer = new RestartableTimer(new Duration(seconds: 5), () {
+        fired = true;
+      });
+
+      async.elapse(new Duration(seconds: 4));
+      expect(fired, isFalse);
+
+      async.elapse(new Duration(seconds: 1));
+      expect(fired, isTrue);
+    });
+  });
+
+  test("doesn't run the callback if the timer is canceled", () {
+    new FakeAsync().run((async) {
+      var fired = false;
+      var timer = new RestartableTimer(new Duration(seconds: 5), () {
+        fired = true;
+      });
+
+      async.elapse(new Duration(seconds: 4));
+      expect(fired, isFalse);
+      timer.cancel();
+
+      async.elapse(new Duration(seconds: 4));
+      expect(fired, isFalse);
+    });
+  });
+
+  test("resets the duration if the timer is reset before it fires", () {
+    new FakeAsync().run((async) {
+      var fired = false;
+      var timer = new RestartableTimer(new Duration(seconds: 5), () {
+        fired = true;
+      });
+
+      async.elapse(new Duration(seconds: 4));
+      expect(fired, isFalse);
+      timer.reset();
+
+      async.elapse(new Duration(seconds: 4));
+      expect(fired, isFalse);
+
+      async.elapse(new Duration(seconds: 1));
+      expect(fired, isTrue);
+    });
+  });
+
+  test("re-runs the callback if the timer is reset after firing", () {
+    new FakeAsync().run((async) {
+      var fired = 0;
+      var timer = new RestartableTimer(new Duration(seconds: 5), () {
+        fired++;
+      });
+
+      async.elapse(new Duration(seconds: 5));
+      expect(fired, equals(1));
+      timer.reset();
+
+      async.elapse(new Duration(seconds: 5));
+      expect(fired, equals(2));
+      timer.reset();
+
+      async.elapse(new Duration(seconds: 5));
+      expect(fired, equals(3));
+    });
+  });
+
+  test("runs the callback if the timer is reset after being canceled", () {
+    new FakeAsync().run((async) {
+      var fired = false;
+      var timer = new RestartableTimer(new Duration(seconds: 5), () {
+        fired = true;
+      });
+
+      async.elapse(new Duration(seconds: 4));
+      expect(fired, isFalse);
+      timer.cancel();
+
+      async.elapse(new Duration(seconds: 4));
+      expect(fired, isFalse);
+      timer.reset();
+
+      async.elapse(new Duration(seconds: 5));
+      expect(fired, isTrue);
+    });
+  });
+
+  test("only runs the callback once if the timer isn't reset", () {
+    new FakeAsync().run((async) {
+      var timer = new RestartableTimer(
+          new Duration(seconds: 5),
+          expectAsync(() {}, count: 1));
+      async.elapse(new Duration(seconds: 10));
+    });
+  });
+}