Upstream version 10.39.225.0
[platform/framework/web/crosswalk.git] / src / remoting / android / java / src / org / chromium / chromoting / TrackingInputHandler.java
1 // Copyright 2013 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4
5 package org.chromium.chromoting;
6
7 import android.content.Context;
8 import android.graphics.Matrix;
9 import android.graphics.PointF;
10 import android.view.GestureDetector;
11 import android.view.MotionEvent;
12 import android.view.ScaleGestureDetector;
13 import android.widget.Scroller;
14
15 /**
16  * This class implements the cursor-tracking behavior and gestures.
17  */
18 public class TrackingInputHandler implements TouchInputHandler {
19     /**
20      * Minimum change to the scaling factor to be recognized as a zoom gesture. Setting lower
21      * values here will result in more frequent canvas redraws during zooming.
22      */
23     private static final double MIN_ZOOM_DELTA = 0.05;
24
25     /**
26      * Maximum allowed zoom level - see {@link #repositionImageWithZoom()}.
27      */
28     private static final float MAX_ZOOM_FACTOR = 100.0f;
29
30     private DesktopViewInterface mViewer;
31     private RenderData mRenderData;
32
33     private GestureDetector mScroller;
34     private ScaleGestureDetector mZoomer;
35     private TapGestureDetector mTapDetector;
36
37     /** Used to calculate the physics for flinging the cursor. */
38     private Scroller mFlingScroller;
39
40     /** Used to disambiguate a 2-finger gesture as a swipe or a pinch. */
41     private SwipePinchDetector mSwipePinchDetector;
42
43     /**
44      * The current cursor position is stored here as floats, so that the desktop image can be
45      * positioned with sub-pixel accuracy, to give a smoother panning animation at high zoom levels.
46      */
47     private PointF mCursorPosition;
48
49     /**
50      * Used for tracking swipe gestures. Only the Y-direction is needed for responding to swipe-up
51      * or swipe-down.
52      */
53     private float mTotalMotionY = 0;
54
55     /**
56      * Distance in pixels beyond which a motion gesture is considered to be a swipe. This is
57      * initialized using the Context passed into the ctor.
58      */
59     private float mSwipeThreshold;
60
61     /** Mouse-button currently held down, or BUTTON_UNDEFINED otherwise. */
62     private int mHeldButton = BUTTON_UNDEFINED;
63
64     /**
65      * Set to true to prevent any further movement of the cursor, for example, when showing the
66      * keyboard to prevent the cursor wandering from the area where keystrokes should be sent.
67      */
68     private boolean mSuppressCursorMovement = false;
69
70     /**
71      * Set to true to suppress the fling animation at the end of a gesture, for example, when
72      * dragging whilst a button is held down.
73      */
74     private boolean mSuppressFling = false;
75
76     /**
77      * Set to true when 3-finger swipe gesture is complete, so that further movement doesn't
78      * trigger more swipe actions.
79      */
80     private boolean mSwipeCompleted = false;
81
82     public TrackingInputHandler(DesktopViewInterface viewer, Context context,
83                                 RenderData renderData) {
84         mViewer = viewer;
85         mRenderData = renderData;
86
87         GestureListener listener = new GestureListener();
88         mScroller = new GestureDetector(context, listener, null, false);
89
90         // If long-press is enabled, the gesture-detector will not emit any further onScroll
91         // notifications after the onLongPress notification. Since onScroll is being used for
92         // moving the cursor, it means that the cursor would become stuck if the finger were held
93         // down too long.
94         mScroller.setIsLongpressEnabled(false);
95
96         mZoomer = new ScaleGestureDetector(context, listener);
97         mTapDetector = new TapGestureDetector(context, listener);
98         mFlingScroller = new Scroller(context);
99         mSwipePinchDetector = new SwipePinchDetector(context);
100
101         mCursorPosition = new PointF();
102
103         // The threshold needs to be bigger than the ScaledTouchSlop used by the gesture-detectors,
104         // so that a gesture cannot be both a tap and a swipe. It also needs to be small enough so
105         // that intentional swipes are usually detected.
106         float density = context.getResources().getDisplayMetrics().density;
107         mSwipeThreshold = 40 * density;
108     }
109
110     /**
111      * Moves the mouse-cursor, injects a mouse-move event and repositions the image.
112      */
113     private void moveCursor(float newX, float newY) {
114         synchronized (mRenderData) {
115             // Constrain cursor to the image area.
116             if (newX < 0) newX = 0;
117             if (newY < 0) newY = 0;
118             if (newX > mRenderData.imageWidth) newX = mRenderData.imageWidth;
119             if (newY > mRenderData.imageHeight) newY = mRenderData.imageHeight;
120             mCursorPosition.set(newX, newY);
121             repositionImage();
122         }
123
124         mViewer.injectMouseEvent((int) newX, (int) newY, BUTTON_UNDEFINED, false);
125     }
126
127     /**
128      * Repositions the image by translating it (without affecting the zoom level) to place the
129      * cursor close to the center of the screen.
130      */
131     private void repositionImage() {
132         synchronized (mRenderData) {
133             // Get the current cursor position in screen coordinates.
134             float[] cursorScreen = {mCursorPosition.x, mCursorPosition.y};
135             mRenderData.transform.mapPoints(cursorScreen);
136
137             // Translate so the cursor is displayed in the middle of the screen.
138             mRenderData.transform.postTranslate(
139                     (float) mRenderData.screenWidth / 2 - cursorScreen[0],
140                     (float) mRenderData.screenHeight / 2 - cursorScreen[1]);
141
142             // Now the cursor is displayed in the middle of the screen, see if the image can be
143             // panned so that more of it is visible. The primary goal is to show as much of the
144             // image as possible. The secondary goal is to keep the cursor in the middle.
145
146             // Get the coordinates of the desktop rectangle (top-left/bottom-right corners) in
147             // screen coordinates. Order is: left, top, right, bottom.
148             float[] rectScreen = {0, 0, mRenderData.imageWidth, mRenderData.imageHeight};
149             mRenderData.transform.mapPoints(rectScreen);
150
151             float leftDelta = rectScreen[0];
152             float rightDelta = rectScreen[2] - mRenderData.screenWidth;
153             float topDelta = rectScreen[1];
154             float bottomDelta = rectScreen[3] - mRenderData.screenHeight;
155             float xAdjust = 0;
156             float yAdjust = 0;
157
158             if (rectScreen[2] - rectScreen[0] < mRenderData.screenWidth) {
159                 // Image is narrower than the screen, so center it.
160                 xAdjust = -(rightDelta + leftDelta) / 2;
161             } else if (leftDelta > 0 && rightDelta > 0) {
162                 // Panning the image left will show more of it.
163                 xAdjust = -Math.min(leftDelta, rightDelta);
164             } else if (leftDelta < 0 && rightDelta < 0) {
165                 // Pan the image right.
166                 xAdjust = Math.min(-leftDelta, -rightDelta);
167             }
168
169             // Apply similar logic for yAdjust.
170             if (rectScreen[3] - rectScreen[1] < mRenderData.screenHeight) {
171                 yAdjust = -(bottomDelta + topDelta) / 2;
172             } else if (topDelta > 0 && bottomDelta > 0) {
173                 yAdjust = -Math.min(topDelta, bottomDelta);
174             } else if (topDelta < 0 && bottomDelta < 0) {
175                 yAdjust = Math.min(-topDelta, -bottomDelta);
176             }
177
178             mRenderData.transform.postTranslate(xAdjust, yAdjust);
179         }
180         mViewer.transformationChanged();
181     }
182
183     /**
184      * Repositions the image by translating and zooming it, to keep the zoom level within sensible
185      * limits. The minimum zoom level is chosen to avoid black space around all 4 sides. The
186      * maximum zoom level is set arbitrarily, so that the user can zoom out again in a reasonable
187      * time, and to prevent arithmetic overflow problems from displaying the image.
188      */
189     private void repositionImageWithZoom() {
190         synchronized (mRenderData) {
191             // Avoid division by zero in case this gets called before the image size is initialized.
192             if (mRenderData.imageWidth == 0 || mRenderData.imageHeight == 0) {
193                 return;
194             }
195
196             // Zoom out if the zoom level is too high.
197             float currentZoomLevel = mRenderData.transform.mapRadius(1.0f);
198             if (currentZoomLevel > MAX_ZOOM_FACTOR) {
199                 mRenderData.transform.setScale(MAX_ZOOM_FACTOR, MAX_ZOOM_FACTOR);
200             }
201
202             // Get image size scaled to screen coordinates.
203             float[] imageSize = {mRenderData.imageWidth, mRenderData.imageHeight};
204             mRenderData.transform.mapVectors(imageSize);
205
206             if (imageSize[0] < mRenderData.screenWidth && imageSize[1] < mRenderData.screenHeight) {
207                 // Displayed image is too small in both directions, so apply the minimum zoom
208                 // level needed to fit either the width or height.
209                 float scale = Math.min((float) mRenderData.screenWidth / mRenderData.imageWidth,
210                                        (float) mRenderData.screenHeight / mRenderData.imageHeight);
211                 mRenderData.transform.setScale(scale, scale);
212             }
213
214             repositionImage();
215         }
216     }
217
218     /** Injects a button event using the current cursor location. */
219     private void injectButtonEvent(int button, boolean pressed) {
220         mViewer.injectMouseEvent((int) mCursorPosition.x, (int) mCursorPosition.y, button, pressed);
221     }
222
223     /** Processes a (multi-finger) swipe gesture. */
224     private boolean onSwipe() {
225         if (mTotalMotionY > mSwipeThreshold) {
226             // Swipe down occurred.
227             mViewer.showActionBar();
228         } else if (mTotalMotionY < -mSwipeThreshold) {
229             // Swipe up occurred.
230             mViewer.showKeyboard();
231         } else {
232             return false;
233         }
234
235         mSuppressCursorMovement = true;
236         mSuppressFling = true;
237         mSwipeCompleted = true;
238         return true;
239     }
240
241     /** Injects a button-up event if the button is currently held down (during a drag event). */
242     private void releaseAnyHeldButton() {
243         if (mHeldButton != BUTTON_UNDEFINED) {
244             injectButtonEvent(mHeldButton, false);
245             mHeldButton = BUTTON_UNDEFINED;
246         }
247     }
248
249     @Override
250     public boolean onTouchEvent(MotionEvent event) {
251         // Avoid short-circuit logic evaluation - ensure all gesture detectors see all events so
252         // that they generate correct notifications.
253         boolean handled = mScroller.onTouchEvent(event);
254         handled |= mZoomer.onTouchEvent(event);
255         handled |= mTapDetector.onTouchEvent(event);
256         mSwipePinchDetector.onTouchEvent(event);
257
258         switch (event.getActionMasked()) {
259             case MotionEvent.ACTION_DOWN:
260                 mViewer.setAnimationEnabled(false);
261                 mSuppressCursorMovement = false;
262                 mSuppressFling = false;
263                 mSwipeCompleted = false;
264                 break;
265
266             case MotionEvent.ACTION_POINTER_DOWN:
267                 mTotalMotionY = 0;
268                 break;
269
270             case MotionEvent.ACTION_UP:
271                 releaseAnyHeldButton();
272                 break;
273
274             default:
275                 break;
276         }
277         return handled;
278     }
279
280     @Override
281     public void onScreenConfigurationChanged() {
282     }
283
284     @Override
285     public void onClientSizeChanged(int width, int height) {
286         repositionImageWithZoom();
287     }
288
289     @Override
290     public void onHostSizeChanged(int width, int height) {
291         moveCursor((float) width / 2, (float) height / 2);
292         repositionImageWithZoom();
293     }
294
295     @Override
296     public void processAnimation() {
297         int previousX = mFlingScroller.getCurrX();
298         int previousY = mFlingScroller.getCurrY();
299         if (!mFlingScroller.computeScrollOffset()) {
300             mViewer.setAnimationEnabled(false);
301             return;
302         }
303         int deltaX = mFlingScroller.getCurrX() - previousX;
304         int deltaY = mFlingScroller.getCurrY() - previousY;
305         float[] delta = {deltaX, deltaY};
306         synchronized (mRenderData) {
307             Matrix canvasToImage = new Matrix();
308             mRenderData.transform.invert(canvasToImage);
309             canvasToImage.mapVectors(delta);
310         }
311
312         moveCursor(mCursorPosition.x + delta[0], mCursorPosition.y + delta[1]);
313     }
314
315     /** Responds to touch events filtered by the gesture detectors. */
316     private class GestureListener extends GestureDetector.SimpleOnGestureListener
317             implements ScaleGestureDetector.OnScaleGestureListener,
318                        TapGestureDetector.OnTapListener {
319         /**
320          * Called when the user drags one or more fingers across the touchscreen.
321          */
322         @Override
323         public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
324             int pointerCount = e2.getPointerCount();
325             if (pointerCount == 3 && !mSwipeCompleted) {
326                 // Note that distance values are reversed. For example, dragging a finger in the
327                 // direction of increasing Y coordinate (downwards) results in distanceY being
328                 // negative.
329                 mTotalMotionY -= distanceY;
330                 return onSwipe();
331             }
332
333             if (pointerCount == 2 && mSwipePinchDetector.isSwiping()) {
334                 mViewer.injectMouseWheelDeltaEvent(-(int) distanceX, -(int) distanceY);
335
336                 // Prevent the cursor being moved or flung by the gesture.
337                 mSuppressCursorMovement = true;
338                 return true;
339             }
340
341             if (pointerCount != 1 || mSuppressCursorMovement) {
342                 return false;
343             }
344
345             float[] delta = {distanceX, distanceY};
346             synchronized (mRenderData) {
347                 Matrix canvasToImage = new Matrix();
348                 mRenderData.transform.invert(canvasToImage);
349                 canvasToImage.mapVectors(delta);
350             }
351
352             moveCursor(mCursorPosition.x - delta[0], mCursorPosition.y - delta[1]);
353             return true;
354         }
355
356         /**
357          * Called when a fling gesture is recognized.
358          */
359         @Override
360         public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
361             // If cursor movement is suppressed, fling also needs to be suppressed, as the
362             // gesture-detector will still generate onFling() notifications based on movement of
363             // the fingers, which would result in unwanted cursor movement.
364             if (mSuppressCursorMovement || mSuppressFling) {
365                 return false;
366             }
367
368             // The fling physics calculation is based on screen coordinates, so that it will
369             // behave consistently at different zoom levels (and will work nicely at high zoom
370             // levels, since |mFlingScroller| outputs integer coordinates). However, the desktop
371             // will usually be panned as the cursor is moved across the desktop, which means the
372             // transformation mapping from screen to desktop coordinates will change. To deal with
373             // this, the cursor movement is computed from relative coordinate changes from
374             // |mFlingScroller|. This means the fling can be started at (0, 0) with no bounding
375             // constraints - the cursor is already constrained by the desktop size.
376             mFlingScroller.fling(0, 0, (int) velocityX, (int) velocityY, Integer.MIN_VALUE,
377                     Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);
378             // Initialize the scroller's current offset coordinates, since they are used for
379             // calculating the delta values.
380             mFlingScroller.computeScrollOffset();
381             mViewer.setAnimationEnabled(true);
382             return true;
383         }
384
385         /** Called when the user is in the process of pinch-zooming. */
386         @Override
387         public boolean onScale(ScaleGestureDetector detector) {
388             if (!mSwipePinchDetector.isPinching()) {
389                 return false;
390             }
391
392             if (Math.abs(detector.getScaleFactor() - 1) < MIN_ZOOM_DELTA) {
393                 return false;
394             }
395
396             float scaleFactor = detector.getScaleFactor();
397             synchronized (mRenderData) {
398                 mRenderData.transform.postScale(
399                         scaleFactor, scaleFactor, detector.getFocusX(), detector.getFocusY());
400             }
401             repositionImageWithZoom();
402             return true;
403         }
404
405         /** Called whenever a gesture starts. Always accepts the gesture so it isn't ignored. */
406         @Override
407         public boolean onDown(MotionEvent e) {
408             return true;
409         }
410
411         /**
412          * Called when the user starts to zoom. Always accepts the zoom so that
413          * onScale() can decide whether to respond to it.
414          */
415         @Override
416         public boolean onScaleBegin(ScaleGestureDetector detector) {
417             return true;
418         }
419
420         /** Called when the user is done zooming. Defers to onScale()'s judgement. */
421         @Override
422         public void onScaleEnd(ScaleGestureDetector detector) {
423             onScale(detector);
424         }
425
426         /** Maps the number of fingers in a tap or long-press gesture to a mouse-button. */
427         private int mouseButtonFromPointerCount(int pointerCount) {
428             switch (pointerCount) {
429                 case 1:
430                     return BUTTON_LEFT;
431                 case 2:
432                     return BUTTON_RIGHT;
433                 case 3:
434                     return BUTTON_MIDDLE;
435                 default:
436                     return BUTTON_UNDEFINED;
437             }
438         }
439
440         /** Called when the user taps the screen with one or more fingers. */
441         @Override
442         public boolean onTap(int pointerCount) {
443             int button = mouseButtonFromPointerCount(pointerCount);
444             if (button == BUTTON_UNDEFINED) {
445                 return false;
446             } else {
447                 injectButtonEvent(button, true);
448                 injectButtonEvent(button, false);
449                 return true;
450             }
451         }
452
453         /** Called when a long-press is triggered for one or more fingers. */
454         @Override
455         public void onLongPress(int pointerCount) {
456             mHeldButton = mouseButtonFromPointerCount(pointerCount);
457             if (mHeldButton != BUTTON_UNDEFINED) {
458                 injectButtonEvent(mHeldButton, true);
459                 mViewer.showLongPressFeedback();
460                 mSuppressFling = true;
461             }
462         }
463     }
464 }