Catch and log any exceptions thrown by an app's gesture recognizer callbacks (#6542)

If a recognizer is interrupted by an exception from a callback, it could be
left in an inconsistent state and be unable to process future events
diff --git a/packages/flutter/lib/src/gestures/drag.dart b/packages/flutter/lib/src/gestures/drag.dart
index 4518ac2..befd77a 100644
--- a/packages/flutter/lib/src/gestures/drag.dart
+++ b/packages/flutter/lib/src/gestures/drag.dart
@@ -182,7 +182,7 @@
       _initialPosition = event.position;
       _pendingDragOffset = Offset.zero;
       if (onDown != null)
-        onDown(new DragDownDetails(globalPosition: _initialPosition));
+        invokeCallback/*<Null>*/('onDown', () => onDown(new DragDownDetails(globalPosition: _initialPosition)));
     }
   }
 
@@ -196,11 +196,11 @@
       Offset delta = event.delta;
       if (_state == _DragState.accepted) {
         if (onUpdate != null) {
-          onUpdate(new DragUpdateDetails(
+          invokeCallback/*<Null>*/('onUpdate', () => onUpdate(new DragUpdateDetails(
             delta: _getDeltaForDetails(delta),
             primaryDelta: _getPrimaryDeltaForDetails(delta),
             globalPosition: event.position
-          ));
+          )));
         }
       } else {
         _pendingDragOffset += delta;
@@ -218,12 +218,12 @@
       Offset delta = _pendingDragOffset;
       _pendingDragOffset = Offset.zero;
       if (onStart != null)
-        onStart(new DragStartDetails(globalPosition: _initialPosition));
+        invokeCallback/*<Null>*/('onStart', () => onStart(new DragStartDetails(globalPosition: _initialPosition)));
       if (delta != Offset.zero && onUpdate != null) {
-        onUpdate(new DragUpdateDetails(
+        invokeCallback/*<Null>*/('onUpdate', () => onUpdate(new DragUpdateDetails(
           delta: _getDeltaForDetails(delta),
           primaryDelta: _getPrimaryDeltaForDetails(delta)
-        ));
+        )));
       }
     }
   }
@@ -239,7 +239,7 @@
       resolve(GestureDisposition.rejected);
       _state = _DragState.ready;
       if (onCancel != null)
-        onCancel();
+        invokeCallback/*<Null>*/('onCancel', onCancel);
       return;
     }
     bool wasAccepted = (_state == _DragState.accepted);
@@ -253,9 +253,9 @@
         final Offset pixelsPerSecond = velocity.pixelsPerSecond;
         if (pixelsPerSecond.distanceSquared > kMaxFlingVelocity * kMaxFlingVelocity)
           velocity = new Velocity(pixelsPerSecond: (pixelsPerSecond / pixelsPerSecond.distance) * kMaxFlingVelocity);
-        onEnd(new DragEndDetails(velocity: velocity));
+        invokeCallback/*<Null>*/('onEnd', () => onEnd(new DragEndDetails(velocity: velocity)));
       } else {
-        onEnd(new DragEndDetails(velocity: Velocity.zero));
+        invokeCallback/*<Null>*/('onEnd', () => onEnd(new DragEndDetails(velocity: Velocity.zero)));
       }
     }
     _velocityTrackers.clear();
diff --git a/packages/flutter/lib/src/gestures/long_press.dart b/packages/flutter/lib/src/gestures/long_press.dart
index 27ba6e4..25685b6 100644
--- a/packages/flutter/lib/src/gestures/long_press.dart
+++ b/packages/flutter/lib/src/gestures/long_press.dart
@@ -26,7 +26,7 @@
   void didExceedDeadline() {
     resolve(GestureDisposition.accepted);
     if (onLongPress != null)
-      onLongPress();
+      invokeCallback/*<Null>*/('onLongPress', onLongPress);
   }
 
   @override
diff --git a/packages/flutter/lib/src/gestures/multidrag.dart b/packages/flutter/lib/src/gestures/multidrag.dart
index f1b1aaa..86eca4c 100644
--- a/packages/flutter/lib/src/gestures/multidrag.dart
+++ b/packages/flutter/lib/src/gestures/multidrag.dart
@@ -255,7 +255,7 @@
     assert(state._pendingDelta != null);
     Drag drag;
     if (onStart != null)
-      drag = onStart(initialPosition);
+      drag = invokeCallback/*<Drag>*/('onStart', () => onStart(initialPosition));
     if (drag != null) {
       state._startDrag(drag);
     } else {
diff --git a/packages/flutter/lib/src/gestures/multitap.dart b/packages/flutter/lib/src/gestures/multitap.dart
index 738a871..5b2d6d9 100644
--- a/packages/flutter/lib/src/gestures/multitap.dart
+++ b/packages/flutter/lib/src/gestures/multitap.dart
@@ -191,7 +191,7 @@
     _freezeTracker(tracker);
     _trackers.remove(tracker.pointer);
     if (onDoubleTap != null)
-      onDoubleTap();
+      invokeCallback/*<Null>*/('onDoubleTap', onDoubleTap);
     _reset();
   }
 
@@ -359,7 +359,7 @@
       longTapDelay: longTapDelay
     );
     if (onTapDown != null)
-      onTapDown(event.pointer, new TapDownDetails(globalPosition: event.position));
+      invokeCallback/*<Null>*/('onTapDown', () => onTapDown(event.pointer, new TapDownDetails(globalPosition: event.position)));
   }
 
   @override
@@ -380,19 +380,19 @@
     _gestureMap.remove(pointer);
     if (resolution == _TapResolution.tap) {
       if (onTapUp != null)
-        onTapUp(pointer, new TapUpDetails(globalPosition: globalPosition));
+        invokeCallback/*<Null>*/('onTapUp', () => onTapUp(pointer, new TapUpDetails(globalPosition: globalPosition)));
       if (onTap != null)
-        onTap(pointer);
+        invokeCallback/*<Null>*/('onTap', () => onTap(pointer));
     } else {
       if (onTapCancel != null)
-        onTapCancel(pointer);
+        invokeCallback/*<Null>*/('onTapCancel', () => onTapCancel(pointer));
     }
   }
 
   void _handleLongTap(int pointer, Point lastPosition) {
     assert(_gestureMap.containsKey(pointer));
     if (onLongTapDown != null)
-      onLongTapDown(pointer, new TapDownDetails(globalPosition: lastPosition));
+      invokeCallback/*<Null>*/('onLongTapDown', () => onLongTapDown(pointer, new TapDownDetails(globalPosition: lastPosition)));
   }
 
   @override
diff --git a/packages/flutter/lib/src/gestures/recognizer.dart b/packages/flutter/lib/src/gestures/recognizer.dart
index b354c2c..65e7d87 100644
--- a/packages/flutter/lib/src/gestures/recognizer.dart
+++ b/packages/flutter/lib/src/gestures/recognizer.dart
@@ -6,6 +6,7 @@
 import 'dart:collection';
 import 'dart:ui' show Point, Offset;
 
+import 'package:flutter/foundation.dart';
 import 'package:meta/meta.dart';
 
 import 'arena.dart';
@@ -16,6 +17,8 @@
 
 export 'pointer_router.dart' show PointerRouter;
 
+typedef T RecognizerCallback<T>();
+
 /// The base class that all GestureRecognizers should inherit from.
 ///
 /// Provides a basic API that can be used by classes that work with
@@ -48,6 +51,28 @@
   /// Returns a very short pretty description of the gesture that the
   /// recognizer looks for, like 'tap' or 'horizontal drag'.
   String toStringShort() => toString();
+
+  /// Invoke a callback provided by the application and log any exceptions.
+  @protected
+  dynamic/*=T*/ invokeCallback/*<T>*/(String name, RecognizerCallback<dynamic/*=T*/> callback) {
+    dynamic/*=T*/ result;
+    try {
+      result = callback();
+    } catch (exception, stack) {
+      FlutterError.reportError(new FlutterErrorDetails(
+        exception: exception,
+        stack: stack,
+        library: 'gesture',
+        context: 'while handling a gesture',
+        informationCollector: (StringBuffer information) {
+          information.writeln('Handler: $name');
+          information.writeln('Recognizer:');
+          information.writeln('  $this');
+        }
+      ));
+    }
+    return result;
+  }
 }
 
 /// Base class for gesture recognizers that can only recognize one
diff --git a/packages/flutter/lib/src/gestures/scale.dart b/packages/flutter/lib/src/gestures/scale.dart
index fc0faaf..2ba4f53 100644
--- a/packages/flutter/lib/src/gestures/scale.dart
+++ b/packages/flutter/lib/src/gestures/scale.dart
@@ -180,9 +180,9 @@
             final Offset pixelsPerSecond = velocity.pixelsPerSecond;
             if (pixelsPerSecond.distanceSquared > kMaxFlingVelocity * kMaxFlingVelocity)
               velocity = new Velocity(pixelsPerSecond: (pixelsPerSecond / pixelsPerSecond.distance) * kMaxFlingVelocity);
-            onEnd(new ScaleEndDetails(velocity: velocity));
+            invokeCallback/*<Null>*/('onEnd', () => onEnd(new ScaleEndDetails(velocity: velocity)));
           } else {
-            onEnd(new ScaleEndDetails(velocity: Velocity.zero));
+            invokeCallback/*<Null>*/('onEnd', () => onEnd(new ScaleEndDetails(velocity: Velocity.zero)));
           }
         }
         _state = ScaleState.accepted;
@@ -200,11 +200,11 @@
     if (_state == ScaleState.accepted && !configChanged) {
       _state = ScaleState.started;
       if (onStart != null)
-        onStart(new ScaleStartDetails(focalPoint: focalPoint));
+        invokeCallback/*<Null>*/('onStart', () => onStart(new ScaleStartDetails(focalPoint: focalPoint)));
     }
 
     if (_state == ScaleState.started && onUpdate != null)
-      onUpdate(new ScaleUpdateDetails(scale: _scaleFactor, focalPoint: focalPoint));
+      invokeCallback/*<Null>*/('onUpdate', () => onUpdate(new ScaleUpdateDetails(scale: _scaleFactor, focalPoint: focalPoint)));
   }
 
   @override
diff --git a/packages/flutter/lib/src/gestures/tap.dart b/packages/flutter/lib/src/gestures/tap.dart
index 164c710..3d1ebe8 100644
--- a/packages/flutter/lib/src/gestures/tap.dart
+++ b/packages/flutter/lib/src/gestures/tap.dart
@@ -99,7 +99,7 @@
   void resolve(GestureDisposition disposition) {
     if (_wonArenaForPrimaryPointer && disposition == GestureDisposition.rejected) {
       if (onTapCancel != null)
-        onTapCancel();
+        invokeCallback/*<Null>*/('onTapCancel', onTapCancel);
       _reset();
     }
     super.resolve(disposition);
@@ -126,7 +126,7 @@
     if (pointer == primaryPointer) {
       assert(state == GestureRecognizerState.defunct);
       if (onTapCancel != null)
-        onTapCancel();
+        invokeCallback/*<Null>*/('onTapCancel', onTapCancel);
       _reset();
     }
   }
@@ -134,7 +134,7 @@
   void _checkDown() {
     if (!_sentTapDown) {
       if (onTapDown != null)
-        onTapDown(new TapDownDetails(globalPosition: initialPosition));
+        invokeCallback/*<Null>*/('onTapDown', () => onTapDown(new TapDownDetails(globalPosition: initialPosition)));
       _sentTapDown = true;
     }
   }
@@ -143,9 +143,9 @@
     if (_wonArenaForPrimaryPointer && _finalPosition != null) {
       resolve(GestureDisposition.accepted);
       if (onTapUp != null)
-        onTapUp(new TapUpDetails(globalPosition: _finalPosition));
+        invokeCallback/*<Null>*/('onTapUp', () => onTapUp(new TapUpDetails(globalPosition: _finalPosition)));
       if (onTap != null)
-        onTap();
+        invokeCallback/*<Null>*/('onTap', onTap);
       _reset();
     }
   }
diff --git a/packages/flutter/test/gestures/tap_test.dart b/packages/flutter/test/gestures/tap_test.dart
index 966767e..517c608 100644
--- a/packages/flutter/test/gestures/tap_test.dart
+++ b/packages/flutter/test/gestures/tap_test.dart
@@ -2,6 +2,7 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+import 'package:flutter/foundation.dart';
 import 'package:flutter/gestures.dart';
 import 'package:test/test.dart';
 
@@ -259,4 +260,28 @@
     tap.dispose();
   });
 
+  testGesture('Should log exceptions from callbacks', (GestureTester tester) {
+    TapGestureRecognizer tap = new TapGestureRecognizer();
+
+    tap.onTap = () {
+      throw new Exception(test);
+    };
+
+    FlutterExceptionHandler previousErrorHandler = FlutterError.onError;
+    bool gotError = false;
+    FlutterError.onError = (FlutterErrorDetails details) {
+      gotError = true;
+    };
+
+    tap.addPointer(down1);
+    tester.closeArena(1);
+    tester.route(down1);
+    expect(gotError, isFalse);
+
+    tester.route(up1);
+    expect(gotError, isTrue);
+
+    FlutterError.onError = previousErrorHandler;
+    tap.dispose();
+  });
 }