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
5package org.chromium.chromoting;
6
7import android.app.ActionBar;
8import android.app.Activity;
9import android.graphics.Bitmap;
10import android.graphics.Canvas;
11import android.graphics.Color;
12import android.graphics.Matrix;
13import android.graphics.Paint;
14import android.os.Bundle;
15import android.os.Looper;
16import android.text.InputType;
17import android.util.Log;
18import android.view.GestureDetector;
19import android.view.MotionEvent;
20import android.view.ScaleGestureDetector;
21import android.view.SurfaceHolder;
22import android.view.SurfaceView;
23import android.view.inputmethod.EditorInfo;
24import android.view.inputmethod.InputConnection;
25
26import org.chromium.chromoting.jni.JniInterface;
27
28/**
29 * The user interface for viewing and interacting with a specific remote host.
30 * It provides a canvas onto which the video feed is rendered, handles
31 * multitouch pan and zoom gestures, and collects and forwards input events.
32 */
33/** GUI element that holds the drawing canvas. */
34public class DesktopView extends SurfaceView implements Runnable, SurfaceHolder.Callback {
35    /**
36     * *Square* of the minimum displacement (in pixels) to be recognized as a scroll gesture.
37     * Setting this to a lower value forces more frequent canvas redraws during scrolling.
38     */
39    private static final int MIN_SCROLL_DISTANCE = 8 * 8;
40
41    /**
42     * Minimum change to the scaling factor to be recognized as a zoom gesture. Setting lower
43     * values here will result in more frequent canvas redraws during zooming.
44     */
45    private static final double MIN_ZOOM_FACTOR = 0.05;
46
47    /*
48     * These constants must match those in the generated struct protoc::MouseEvent_MouseButton.
49     */
50    private static final int BUTTON_UNDEFINED = 0;
51    private static final int BUTTON_LEFT = 1;
52    private static final int BUTTON_RIGHT = 3;
53
54    /** Specifies one dimension of an image. */
55    private static enum Constraint {
56        UNDEFINED, WIDTH, HEIGHT
57    }
58
59    private ActionBar mActionBar;
60
61    private GestureDetector mScroller;
62    private ScaleGestureDetector mZoomer;
63
64    /** Stores pan and zoom configuration and converts image coordinates to screen coordinates. */
65    private Matrix mTransform;
66
67    private int mScreenWidth;
68    private int mScreenHeight;
69
70    /** Specifies the dimension by which the zoom level is being lower-bounded. */
71    private Constraint mConstraint;
72
73    /** Whether the dimension of constraint should be reckecked on the next aspect ratio change. */
74    private boolean mRecheckConstraint;
75
76    /** Whether the right edge of the image was visible on-screen during the last render. */
77    private boolean mRightUsedToBeOut;
78
79    /** Whether the bottom edge of the image was visible on-screen during the last render. */
80    private boolean mBottomUsedToBeOut;
81
82    private int mMouseButton;
83    private boolean mMousePressed;
84
85    public DesktopView(Activity context) {
86        super(context);
87
88        // Give this view keyboard focus, allowing us to customize the soft keyboard's settings.
89        setFocusableInTouchMode(true);
90
91        mActionBar = context.getActionBar();
92
93        getHolder().addCallback(this);
94        DesktopListener listener = new DesktopListener();
95        mScroller = new GestureDetector(context, listener, null, false);
96        mZoomer = new ScaleGestureDetector(context, listener);
97
98        mTransform = new Matrix();
99        mScreenWidth = 0;
100        mScreenHeight = 0;
101
102        mConstraint = Constraint.UNDEFINED;
103        mRecheckConstraint = false;
104
105        mRightUsedToBeOut = false;
106        mBottomUsedToBeOut = false;
107
108        mMouseButton = BUTTON_UNDEFINED;
109        mMousePressed = false;
110    }
111
112    /**
113     * Redraws the canvas. This should be done on a non-UI thread or it could
114     * cause the UI to lag. Specifically, it is currently invoked on the native
115     * graphics thread using a JNI.
116     */
117    @Override
118    public void run() {
119        if (Looper.myLooper() == Looper.getMainLooper()) {
120            Log.w("deskview", "Canvas being redrawn on UI thread");
121        }
122
123        Bitmap image = JniInterface.retrieveVideoFrame();
124        Canvas canvas = getHolder().lockCanvas();
125        synchronized (mTransform) {
126            canvas.setMatrix(mTransform);
127
128            // Internal parameters of the transformation matrix.
129            float[] values = new float[9];
130            mTransform.getValues(values);
131
132            // Screen coordinates of two defining points of the image.
133            float[] topleft = {0, 0};
134            mTransform.mapPoints(topleft);
135            float[] bottomright = {image.getWidth(), image.getHeight()};
136            mTransform.mapPoints(bottomright);
137
138            // Whether to rescale and recenter the view.
139            boolean recenter = false;
140
141            if (mConstraint == Constraint.UNDEFINED) {
142                mConstraint = (double)image.getWidth()/image.getHeight() >
143                        (double)mScreenWidth/mScreenHeight ? Constraint.WIDTH : Constraint.HEIGHT;
144                recenter = true;  // We always rescale and recenter after a rotation.
145            }
146
147            if (mConstraint == Constraint.WIDTH &&
148                    ((int)(bottomright[0] - topleft[0] + 0.5) < mScreenWidth || recenter)) {
149                // The vertical edges of the image are flush against the device's screen edges
150                // when the entire host screen is visible, and the user has zoomed out too far.
151                float imageMiddle = (float)image.getHeight() / 2;
152                float screenMiddle = (float)mScreenHeight / 2;
153                mTransform.setPolyToPoly(
154                        new float[] {0, imageMiddle, image.getWidth(), imageMiddle}, 0,
155                        new float[] {0, screenMiddle, mScreenWidth, screenMiddle}, 0, 2);
156            } else if (mConstraint == Constraint.HEIGHT &&
157                    ((int)(bottomright[1] - topleft[1] + 0.5) < mScreenHeight || recenter)) {
158                // The horizontal image edges are flush against the device's screen edges when
159                // the entire host screen is visible, and the user has zoomed out too far.
160                float imageCenter = (float)image.getWidth() / 2;
161                float screenCenter = (float)mScreenWidth / 2;
162                mTransform.setPolyToPoly(
163                        new float[] {imageCenter, 0, imageCenter, image.getHeight()}, 0,
164                        new float[] {screenCenter, 0, screenCenter, mScreenHeight}, 0, 2);
165            } else {
166                // It's fine for both members of a pair of image edges to be within the screen
167                // edges (or "out of bounds"); that simply means that the image is zoomed out as
168                // far as permissible. And both members of a pair can obviously be outside the
169                // screen's edges, which indicates that the image is zoomed in to far to see the
170                // whole host screen. However, if only one of a pair of edges has entered the
171                // screen, the user is attempting to scroll into a blank area of the canvas.
172
173                // A value of true means the corresponding edge has entered the screen's borders.
174                boolean leftEdgeOutOfBounds = values[Matrix.MTRANS_X] > 0;
175                boolean topEdgeOutOfBounds = values[Matrix.MTRANS_Y] > 0;
176                boolean rightEdgeOutOfBounds = bottomright[0] < mScreenWidth;
177                boolean bottomEdgeOutOfBounds = bottomright[1] < mScreenHeight;
178
179                // Prevent the user from scrolling past the left or right edge of the image.
180                if (leftEdgeOutOfBounds != rightEdgeOutOfBounds) {
181                    if (leftEdgeOutOfBounds != mRightUsedToBeOut) {
182                        // Make the left edge of the image flush with the left screen edge.
183                        values[Matrix.MTRANS_X] = 0;
184                    }
185                    else {
186                        // Make the right edge of the image flush with the right screen edge.
187                        values[Matrix.MTRANS_X] += mScreenWidth - bottomright[0];
188                    }
189                } else {
190                    // The else prevents this from being updated during the repositioning process,
191                    // in which case the view would begin to oscillate.
192                    mRightUsedToBeOut = rightEdgeOutOfBounds;
193                }
194
195                // Prevent the user from scrolling past the top or bottom edge of the image.
196                if (topEdgeOutOfBounds != bottomEdgeOutOfBounds) {
197                    if (topEdgeOutOfBounds != mBottomUsedToBeOut) {
198                        // Make the top edge of the image flush with the top screen edge.
199                        values[Matrix.MTRANS_Y] = 0;
200                    } else {
201                        // Make the bottom edge of the image flush with the bottom screen edge.
202                        values[Matrix.MTRANS_Y] += mScreenHeight - bottomright[1];
203                    }
204                }
205                else {
206                    // The else prevents this from being updated during the repositioning process,
207                    // in which case the view would begin to oscillate.
208                    mBottomUsedToBeOut = bottomEdgeOutOfBounds;
209                }
210
211                mTransform.setValues(values);
212            }
213
214            canvas.setMatrix(mTransform);
215        }
216
217        canvas.drawColor(Color.BLACK);
218        canvas.drawBitmap(image, 0, 0, new Paint());
219        getHolder().unlockCanvasAndPost(canvas);
220    }
221
222    /**
223     * Causes the next canvas redraw to perform a check for which screen dimension more tightly
224     * constrains the view of the image. This should be called between the time that a screen size
225     * change is requested and the time it actually occurs. If it is not called in such a case, the
226     * screen will not be rearranged as aggressively (which is desirable when the software keyboard
227     * appears in order to allow it to cover the image without forcing a resize).
228     */
229    public void requestRecheckConstrainingDimension() {
230        mRecheckConstraint = true;
231    }
232
233    /**
234     * Called after the canvas is initially created, then after every
235     * subsequent resize, as when the display is rotated.
236     */
237    @Override
238    public void surfaceChanged(
239            SurfaceHolder holder, int format, int width, int height) {
240        mActionBar.hide();
241
242        synchronized (mTransform) {
243            mScreenWidth = width;
244            mScreenHeight = height;
245
246            if (mRecheckConstraint) {
247                mConstraint = Constraint.UNDEFINED;
248                mRecheckConstraint = false;
249            }
250        }
251
252        if (!JniInterface.redrawGraphics()) {
253            JniInterface.provideRedrawCallback(this);
254        }
255    }
256
257    /** Called when the canvas is first created. */
258    @Override
259    public void surfaceCreated(SurfaceHolder holder) {
260        Log.i("deskview", "DesktopView.surfaceCreated(...)");
261    }
262
263    /**
264     * Called when the canvas is finally destroyed. Marks the canvas as needing a redraw so that it
265     * will not be blank if the user later switches back to our window.
266     */
267    @Override
268    public void surfaceDestroyed(SurfaceHolder holder) {
269        Log.i("deskview", "DesktopView.surfaceDestroyed(...)");
270
271        // Stop this canvas from being redrawn.
272        JniInterface.provideRedrawCallback(null);
273    }
274
275    /** Called when a software keyboard is requested, and specifies its options. */
276    @Override
277    public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
278        // Disables rich input support and instead requests simple key events.
279        outAttrs.inputType = InputType.TYPE_NULL;
280
281        // Prevents most third-party IMEs from ignoring our Activity's adjustResize preference.
282        outAttrs.imeOptions |= EditorInfo.IME_FLAG_NO_FULLSCREEN;
283
284        // Ensures that keyboards will not decide to hide the remote desktop on small displays.
285        outAttrs.imeOptions |= EditorInfo.IME_FLAG_NO_EXTRACT_UI;
286
287        // Stops software keyboards from closing as soon as the enter key is pressed.
288        outAttrs.imeOptions |= EditorInfo.IME_MASK_ACTION | EditorInfo.IME_FLAG_NO_ENTER_ACTION;
289
290        return null;
291    }
292
293    /** Called when a mouse action is made. */
294    private void handleMouseMovement(float x, float y, int button, boolean pressed) {
295        float[] coordinates = {x, y};
296
297        // Coordinates are relative to the canvas, but we need image coordinates.
298        Matrix canvasToImage = new Matrix();
299        mTransform.invert(canvasToImage);
300        canvasToImage.mapPoints(coordinates);
301
302        // Coordinates are now relative to the image, so transmit them to the host.
303        JniInterface.mouseAction((int)coordinates[0], (int)coordinates[1], button, pressed);
304    }
305
306    /**
307     * Called whenever the user attempts to touch the canvas. Forwards such
308     * events to the appropriate gesture detector until one accepts them.
309     */
310    @Override
311    public boolean onTouchEvent(MotionEvent event) {
312        if (event.getPointerCount() == 3) {
313            mActionBar.show();
314        }
315
316        boolean handled = mScroller.onTouchEvent(event) || mZoomer.onTouchEvent(event);
317
318        if (event.getPointerCount() == 1) {
319            float x = event.getRawX();
320            float y = event.getY();
321
322            switch (event.getActionMasked()) {
323                case MotionEvent.ACTION_DOWN:
324                    Log.i("mouse", "Found a finger");
325                    mMouseButton = BUTTON_UNDEFINED;
326                    mMousePressed = false;
327                    break;
328
329                case MotionEvent.ACTION_MOVE:
330                    Log.i("mouse", "Finger is dragging");
331                    if (mMouseButton == BUTTON_UNDEFINED) {
332                        Log.i("mouse", "\tStarting left click");
333                        mMouseButton = BUTTON_LEFT;
334                        mMousePressed = true;
335                    }
336                    break;
337
338                case MotionEvent.ACTION_UP:
339                    Log.i("mouse", "Lost the finger");
340                    if (mMouseButton == BUTTON_UNDEFINED) {
341                        // The user pressed and released without moving: do left click and release.
342                        Log.i("mouse", "\tStarting and finishing left click");
343                        handleMouseMovement(x, y, BUTTON_LEFT, true);
344                        mMouseButton = BUTTON_LEFT;
345                        mMousePressed = false;
346                    }
347                    else if (mMousePressed) {
348                        Log.i("mouse", "\tReleasing the currently-pressed button");
349                        mMousePressed = false;
350                    }
351                    else {
352                        Log.w("mouse", "Button already in released state before gesture ended");
353                    }
354                    break;
355
356                default:
357                    return handled;
358            }
359            handleMouseMovement(x, y, mMouseButton, mMousePressed);
360
361            return true;
362        }
363
364        return handled;
365    }
366
367    /** Responds to touch events filtered by the gesture detectors. */
368    private class DesktopListener extends GestureDetector.SimpleOnGestureListener
369            implements ScaleGestureDetector.OnScaleGestureListener {
370        /**
371         * Called when the user is scrolling. We refuse to accept or process the event unless it
372         * is being performed with 2 or more touch points, in order to reserve single-point touch
373         * events for emulating mouse input.
374         */
375        @Override
376        public boolean onScroll(
377                MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
378            if (e2.getPointerCount() < 2 ||
379                    Math.pow(distanceX, 2) + Math.pow(distanceY, 2) < MIN_SCROLL_DISTANCE) {
380                return false;
381            }
382
383            synchronized (mTransform) {
384                mTransform.postTranslate(-distanceX, -distanceY);
385            }
386            JniInterface.redrawGraphics();
387            return true;
388        }
389
390        /** Called when the user is in the process of pinch-zooming. */
391        @Override
392        public boolean onScale(ScaleGestureDetector detector) {
393            if (Math.abs(detector.getScaleFactor() - 1) < MIN_ZOOM_FACTOR) {
394                return false;
395            }
396
397            synchronized (mTransform) {
398                float scaleFactor = detector.getScaleFactor();
399                mTransform.postScale(
400                        scaleFactor, scaleFactor, detector.getFocusX(), detector.getFocusY());
401            }
402            JniInterface.redrawGraphics();
403            return true;
404        }
405
406        /** Called whenever a gesture starts. Always accepts the gesture so it isn't ignored. */
407        @Override
408        public boolean onDown(MotionEvent e) {
409            return true;
410        }
411
412        /**
413         * Called when the user starts to zoom. Always accepts the zoom so that
414         * onScale() can decide whether to respond to it.
415         */
416        @Override
417        public boolean onScaleBegin(ScaleGestureDetector detector) {
418            return true;
419        }
420
421        /** Called when the user is done zooming. Defers to onScale()'s judgement. */
422        @Override
423        public void onScaleEnd(ScaleGestureDetector detector) {
424            onScale(detector);
425        }
426
427        /** Called when the user holds down on the screen. Starts a right-click. */
428        @Override
429        public void onLongPress(MotionEvent e) {
430            if (e.getPointerCount() > 1) {
431                return;
432            }
433
434            float x = e.getRawX();
435            float y = e.getY();
436
437            Log.i("mouse", "Finger held down");
438            if (mMousePressed) {
439                Log.i("mouse", "\tReleasing the currently-pressed button");
440                handleMouseMovement(x, y, mMouseButton, false);
441            }
442
443            Log.i("mouse", "\tStarting right click");
444            mMouseButton = BUTTON_RIGHT;
445            mMousePressed = true;
446            handleMouseMovement(x, y, mMouseButton, mMousePressed);
447        }
448    }
449}
450