Commit 24ea0f0d authored by Kris Giesing's avatar Kris Giesing Committed by kgiesing

Move velocity tracker to dart

parent 56b25763
...@@ -10,8 +10,10 @@ export 'src/gestures/constants.dart'; ...@@ -10,8 +10,10 @@ export 'src/gestures/constants.dart';
export 'src/gestures/drag.dart'; export 'src/gestures/drag.dart';
export 'src/gestures/events.dart'; export 'src/gestures/events.dart';
export 'src/gestures/long_press.dart'; export 'src/gestures/long_press.dart';
export 'src/gestures/lsq_solver.dart';
export 'src/gestures/pointer_router.dart'; export 'src/gestures/pointer_router.dart';
export 'src/gestures/recognizer.dart'; export 'src/gestures/recognizer.dart';
export 'src/gestures/scale.dart'; export 'src/gestures/scale.dart';
export 'src/gestures/show_press.dart'; export 'src/gestures/show_press.dart';
export 'src/gestures/tap.dart'; export 'src/gestures/tap.dart';
export 'src/gestures/velocity_tracker.dart';
...@@ -8,6 +8,7 @@ import 'arena.dart'; ...@@ -8,6 +8,7 @@ import 'arena.dart';
import 'recognizer.dart'; import 'recognizer.dart';
import 'constants.dart'; import 'constants.dart';
import 'events.dart'; import 'events.dart';
import 'velocity_tracker.dart';
enum DragState { enum DragState {
ready, ready,
...@@ -25,9 +26,7 @@ typedef void GesturePanEndCallback(ui.Offset velocity); ...@@ -25,9 +26,7 @@ typedef void GesturePanEndCallback(ui.Offset velocity);
typedef void _GesturePolymorphicUpdateCallback<T>(T delta); typedef void _GesturePolymorphicUpdateCallback<T>(T delta);
int _eventTime(PointerInputEvent event) => (event.timeStamp * 1000.0).toInt(); // microseconds bool _isFlingGesture(GestureVelocity velocity) {
bool _isFlingGesture(ui.GestureVelocity velocity) {
double velocitySquared = velocity.x * velocity.x + velocity.y * velocity.y; double velocitySquared = velocity.x * velocity.x + velocity.y * velocity.y;
return velocity.isValid && return velocity.isValid &&
velocitySquared > kMinFlingVelocity * kMinFlingVelocity && velocitySquared > kMinFlingVelocity * kMinFlingVelocity &&
...@@ -50,11 +49,11 @@ abstract class _DragGestureRecognizer<T extends dynamic> extends GestureRecogniz ...@@ -50,11 +49,11 @@ abstract class _DragGestureRecognizer<T extends dynamic> extends GestureRecogniz
T _getDragDelta(PointerInputEvent event); T _getDragDelta(PointerInputEvent event);
bool get _hasSufficientPendingDragDeltaToAccept; bool get _hasSufficientPendingDragDeltaToAccept;
Map<int, ui.VelocityTracker> _velocityTrackers = new Map<int, ui.VelocityTracker>(); Map<int, VelocityTracker> _velocityTrackers = new Map<int, VelocityTracker>();
void addPointer(PointerInputEvent event) { void addPointer(PointerInputEvent event) {
startTrackingPointer(event.pointer); startTrackingPointer(event.pointer);
_velocityTrackers[event.pointer] = new ui.VelocityTracker(); _velocityTrackers[event.pointer] = new VelocityTracker();
if (_state == DragState.ready) { if (_state == DragState.ready) {
_state = DragState.possible; _state = DragState.possible;
_initialPosition = event.position; _initialPosition = event.position;
...@@ -65,9 +64,9 @@ abstract class _DragGestureRecognizer<T extends dynamic> extends GestureRecogniz ...@@ -65,9 +64,9 @@ abstract class _DragGestureRecognizer<T extends dynamic> extends GestureRecogniz
void handleEvent(PointerInputEvent event) { void handleEvent(PointerInputEvent event) {
assert(_state != DragState.ready); assert(_state != DragState.ready);
if (event.type == 'pointermove') { if (event.type == 'pointermove') {
ui.VelocityTracker tracker = _velocityTrackers[event.pointer]; VelocityTracker tracker = _velocityTrackers[event.pointer];
assert(tracker != null); assert(tracker != null);
tracker.addPosition(_eventTime(event), event.x, event.y); tracker.addPosition(event.timeStamp, event.x, event.y);
T delta = _getDragDelta(event); T delta = _getDragDelta(event);
if (_state == DragState.accepted) { if (_state == DragState.accepted) {
if (onUpdate != null) if (onUpdate != null)
...@@ -102,10 +101,10 @@ abstract class _DragGestureRecognizer<T extends dynamic> extends GestureRecogniz ...@@ -102,10 +101,10 @@ abstract class _DragGestureRecognizer<T extends dynamic> extends GestureRecogniz
bool wasAccepted = (_state == DragState.accepted); bool wasAccepted = (_state == DragState.accepted);
_state = DragState.ready; _state = DragState.ready;
if (wasAccepted && onEnd != null) { if (wasAccepted && onEnd != null) {
ui.VelocityTracker tracker = _velocityTrackers[pointer]; VelocityTracker tracker = _velocityTrackers[pointer];
assert(tracker != null); assert(tracker != null);
ui.GestureVelocity gestureVelocity = tracker.getVelocity(); GestureVelocity gestureVelocity = tracker.getVelocity();
ui.Offset velocity = ui.Offset.zero; ui.Offset velocity = ui.Offset.zero;
if (_isFlingGesture(gestureVelocity)) if (_isFlingGesture(gestureVelocity))
velocity = new ui.Offset(gestureVelocity.x, gestureVelocity.y); velocity = new ui.Offset(gestureVelocity.x, gestureVelocity.y);
......
import "dart:math" as math;
import "dart:typed_data";
class Vector {
Vector(int size)
: _offset = 0, _length = size, _elem = new Float64List(size);
Vector.fromValues(List<double> values)
: _offset = 0, _length = values.length, _elem = values;
Vector.fromVOL(List<double> values, int offset, int length)
: _offset = offset, _length = length, _elem = values;
int get length => _length;
operator [](int i) => _elem[i + _offset];
operator []=(int i, double value) => _elem[i + _offset] = value;
operator *(Vector a) {
double result = 0.0;
for (int i = 0; i < _length; i++) {
result += this[i] * a[i];
}
return result;
}
double norm() => math.sqrt(this * this);
String toString() {
String result = "";
for (int i = 0; i < _length; i++) {
if (i > 0)
result += ", ";
result += this[i].toString();
}
return result;
}
final int _offset;
final int _length;
final List<double> _elem;
}
class Matrix {
Matrix(int rows, int cols)
: _rows = rows,
_cols = cols,
_elem = new Float64List(rows * cols);
double get(int row, int col) => _elem[row * _cols + col];
void set(int row, int col, double value) {
_elem[row * _cols + col] = value;
}
Vector getRow(int row) => new Vector.fromVOL(_elem, row * _cols, _cols);
String toString() {
String result = "";
for (int i = 0; i < _rows; i++) {
if (i > 0)
result += "; ";
for (int j = 0; j < _cols; j++) {
if (j > 0)
result += ", ";
result += get(i, j).toString();
}
}
return result;
}
final int _rows;
final int _cols;
final List<double> _elem;
}
class PolynomialFit {
PolynomialFit(int degree) : coefficients = new Float64List(degree + 1);
final List<double> coefficients;
double confidence;
}
class LeastSquaresSolver {
LeastSquaresSolver(this.x, this.y, this.w) {
assert(x.length == y.length);
assert(y.length == w.length);
}
final List<double> x;
final List<double> y;
final List<double> w;
PolynomialFit solve(int degree) {
if (degree > x.length) // not enough data to fit a curve
return null;
PolynomialFit result = new PolynomialFit(degree);
// Shorthands for the purpose of notation equivalence to original C++ code
final int m = x.length;
final int n = degree + 1;
final List<double> out_b = result.coefficients;
// Expand the X vector to a matrix A, pre-multiplied by the weights.
Matrix a = new Matrix(n, m);
for (int h = 0; h < m; h++) {
a.set(0, h, w[h]);
for (int i = 1; i < n; i++) {
a.set(i, h, a.get(i - 1, h) * x[h]);
}
}
// Apply the Gram-Schmidt process to A to obtain its QR decomposition.
// Orthonormal basis, column-major ordVectorer.
Matrix q = new Matrix(n, m);
// Upper triangular matrix, row-major order.
Matrix r = new Matrix(n, n);
for (int j = 0; j < n; j++) {
for (int h = 0; h < m; h++) {
q.set(j, h, a.get(j, h));
}
for (int i = 0; i < j; i++) {
double dot = q.getRow(j)*q.getRow(i);
for (int h = 0; h < m; h++) {
q.set(j, h, q.get(j, h) - dot * q.get(i, h));
}
}
double norm = q.getRow(j).norm();
if (norm < 0.000001) {
// vectors are linearly dependent or zero so no solution
return null;
}
double invNorm = 1.0 / norm;
for (int h = 0; h < m; h++) {
q.set(j, h, q.get(j, h) * invNorm);
}
for (int i = 0; i < n; i++) {
r.set(j, i, i < j ? 0.0 : q.getRow(j)*a.getRow(i));
}
}
// Solve R B = Qt W Y to find B. This is easy because R is upper triangular.
// We just work from bottom-right to top-left calculating B's coefficients.
Vector wy = new Vector(m);
for (int h = 0; h < m; h++) {
wy[h] = y[h] * w[h];
}
for (int i = n; i-- != 0;) {
out_b[i] = q.getRow(i) * wy;
for (int j = n - 1; j > i; j--) {
out_b[i] -= r.get(i, j) * out_b[j];
}
out_b[i] /= r.get(i, i);
}
// Calculate the coefficient of determination as 1 - (SSerr / SStot) where
// SSerr is the residual sum of squares (variance of the error),
// and SStot is the total sum of squares (variance of the data) where each
// has been weighted.
double ymean = 0.0;
for (int h = 0; h < m; h++) {
ymean += y[h];
}
ymean /= m;
double sserr = 0.0;
double sstot = 0.0;
for (int h = 0; h < m; h++) {
double err = y[h] - out_b[0];
double term = 1.0;
for (int i = 1; i < n; i++) {
term *= x[h];
err -= term * out_b[i];
}
sserr += w[h] * w[h] * err * err;
double v = y[h] - ymean;
sstot += w[h] * w[h] * v * v;
}
double det = sstot > 0.000001 ? 1.0 - (sserr / sstot) : 1.0;
result.confidence = det;
return result;
}
}
import 'dart:ui' as ui;
import 'lsq_solver.dart';
class GestureVelocity {
GestureVelocity({ this.isValid: false, this.x: 0.0, this.y : 0.0 });
final bool isValid;
final double x;
final double y;
}
class Estimator {
int degree;
double time;
List<double> xcoeff;
List<double> ycoeff;
double confidence;
String toString() {
String result = "Estimator(degree: " + degree.toString();
result += ", time: " + time.toString();
result += ", confidence: " + confidence.toString();
result += ", xcoeff: " + (new Vector.fromValues(xcoeff)).toString();
result += ", ycoeff: " + (new Vector.fromValues(ycoeff)).toString();
return result;
}
}
abstract class VelocityTrackerStrategy {
void addMovement(double timeStamp, double x, double y);
bool getEstimator(Estimator estimator);
void clear();
}
enum Weighting {
WEIGHTING_NONE,
WEIGHTING_DELTA,
WEIGHTING_CENTRAL,
WEIGHTING_RECENT
}
class Movement {
double event_time = 0.0;
ui.Point position = ui.Point.origin;
}
class LeastSquaresVelocityTrackerStrategy extends VelocityTrackerStrategy {
static const int kHistorySize = 20;
static const int kHorizonMS = 100;
LeastSquaresVelocityTrackerStrategy(this.degree, this.weighting)
: _index = 0, _movements = new List<Movement>(kHistorySize);
final int degree;
final Weighting weighting;
final List<Movement> _movements;
int _index;
void addMovement(double timeStamp, double x, double y) {
if (++_index == kHistorySize)
_index = 0;
Movement movement = _getMovement(_index);
movement.event_time = timeStamp;
movement.position = new ui.Point(x, y);
}
bool getEstimator(Estimator estimator) {
// Iterate over movement samples in reverse time order and collect samples.
List<double> x = new List<double>();
List<double> y = new List<double>();
List<double> w = new List<double>();
List<double> time = new List<double>();
int m = 0;
int index = _index;
Movement newest_movement = _getMovement(index);
do {
Movement movement = _getMovement(index);
double age = newest_movement.event_time - movement.event_time;
if (age > kHorizonMS)
break;
ui.Point position = movement.position;
x.add(position.x);
y.add(position.y);
w.add(_chooseWeight(index));
time.add(-age);
index = (index == 0 ? kHistorySize : index) - 1;
} while (++m < kHistorySize);
if (m == 0)
return false; // no data
// Calculate a least squares polynomial fit.
int n = degree;
if (n > m - 1)
n = m - 1;
if (n >= 1) {
LeastSquaresSolver xSolver = new LeastSquaresSolver(time, x, w);
PolynomialFit xFit = xSolver.solve(n);
if (xFit != null) {
LeastSquaresSolver ySolver = new LeastSquaresSolver(time, y, w);
PolynomialFit yFit = ySolver.solve(n);
if (yFit != null) {
estimator.xcoeff = xFit.coefficients;
estimator.ycoeff = yFit.coefficients;
estimator.time = newest_movement.event_time;
estimator.degree = n;
estimator.confidence = xFit.confidence * yFit.confidence;
return true;
}
}
}
// No velocity data available for this pointer, but we do have its current
// position.
estimator.xcoeff = [ x[0] ];
estimator.ycoeff = [ y[0] ];
estimator.time = newest_movement.event_time;
estimator.degree = 0;
estimator.confidence = 1.0;
return true;
}
void clear() {
_index = -1;
}
double _chooseWeight(int index) {
switch (weighting) {
case Weighting.WEIGHTING_DELTA:
// Weight points based on how much time elapsed between them and the next
// point so that points that "cover" a shorter time span are weighed less.
// delta 0ms: 0.5
// delta 10ms: 1.0
if (index == _index) {
return 1.0;
}
int next_index = (index + 1) % kHistorySize;
double delta_millis = _movements[next_index].event_time -
_movements[index].event_time;
if (delta_millis < 0)
return 0.5;
if (delta_millis < 10)
return 0.5 + delta_millis * 0.05;
return 1.0;
case Weighting.WEIGHTING_CENTRAL:
// Weight points based on their age, weighing very recent and very old
// points less.
// age 0ms: 0.5
// age 10ms: 1.0
// age 50ms: 1.0
// age 60ms: 0.5
double age_millis = _movements[_index].event_time -
_movements[index].event_time;
if (age_millis < 0)
return 0.5;
if (age_millis < 10)
return 0.5 + age_millis * 0.05;
if (age_millis < 50)
return 1.0;
if (age_millis < 60)
return 0.5 + (60 - age_millis) * 0.05;
return 0.5;
case Weighting.WEIGHTING_RECENT:
// Weight points based on their age, weighing older points less.
// age 0ms: 1.0
// age 50ms: 1.0
// age 100ms: 0.5
double age_millis = _movements[_index].event_time -
_movements[index].event_time;
if (age_millis < 50) {
return 1.0;
}
if (age_millis < 100) {
return 0.5 + (100 - age_millis) * 0.01;
}
return 0.5;
case Weighting.WEIGHTING_NONE:
default:
return 1.0;
}
}
Movement _getMovement(int i) {
Movement result = _movements[i];
if (result == null) {
result = new Movement();
_movements[i] = result;
}
return result;
}
}
class VelocityTracker {
static const int kAssumePointerMoveStoppedTimeMs = 40;
VelocityTracker() : _lastTimeStamp = 0.0, _strategy = _createStrategy();
double _lastTimeStamp;
VelocityTrackerStrategy _strategy;
void addPosition(double timeStamp, double x, double y) {
if ((timeStamp - _lastTimeStamp) >= kAssumePointerMoveStoppedTimeMs)
_strategy.clear();
_lastTimeStamp = timeStamp;
_strategy.addMovement(timeStamp, x, y);
}
GestureVelocity getVelocity() {
Estimator estimator = new Estimator();
if (_strategy.getEstimator(estimator) && estimator.degree >= 1) {
// convert from pixels/ms to pixels/s
return new GestureVelocity(
isValid: true,
x: estimator.xcoeff[1]*1000,
y: estimator.ycoeff[1]*1000
);
}
return new GestureVelocity(isValid: false, x: 0.0, y: 0.0);
}
static VelocityTrackerStrategy _createStrategy() {
return new LeastSquaresVelocityTrackerStrategy(2, Weighting.WEIGHTING_NONE);
}
}
// Copyright 2015 The Chromium 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:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'basic.dart';
import 'framework.dart';
enum EventRecorderMode {
stop,
record
}
typedef void EventsReady(List<PointerInputEvent> events);
class EventRecorder extends StatefulComponent {
EventRecorder({
Key key,
this.child,
this.mode: EventRecorderMode.stop,
this.onEventsReady
});
final Widget child;
final EventRecorderMode mode;
final EventsReady onEventsReady;
_EventRecorderState createState() => new _EventRecorderState();
}
class _EventRecorderState extends State<EventRecorder> {
EventRecorderMode _mode;
List<PointerInputEvent> _events = new List<PointerInputEvent>();
void initState() {
super.initState();
_mode = config.mode;
}
void didUpdateConfig(EventRecorder oldConfig) {
if (_mode == EventRecorderMode.record &&
config.mode == EventRecorderMode.stop) {
config.onEventsReady(_events);
_events.clear();
}
_mode = config.mode;
}
void _recordEvent(PointerInputEvent event) {
if (_mode == EventRecorderMode.record) {
_events.add(event);
}
}
Widget build(BuildContext context) {
return new Listener(
onPointerDown: _recordEvent,
onPointerMove: _recordEvent,
onPointerUp: _recordEvent,
onPointerCancel: _recordEvent,
child: config.child
);
}
}
...@@ -12,6 +12,7 @@ export 'src/widgets/dismissable.dart'; ...@@ -12,6 +12,7 @@ export 'src/widgets/dismissable.dart';
export 'src/widgets/drag_target.dart'; export 'src/widgets/drag_target.dart';
export 'src/widgets/editable_text.dart'; export 'src/widgets/editable_text.dart';
export 'src/widgets/enter_exit_transition.dart'; export 'src/widgets/enter_exit_transition.dart';
export 'src/widgets/event_recorder.dart';
export 'src/widgets/focus.dart'; export 'src/widgets/focus.dart';
export 'src/widgets/framework.dart'; export 'src/widgets/framework.dart';
export 'src/widgets/gesture_detector.dart'; export 'src/widgets/gesture_detector.dart';
......
Benchmarks
==========
This directory (and its sub-directories) contain benchmarks for Flutter.
The reporting format for benchmarks is not standardized yet, so benchmarks
here are typically run by hand. To run a particular benchmark, use a command
similar to that used to run individual unit tests. For example:
```
sky/tools/run_tests --debug -r expanded benchmark/gestures/velocity_tracker_bench.dart
```
(The `-r expanded` flag prints one line per test, which can be more helpful
than the default format when running individual tests.)
import 'dart:ui' as ui;
import 'package:flutter/gestures.dart';
import 'package:test/test.dart';
import 'velocity_tracker_data.dart';
const int kNumIters = 10000;
const int kBatchSize = 1000;
const int kBatchOffset = 50;
const int kNumMarks = 130;
List<PointerInputEvent> _eventFromMap(List<Map> intermediate) {
List<PointerInputEvent> events = new List<PointerInputEvent>();
for (Map entry in intermediate)
events.add(_eventFor(entry));
return events;
}
PointerInputEvent _eventFor(Map entry) {
PointerInputEvent result = new PointerInputEvent(
type: entry['type'],
timeStamp: entry['timeStamp'],
pointer: entry['pointer'],
x: entry['x'],
y: entry['y']
);
return result;
}
void main() {
List<PointerInputEvent> events = _eventFromMap(velocityEventData);
test('Dart velocity tracker performance', () {
VelocityTracker tracker = new VelocityTracker();
Stopwatch watch = new Stopwatch();
watch.start();
for (int i = 0; i < kNumIters; i++) {
for (PointerInputEvent event in events) {
if (event.type == 'pointerdown' || event.type == 'pointermove')
tracker.addPosition(event.timeStamp, event.x, event.y);
if (event.type == 'pointerup')
tracker.getVelocity();
}
}
watch.stop();
print("Dart tracker: " + watch.elapsed.toString());
});
test('Native velocity tracker performance', () {
ui.VelocityTracker tracker = new ui.VelocityTracker();
Stopwatch watch = new Stopwatch();
watch.start();
for (int i = 0; i < kNumIters; i++) {
for (PointerInputEvent event in events) {
if (event.type == 'pointerdown' || event.type == 'pointermove')
tracker.addPosition((event.timeStamp*1000.0).toInt(), event.x, event.y);
if (event.type == 'pointerup')
tracker.getVelocity();
}
}
watch.stop();
print("Native tracker: " + watch.elapsed.toString());
});
}
This diff is collapsed.
import 'package:flutter/gestures.dart';
import 'package:test/test.dart';
void main() {
approx(double value, double expectation) {
const double eps = 1e-6;
return (value - expectation).abs() < eps;
}
test('Least-squares fit: linear polynomial to line', () {
List<double> x = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
List<double> y = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
List<double> w = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
LeastSquaresSolver solver = new LeastSquaresSolver(x, y, w);
PolynomialFit fit = solver.solve(1);
expect(fit.coefficients.length, 2);
expect(approx(fit.coefficients[0], 1.0), isTrue);
expect(approx(fit.coefficients[1], 0.0), isTrue);
expect(approx(fit.confidence, 1.0), isTrue);
});
test('Least-squares fit: linear polynomial to sloped line', () {
List<double> x = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
List<double> y = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0];
List<double> w = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
LeastSquaresSolver solver = new LeastSquaresSolver(x, y, w);
PolynomialFit fit = solver.solve(1);
expect(fit.coefficients.length, 2);
expect(approx(fit.coefficients[0], 1.0), isTrue);
expect(approx(fit.coefficients[1], 1.0), isTrue);
expect(approx(fit.confidence, 1.0), isTrue);
});
test('Least-squares fit: quadratic polynomial to line', () {
List<double> x = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
List<double> y = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
List<double> w = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
LeastSquaresSolver solver = new LeastSquaresSolver(x, y, w);
PolynomialFit fit = solver.solve(2);
expect(fit.coefficients.length, 3);
expect(approx(fit.coefficients[0], 1.0), isTrue);
expect(approx(fit.coefficients[1], 0.0), isTrue);
expect(approx(fit.coefficients[2], 0.0), isTrue);
expect(approx(fit.confidence, 1.0), isTrue);
});
test('Least-squares fit: quadratic polynomial to sloped line', () {
List<double> x = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
List<double> y = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0];
List<double> w = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
LeastSquaresSolver solver = new LeastSquaresSolver(x, y, w);
PolynomialFit fit = solver.solve(2);
expect(fit.coefficients.length, 3);
expect(approx(fit.coefficients[0], 1.0), isTrue);
expect(approx(fit.coefficients[1], 1.0), isTrue);
expect(approx(fit.coefficients[2], 0.0), isTrue);
expect(approx(fit.confidence, 1.0), isTrue);
});
}
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment