[PointerEventsListener] Resample pointer move events.

This results in smooth touch event processing on devices
without high frequency touch sensors.

This also sets physicalDeltaX/Y correctly.

Bug: fxb/35050
Test: fx run-host-tests dart_widget_tests
Change-Id: I8149a1f8dde964ca62aa41579d5361cc97a77f9a
diff --git a/public/dart/widgets/BUILD.gn b/public/dart/widgets/BUILD.gn
index c483ecb..7e659ca 100644
--- a/public/dart/widgets/BUILD.gn
+++ b/public/dart/widgets/BUILD.gn
@@ -56,6 +56,7 @@
 flutter_test("dart_widget_tests") {
   sources = [
     "alphatar_test.dart",
+    "pointer_events_listener_test.dart",
     "providers_test.dart",
     "rk4_spring_simulation_test.dart",
   ]
diff --git a/public/dart/widgets/lib/src/utils/pointer_events_listener.dart b/public/dart/widgets/lib/src/utils/pointer_events_listener.dart
index 56a5003..f29893f 100644
--- a/public/dart/widgets/lib/src/utils/pointer_events_listener.dart
+++ b/public/dart/widgets/lib/src/utils/pointer_events_listener.dart
@@ -11,6 +11,25 @@
 
 import 'package:flutter/scheduler.dart';
 
+// Sampling offset is relative to presentation time. If we produce frames
+// 16.667 ms before presentation and input rate is ~60hz, worst case latency
+// is 33.334 ms. This however assumes zero latency from the input driver.
+// 4.666 ms margin is added for this.
+const _defaultSamplingOffset = Duration(milliseconds: -38);
+
+class _Event {
+  PointerEvent p;
+  Flow flow1;
+  Flow flow2;
+  _Event(this.p, this.flow1, this.flow2);
+}
+
+class _DownPointer {
+  _Event last;
+  _Event next;
+  _DownPointer(this.last, this.next);
+}
+
 /// Listens for pointer events and injects them into Flutter input pipeline.
 class PointerEventsListener extends PointerCaptureListenerHack {
   // Holds the fidl binding to receive pointer events.
@@ -20,71 +39,85 @@
   // Holds the last [PointerEvent] mapped to its pointer id. This is used to
   // determine the correct [PointerDataPacket] to generate at boundary condition
   // of the screen rect.
-  final Map<int, PointerEvent> _lastPointerEvent = <int, PointerEvent>{};
+  final _lastPointerEvent = <int, PointerEvent>{};
 
   // Flag to remember that a down event was seen before a move event.
   // TODO(sanjayc): Should really convert to a FSM for PointerEvent.
-  final Map<int, bool> _downEvent = <int, bool>{};
+  final _downEvent = <int, bool>{};
 
-  // Holds the [onPointerDataCallback] assigned to [ui.window] at
-  // the start of the program.
-  ui.PointerDataPacketCallback _originalCallback;
+  // Scheduler used for frame callbacks.
+  var _scheduler;
 
-  final _queuedEvents = <PointerEvent>[];
-  bool _frameScheduled = false;
+  // [onPointerDataCallback] used to dispatch pointer data callbacks.
+  var _callback;
+
+  // Offset used for re-sampling of move events.
+  var _samplingOffset;
+
+  final _queuedEvents = <_Event>[];
+  var _frameCallbackScheduled = false;
+
+  var _sampleTimeNs = 0;
+  final _downPointers = <int, _DownPointer>{};
+
+  PointerEventsListener(
+      {SchedulerBinding scheduler,
+      ui.PointerDataPacketCallback callback,
+      Duration samplingOffset}) {
+    _scheduler = scheduler ?? SchedulerBinding.instance;
+    _samplingOffset = samplingOffset ?? _defaultSamplingOffset;
+    _callback = callback;
+  }
 
   /// Starts listening to pointer events. Also overrides the original
   /// [ui.window.onPointerDataPacket] callback to a NOP since we
   /// inject the pointer events received from the [Presentation] service.
   void listen(PresentationProxy presentation) {
-    _originalCallback = ui.window.onPointerDataPacket;
+    _callback = ui.window.onPointerDataPacket;
     ui.window.onPointerDataPacket = (ui.PointerDataPacket packet) {};
 
-    if (_pointerCaptureListenerBinding.isUnbound) {
-      presentation
-          .capturePointerEventsHack(_pointerCaptureListenerBinding.wrap(this));
-    }
+    presentation
+        .capturePointerEventsHack(_pointerCaptureListenerBinding.wrap(this));
   }
 
   /// Stops listening to pointer events. Also restores the
   /// [ui.window.onPointerDataPacket] callback.
   void stop() {
-    if (_originalCallback != null) {
+    if (_callback != null) {
       _cleanupPointerEvents();
-      if (_pointerCaptureListenerBinding.isBound) {
-        _pointerCaptureListenerBinding.unbind();
-      }
       _pointerCaptureListenerBinding.close();
 
       // Restore the original pointer events callback on the window.
-      ui.window.onPointerDataPacket = _originalCallback;
-      _originalCallback = null;
+      ui.window.onPointerDataPacket = _callback;
+      _callback = null;
       _lastPointerEvent.clear();
       _downEvent.clear();
     }
   }
 
   void _cleanupPointerEvents() {
-    for (PointerEvent lastEvent in _lastPointerEvent.values.toList()) {
+    for (final lastEvent in _lastPointerEvent.values.toList()) {
       if (lastEvent.phase != PointerEventPhase.remove &&
           lastEvent.type != PointerEventType.mouse) {
-        onPointerEvent(_clone(lastEvent, PointerEventPhase.remove));
+        onPointerEvent(_clone(lastEvent, PointerEventPhase.remove, lastEvent.x,
+            lastEvent.y, lastEvent.eventTime));
       }
     }
   }
 
-  PointerEvent _clone(PointerEvent event, [PointerEventPhase phase]) {
+  PointerEvent _clone(PointerEvent event, PointerEventPhase phase, double x,
+      double y, int eventTime) {
     return PointerEvent(
         buttons: event.buttons,
         deviceId: event.deviceId,
-        eventTime: event.eventTime,
-        phase: phase ?? event.phase,
+        eventTime: eventTime,
+        phase: phase,
         pointerId: event.pointerId,
         radiusMajor: event.radiusMajor,
         radiusMinor: event.radiusMinor,
         type: event.type,
-        x: event.x,
-        y: event.y);
+        x: x,
+        y: y);
   }
 
   /// |PointerCaptureListener|.
@@ -94,20 +127,198 @@
   }
 
   void _onPointerEvent(PointerEvent event) {
-    if (_originalCallback == null) {
+    if (_callback == null) {
       return;
     }
 
-    Timeline.startSync('PointerEventsListener.onPointerEvent');
-    final packet = _getPacket(event);
-    if (packet != null) {
-      _originalCallback(ui.PointerDataPacket(data: [packet]));
+    final eventArguments = <String, int>{
+      'eventTimeUs': event.eventTime ~/ 1000,
+    };
+    final flow1 = Flow.begin();
+    Timeline.timeSync('PointerEventsListener.onPointerEvent', () {
+      final flow2 = Flow.begin();
+      Timeline.timeSync('PointerEventsListener.queueEvent', () {
+        _queuedEvents.add(_Event(event, flow1, flow2));
+      }, flow: flow2);
+      _dispatchQueuedEvents();
+    }, arguments: eventArguments, flow: flow1);
+  }
+
+  void _dispatchQueuedEvents() {
+    Timeline.timeSync('PointerEventsListener.dispatchQueuedEvents', () {
+      _consumePendingEvents();
+      _updateDownPointers();
+      _dispatchMoveChanges();
+
+      // Schedule frame callback if down pointers exists or events are
+      // still queued. We need the frame callback to determine sample
+      // time. This however makes us produce frames whenever touch points
+      // are present. Probably OK as we'll likely receive an update to
+      // the touch point location each frame that will result in us
+      // actually having to produce a frame.
+      if (_queuedEvents.isNotEmpty || _downPointers.isNotEmpty) {
+        _scheduleFrameCallback();
+      }
+    });
+  }
+
+  void _consumePendingEvents() {
+    final packets = <ui.PointerData>[];
+
+    while (_queuedEvents.isNotEmpty) {
+      final event = _queuedEvents.first;
+
+      // Stop consuming events if more recent than current sample time.
+      if (event.p.eventTime > _sampleTimeNs) {
+        break;
+      }
+
+      switch (event.p.phase) {
+        case PointerEventPhase.down:
+          _downPointers[event.p.pointerId] = _DownPointer(event, event);
+          break;
+        case PointerEventPhase.move:
+          _downPointers[event.p.pointerId].next = event;
+          break;
+        case PointerEventPhase.up:
+        case PointerEventPhase.cancel:
+          _downPointers.remove(event.p.pointerId);
+          break;
+        case PointerEventPhase.add:
+        case PointerEventPhase.remove:
+        case PointerEventPhase.hover:
+          break;
+      }
+
+      // Add non-move changes without re-sampling.
+      if (event.p.phase != PointerEventPhase.move) {
+        final eventArguments = <String, int>{
+          'eventTimeUs': event.p.eventTime ~/ 1000,
+        };
+        Timeline.timeSync('PointerEventsListener.consumePendingEvent', () {
+          final packet = _getPacket(event.p);
+          if (packet != null) {
+            packets.add(packet);
+          }
+        }, arguments: eventArguments, flow: Flow.end(event.flow1.id));
+      }
+
+      _queuedEvents.removeAt(0);
     }
-    Timeline.finishSync();
+
+    if (packets.isNotEmpty) {
+      Timeline.timeSync('PointerEventsListener.dispatchPackets', () {
+        _callback(ui.PointerDataPacket(data: packets));
+      });
+    }
+  }
+
+  void _updateDownPointers() {
+    // Update [_downPointers] by examining queued changes.
+    for (var event in _queuedEvents) {
+      final p = _downPointers[event.p.pointerId];
+      if (p != null) {
+        switch (event.p.phase) {
+          case PointerEventPhase.down:
+          case PointerEventPhase.move:
+          case PointerEventPhase.cancel:
+          case PointerEventPhase.up:
+            // Update next event if not already passed sample time.
+            if (p.next.p.eventTime < _sampleTimeNs) {
+              _downPointers[event.p.pointerId].next = event;
+            }
+            break;
+          case PointerEventPhase.add:
+          case PointerEventPhase.remove:
+          case PointerEventPhase.hover:
+            break;
+        }
+      }
+    }
+  }
+
+  void _dispatchMoveChanges() {
+    final packets = <ui.PointerData>[];
+
+    // Add move changes for [_downPointers].
+    for (var v in _downPointers.values) {
+      var x = v.next.p.x;
+      var y = v.next.p.y;
+      var eventTime = v.next.p.eventTime;
+      // Re-sample if next time stamp is past sample time.
+      if (v.next.p.eventTime > _sampleTimeNs &&
+          v.next.p.eventTime > v.last.p.eventTime) {
+        var resampleArguments = <String, int>{
+          'lastEventTimeUs': v.last.p.eventTime ~/ 1000,
+          'nextEventTimeUs': v.next.p.eventTime ~/ 1000
+        };
+        Timeline.timeSync('PointerEventsListener.resampledEvent', () {
+          final interval = (v.next.p.eventTime - v.last.p.eventTime).toDouble();
+          final scalar =
+              (_sampleTimeNs - v.last.p.eventTime).toDouble() / interval;
+          x = v.last.p.x + (v.next.p.x - v.last.p.x) * scalar;
+          y = v.last.p.y + (v.next.p.y - v.last.p.y) * scalar;
+          eventTime = _sampleTimeNs;
+        }, arguments: resampleArguments);
+      }
+
+      // Add move change if time stamp is greater than last event.
+      if (eventTime > v.last.p.eventTime) {
+        final event = _clone(v.next.p, PointerEventPhase.move, x, y, eventTime);
+        final eventArguments = <String, int>{
+          'eventTimeUs': eventTime ~/ 1000,
+        };
+        Timeline.timeSync('PointerEventsListener.addMoveEvent', () {
+          final packet = _getPacket(event);
+          if (packet != null) {
+            packets.add(packet);
+          }
+        }, arguments: eventArguments, flow: Flow.end(v.last.flow2.id));
+
+        Timeline.timeSync('PointerEventsListener.updateLastEvent', () {
+          v.last.p = event;
+          v.last.flow2 = v.next.flow2;
+        }, flow: Flow.end(v.next.flow1.id));
+      }
+    }
+
+    if (packets.isNotEmpty) {
+      Timeline.timeSync('PointerEventsListener.dispatchPackets', () {
+        _callback(ui.PointerDataPacket(data: packets));
+      });
+    }
+  }
+
+  void _scheduleFrameCallback() {
+    if (_frameCallbackScheduled) {
+      return;
+    }
+    _frameCallbackScheduled = true;
+    _scheduler.scheduleFrameCallback((_) {
+      _frameCallbackScheduled = false;
+      _sampleTimeNs = (_scheduler.currentSystemFrameTimeStamp + _samplingOffset)
+              .inMicroseconds *
+          1000;
+      var frameArguments = <String, int>{
+        'frameTimeUs': _scheduler.currentSystemFrameTimeStamp.inMicroseconds,
+      };
+      if (_queuedEvents.isNotEmpty) {
+        final lastEventTime = _queuedEvents.last.p.eventTime;
+        frameArguments['lastEventTimeUs'] = lastEventTime ~/ 1000;
+        frameArguments['eventTimeMarginUs'] =
+            (lastEventTime - _sampleTimeNs) ~/ 1000;
+      }
+      Timeline.timeSync('PointerEventsListener.onFrameCallback', () {
+        _dispatchQueuedEvents();
+      }, arguments: frameArguments);
+    });
+    Timeline.timeSync('PointerEventsListener.scheduleFrameCallback', () {
+      _scheduler.scheduleFrame();
+    });
   }
 
   ui.PointerChange _changeFromPointerEvent(PointerEvent event) {
-    PointerEvent lastEvent = _lastPointerEvent[event.pointerId] ?? event;
+    final lastEvent = _lastPointerEvent[event.pointerId] ?? event;
 
     switch (event.phase) {
       case PointerEventPhase.add:
@@ -174,7 +385,7 @@
   }
 
   ui.PointerData _getPacket(PointerEvent event) {
-    PointerEvent lastEvent = _lastPointerEvent[event.pointerId] ?? event;
+    final lastEvent = _lastPointerEvent[event.pointerId];
 
     // Only allow add and remove pointer events from outside the window bounds.
     // For other events, we drop them if the last two were outside the window
@@ -183,11 +394,16 @@
     if (event.phase != PointerEventPhase.add &&
         event.phase != PointerEventPhase.remove &&
         _outside(event) &&
-        _outside(lastEvent)) {
+        _outside(lastEvent ?? event)) {
       _lastPointerEvent[event.pointerId] = event;
       return null;
     }
 
+    // Calculate the offset between two events.
+    final delta = lastEvent != null
+        ? ui.Offset(event.x - lastEvent.x, event.y - lastEvent.y)
+        : ui.Offset(0, 0);
+
     // Convert from PointerEvent to PointerData.
     final data = ui.PointerData(
       buttons: event.buttons,
@@ -195,8 +411,12 @@
       timeStamp: Duration(microseconds: event.eventTime ~/ 1000),
       change: _changeFromPointerEvent(event),
       kind: _kindFromPointerEvent(event),
+      physicalDeltaX: delta.dx * ui.window.devicePixelRatio,
+      physicalDeltaY: delta.dy * ui.window.devicePixelRatio,
       physicalX: event.x * ui.window.devicePixelRatio,
       physicalY: event.y * ui.window.devicePixelRatio,
+      pointerIdentifier: event.pointerId,
+      synthesized: false,
     );
 
     // Remember this event for checking boundary condition on the next event.
diff --git a/public/dart/widgets/test/pointer_events_listener_test.dart b/public/dart/widgets/test/pointer_events_listener_test.dart
new file mode 100644
index 0000000..31e7b80
--- /dev/null
+++ b/public/dart/widgets/test/pointer_events_listener_test.dart
@@ -0,0 +1,133 @@
+// Copyright 2020 The Fuchsia 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 'dart:ui' as ui;
+
+import 'package:flutter/scheduler.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:lib.widgets/utils.dart';
+import 'package:mockito/mockito.dart';
+import 'package:fidl_fuchsia_ui_input/fidl_async.dart';
+
+class MockSchedulerBinding extends Mock implements SchedulerBinding {}
+
+const _samplingOffset = Duration(microseconds: -500);
+
+void main() {
+  final result = <ui.PointerData>[];
+
+  MockSchedulerBinding scheduler;
+  PointerEventsListener pointerEventsListener;
+
+  setUpAll(() {
+    scheduler = MockSchedulerBinding();
+    pointerEventsListener = PointerEventsListener(
+        scheduler: scheduler,
+        callback: (ui.PointerDataPacket packet) {
+          result.addAll(packet.data);
+        },
+        samplingOffset: _samplingOffset);
+  });
+
+  PointerEvent _createSimulatedPointerEvent(
+      PointerEventPhase phase, int eventTimeUs, double x, double y) {
+    return PointerEvent(
+        buttons: 0,
+        deviceId: 0,
+        pointerId: 1,
+        eventTime: eventTimeUs * 1000,
+        phase: phase,
+        type: PointerEventType.touch,
+        x: x,
+        y: y);
+  }
+
+  test('resampling', () {
+    final event0 =
+        _createSimulatedPointerEvent(PointerEventPhase.down, 1000, 0.0, 0.0);
+    final event1 =
+        _createSimulatedPointerEvent(PointerEventPhase.move, 2000, 10.0, 0.0);
+    final event2 =
+        _createSimulatedPointerEvent(PointerEventPhase.move, 3000, 20.0, 0.0);
+    final event3 =
+        _createSimulatedPointerEvent(PointerEventPhase.up, 4000, 30.0, 0.0);
+
+    pointerEventsListener
+      ..onPointerEvent(event0)
+      ..onPointerEvent(event1)
+      ..onPointerEvent(event2)
+      ..onPointerEvent(event3);
+
+    // No pointer events should have been dispatched yet.
+    expect(result.isEmpty, true);
+
+    // Frame callback should have been requested.
+    FrameCallback callback =
+        verify(scheduler.scheduleFrameCallback(captureThat(isNotNull)))
+            .captured
+            .single;
+    verify(scheduler.scheduleFrame());
+    clearInteractions(scheduler);
+
+    var frameTime = Duration(milliseconds: 2);
+    when(scheduler.currentSystemFrameTimeStamp).thenReturn(frameTime);
+    callback(Duration());
+
+    // Two pointer events should have been dispatched.
+    expect(result.length, 2);
+    expect(result[0].timeStamp, Duration(milliseconds: 1));
+    expect(result[0].change, ui.PointerChange.down);
+    expect(result[0].physicalX, 0.0);
+    expect(result[0].physicalY, 0.0);
+    expect(result[0].physicalDeltaX, 0.0);
+    expect(result[0].physicalDeltaY, 0.0);
+
+    expect(result[1].timeStamp, frameTime + _samplingOffset);
+    expect(result[1].change, ui.PointerChange.move);
+    expect(result[1].physicalX, 5.0 * ui.window.devicePixelRatio);
+    expect(result[1].physicalY, 0.0);
+    expect(result[1].physicalDeltaX, 5.0 * ui.window.devicePixelRatio);
+    expect(result[1].physicalDeltaY, 0.0);
+
+    // Another frame callback should have been requested.
+    callback = verify(scheduler.scheduleFrameCallback(captureThat(isNotNull)))
+        .captured
+        .single;
+    verify(scheduler.scheduleFrame());
+    clearInteractions(scheduler);
+
+    frameTime = Duration(milliseconds: 4);
+    when(scheduler.currentSystemFrameTimeStamp).thenReturn(frameTime);
+    callback(Duration());
+
+    // Another pointer event should have been dispatched.
+    expect(result.length, 3);
+    expect(result[2].timeStamp, frameTime + _samplingOffset);
+    expect(result[2].change, ui.PointerChange.move);
+    expect(result[2].physicalX, 25.0 * ui.window.devicePixelRatio);
+    expect(result[2].physicalY, 0.0);
+    expect(result[2].physicalDeltaX, 20.0 * ui.window.devicePixelRatio);
+    expect(result[2].physicalDeltaY, 0.0);
+
+    // Another frame callback should have been requested.
+    callback = verify(scheduler.scheduleFrameCallback(captureThat(isNotNull)))
+        .captured
+        .single;
+    verify(scheduler.scheduleFrame());
+    clearInteractions(scheduler);
+
+    frameTime = Duration(milliseconds: 6);
+    when(scheduler.currentSystemFrameTimeStamp).thenReturn(frameTime);
+    callback(Duration());
+
+    // Last pointer event should have been dispatched.
+    expect(result.length, 4);
+    expect(result[3].timeStamp, Duration(milliseconds: 4));
+    expect(result[3].change, ui.PointerChange.up);
+    expect(result[3].physicalX, 30.0 * ui.window.devicePixelRatio);
+    expect(result[3].physicalY, 0.0);
+    expect(result[3].physicalDeltaX, 5.0 * ui.window.devicePixelRatio);
+    expect(result[3].physicalDeltaY, 0.0);
+  });
+}