| // Copyright 2013 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. |
| |
| package org.chromium.chromoting; |
| |
| import android.app.ActionBar; |
| import android.app.Activity; |
| import android.graphics.Bitmap; |
| import android.graphics.Canvas; |
| import android.graphics.Color; |
| import android.graphics.Matrix; |
| import android.graphics.Paint; |
| import android.os.Bundle; |
| import android.os.Looper; |
| import android.util.Log; |
| import android.view.GestureDetector; |
| import android.view.MotionEvent; |
| import android.view.ScaleGestureDetector; |
| import android.view.SurfaceHolder; |
| import android.view.SurfaceView; |
| |
| import org.chromium.chromoting.jni.JniInterface; |
| |
| /** |
| * The user interface for viewing and interacting with a specific remote host. |
| * It provides a canvas onto which the video feed is rendered, handles |
| * multitouch pan and zoom gestures, and collects and forwards input events. |
| */ |
| /** GUI element that holds the drawing canvas. */ |
| public class DesktopView extends SurfaceView implements Runnable, SurfaceHolder.Callback { |
| /** |
| * *Square* of the minimum displacement (in pixels) to be recognized as a scroll gesture. |
| * Setting this to a lower value forces more frequent canvas redraws during scrolling. |
| */ |
| private static final int MIN_SCROLL_DISTANCE = 8 * 8; |
| |
| /** |
| * Minimum change to the scaling factor to be recognized as a zoom gesture. Setting lower |
| * values here will result in more frequent canvas redraws during zooming. |
| */ |
| private static final double MIN_ZOOM_FACTOR = 0.05; |
| |
| /* |
| * These constants must match those in the generated struct protoc::MouseEvent_MouseButton. |
| */ |
| private static final int BUTTON_UNDEFINED = 0; |
| private static final int BUTTON_LEFT = 1; |
| private static final int BUTTON_RIGHT = 3; |
| |
| /** Specifies one dimension of an image. */ |
| private static enum Constraint { |
| UNDEFINED, WIDTH, HEIGHT |
| } |
| |
| private ActionBar mActionBar; |
| |
| private GestureDetector mScroller; |
| private ScaleGestureDetector mZoomer; |
| |
| /** Stores pan and zoom configuration and converts image coordinates to screen coordinates. */ |
| private Matrix mTransform; |
| |
| private int mScreenWidth; |
| private int mScreenHeight; |
| |
| /** Specifies the dimension by which the zoom level is being lower-bounded. */ |
| private Constraint mConstraint; |
| |
| /** Whether the dimension of constraint should be reckecked on the next aspect ratio change. */ |
| private boolean mRecheckConstraint; |
| |
| /** Whether the right edge of the image was visible on-screen during the last render. */ |
| private boolean mRightUsedToBeOut; |
| |
| /** Whether the bottom edge of the image was visible on-screen during the last render. */ |
| private boolean mBottomUsedToBeOut; |
| |
| private int mMouseButton; |
| private boolean mMousePressed; |
| |
| public DesktopView(Activity context) { |
| super(context); |
| mActionBar = context.getActionBar(); |
| |
| getHolder().addCallback(this); |
| DesktopListener listener = new DesktopListener(); |
| mScroller = new GestureDetector(context, listener, null, false); |
| mZoomer = new ScaleGestureDetector(context, listener); |
| |
| mTransform = new Matrix(); |
| mScreenWidth = 0; |
| mScreenHeight = 0; |
| |
| mConstraint = Constraint.UNDEFINED; |
| mRecheckConstraint = false; |
| |
| mRightUsedToBeOut = false; |
| mBottomUsedToBeOut = false; |
| |
| mMouseButton = BUTTON_UNDEFINED; |
| mMousePressed = false; |
| } |
| |
| /** |
| * Redraws the canvas. This should be done on a non-UI thread or it could |
| * cause the UI to lag. Specifically, it is currently invoked on the native |
| * graphics thread using a JNI. |
| */ |
| @Override |
| public void run() { |
| if (Looper.myLooper() == Looper.getMainLooper()) { |
| Log.w("deskview", "Canvas being redrawn on UI thread"); |
| } |
| |
| Bitmap image = JniInterface.retrieveVideoFrame(); |
| Canvas canvas = getHolder().lockCanvas(); |
| synchronized (mTransform) { |
| canvas.setMatrix(mTransform); |
| |
| // Internal parameters of the transformation matrix. |
| float[] values = new float[9]; |
| mTransform.getValues(values); |
| |
| // Screen coordinates of two defining points of the image. |
| float[] topleft = {0, 0}; |
| mTransform.mapPoints(topleft); |
| float[] bottomright = {image.getWidth(), image.getHeight()}; |
| mTransform.mapPoints(bottomright); |
| |
| // Whether to rescale and recenter the view. |
| boolean recenter = false; |
| |
| if (mConstraint == Constraint.UNDEFINED) { |
| mConstraint = (double)image.getWidth()/image.getHeight() > |
| (double)mScreenWidth/mScreenHeight ? Constraint.WIDTH : Constraint.HEIGHT; |
| recenter = true; // We always rescale and recenter after a rotation. |
| } |
| |
| if (mConstraint == Constraint.WIDTH && |
| ((int)(bottomright[0] - topleft[0] + 0.5) < mScreenWidth || recenter)) { |
| // The vertical edges of the image are flush against the device's screen edges |
| // when the entire host screen is visible, and the user has zoomed out too far. |
| float imageMiddle = (float)image.getHeight() / 2; |
| float screenMiddle = (float)mScreenHeight / 2; |
| mTransform.setPolyToPoly( |
| new float[] {0, imageMiddle, image.getWidth(), imageMiddle}, 0, |
| new float[] {0, screenMiddle, mScreenWidth, screenMiddle}, 0, 2); |
| } else if (mConstraint == Constraint.HEIGHT && |
| ((int)(bottomright[1] - topleft[1] + 0.5) < mScreenHeight || recenter)) { |
| // The horizontal image edges are flush against the device's screen edges when |
| // the entire host screen is visible, and the user has zoomed out too far. |
| float imageCenter = (float)image.getWidth() / 2; |
| float screenCenter = (float)mScreenWidth / 2; |
| mTransform.setPolyToPoly( |
| new float[] {imageCenter, 0, imageCenter, image.getHeight()}, 0, |
| new float[] {screenCenter, 0, screenCenter, mScreenHeight}, 0, 2); |
| } else { |
| // It's fine for both members of a pair of image edges to be within the screen |
| // edges (or "out of bounds"); that simply means that the image is zoomed out as |
| // far as permissible. And both members of a pair can obviously be outside the |
| // screen's edges, which indicates that the image is zoomed in to far to see the |
| // whole host screen. However, if only one of a pair of edges has entered the |
| // screen, the user is attempting to scroll into a blank area of the canvas. |
| |
| // A value of true means the corresponding edge has entered the screen's borders. |
| boolean leftEdgeOutOfBounds = values[Matrix.MTRANS_X] > 0; |
| boolean topEdgeOutOfBounds = values[Matrix.MTRANS_Y] > 0; |
| boolean rightEdgeOutOfBounds = bottomright[0] < mScreenWidth; |
| boolean bottomEdgeOutOfBounds = bottomright[1] < mScreenHeight; |
| |
| // Prevent the user from scrolling past the left or right edge of the image. |
| if (leftEdgeOutOfBounds != rightEdgeOutOfBounds) { |
| if (leftEdgeOutOfBounds != mRightUsedToBeOut) { |
| // Make the left edge of the image flush with the left screen edge. |
| values[Matrix.MTRANS_X] = 0; |
| } |
| else { |
| // Make the right edge of the image flush with the right screen edge. |
| values[Matrix.MTRANS_X] += mScreenWidth - bottomright[0]; |
| } |
| } else { |
| // The else prevents this from being updated during the repositioning process, |
| // in which case the view would begin to oscillate. |
| mRightUsedToBeOut = rightEdgeOutOfBounds; |
| } |
| |
| // Prevent the user from scrolling past the top or bottom edge of the image. |
| if (topEdgeOutOfBounds != bottomEdgeOutOfBounds) { |
| if (topEdgeOutOfBounds != mBottomUsedToBeOut) { |
| // Make the top edge of the image flush with the top screen edge. |
| values[Matrix.MTRANS_Y] = 0; |
| } else { |
| // Make the bottom edge of the image flush with the bottom screen edge. |
| values[Matrix.MTRANS_Y] += mScreenHeight - bottomright[1]; |
| } |
| } |
| else { |
| // The else prevents this from being updated during the repositioning process, |
| // in which case the view would begin to oscillate. |
| mBottomUsedToBeOut = bottomEdgeOutOfBounds; |
| } |
| |
| mTransform.setValues(values); |
| } |
| |
| canvas.setMatrix(mTransform); |
| } |
| |
| canvas.drawColor(Color.BLACK); |
| canvas.drawBitmap(image, 0, 0, new Paint()); |
| getHolder().unlockCanvasAndPost(canvas); |
| } |
| |
| /** |
| * Causes the next canvas redraw to perform a check for which screen dimension more tightly |
| * constrains the view of the image. This should be called between the time that a screen size |
| * change is requested and the time it actually occurs. If it is not called in such a case, the |
| * screen will not be rearranged as aggressively (which is desirable when the software keyboard |
| * appears in order to allow it to cover the image without forcing a resize). |
| */ |
| public void requestRecheckConstrainingDimension() { |
| mRecheckConstraint = true; |
| } |
| |
| /** |
| * Called after the canvas is initially created, then after every |
| * subsequent resize, as when the display is rotated. |
| */ |
| @Override |
| public void surfaceChanged( |
| SurfaceHolder holder, int format, int width, int height) { |
| mActionBar.hide(); |
| |
| synchronized (mTransform) { |
| mScreenWidth = width; |
| mScreenHeight = height; |
| |
| if (mRecheckConstraint) { |
| mConstraint = Constraint.UNDEFINED; |
| mRecheckConstraint = false; |
| } |
| } |
| |
| if (!JniInterface.redrawGraphics()) { |
| JniInterface.provideRedrawCallback(this); |
| } |
| } |
| |
| /** Called when the canvas is first created. */ |
| @Override |
| public void surfaceCreated(SurfaceHolder holder) { |
| Log.i("deskview", "DesktopView.surfaceCreated(...)"); |
| } |
| |
| /** |
| * Called when the canvas is finally destroyed. Marks the canvas as needing a redraw so that it |
| * will not be blank if the user later switches back to our window. |
| */ |
| @Override |
| public void surfaceDestroyed(SurfaceHolder holder) { |
| Log.i("deskview", "DesktopView.surfaceDestroyed(...)"); |
| |
| // Stop this canvas from being redrawn. |
| JniInterface.provideRedrawCallback(null); |
| } |
| |
| /** Called when a mouse action is made. */ |
| private void handleMouseMovement(float x, float y, int button, boolean pressed) { |
| float[] coordinates = {x, y}; |
| |
| // Coordinates are relative to the canvas, but we need image coordinates. |
| Matrix canvasToImage = new Matrix(); |
| mTransform.invert(canvasToImage); |
| canvasToImage.mapPoints(coordinates); |
| |
| // Coordinates are now relative to the image, so transmit them to the host. |
| JniInterface.mouseAction((int)coordinates[0], (int)coordinates[1], button, pressed); |
| } |
| |
| /** |
| * Called whenever the user attempts to touch the canvas. Forwards such |
| * events to the appropriate gesture detector until one accepts them. |
| */ |
| @Override |
| public boolean onTouchEvent(MotionEvent event) { |
| if (event.getPointerCount() == 3) { |
| mActionBar.show(); |
| } |
| |
| boolean handled = mScroller.onTouchEvent(event) || mZoomer.onTouchEvent(event); |
| |
| if (event.getPointerCount() == 1) { |
| float x = event.getRawX(); |
| float y = event.getY(); |
| |
| switch (event.getActionMasked()) { |
| case MotionEvent.ACTION_DOWN: |
| Log.i("mouse", "Found a finger"); |
| mMouseButton = BUTTON_UNDEFINED; |
| mMousePressed = false; |
| break; |
| |
| case MotionEvent.ACTION_MOVE: |
| Log.i("mouse", "Finger is dragging"); |
| if (mMouseButton == BUTTON_UNDEFINED) { |
| Log.i("mouse", "\tStarting left click"); |
| mMouseButton = BUTTON_LEFT; |
| mMousePressed = true; |
| } |
| break; |
| |
| case MotionEvent.ACTION_UP: |
| Log.i("mouse", "Lost the finger"); |
| if (mMouseButton == BUTTON_UNDEFINED) { |
| // The user pressed and released without moving: do left click and release. |
| Log.i("mouse", "\tStarting and finishing left click"); |
| handleMouseMovement(x, y, BUTTON_LEFT, true); |
| mMouseButton = BUTTON_LEFT; |
| mMousePressed = false; |
| } |
| else if (mMousePressed) { |
| Log.i("mouse", "\tReleasing the currently-pressed button"); |
| mMousePressed = false; |
| } |
| else { |
| Log.w("mouse", "Button already in released state before gesture ended"); |
| } |
| break; |
| |
| default: |
| return handled; |
| } |
| handleMouseMovement(x, y, mMouseButton, mMousePressed); |
| |
| return true; |
| } |
| |
| return handled; |
| } |
| |
| /** Responds to touch events filtered by the gesture detectors. */ |
| private class DesktopListener extends GestureDetector.SimpleOnGestureListener |
| implements ScaleGestureDetector.OnScaleGestureListener { |
| /** |
| * Called when the user is scrolling. We refuse to accept or process the event unless it |
| * is being performed with 2 or more touch points, in order to reserve single-point touch |
| * events for emulating mouse input. |
| */ |
| @Override |
| public boolean onScroll( |
| MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { |
| if (e2.getPointerCount() < 2 || |
| Math.pow(distanceX, 2) + Math.pow(distanceY, 2) < MIN_SCROLL_DISTANCE) { |
| return false; |
| } |
| |
| synchronized (mTransform) { |
| mTransform.postTranslate(-distanceX, -distanceY); |
| } |
| JniInterface.redrawGraphics(); |
| return true; |
| } |
| |
| /** Called when the user is in the process of pinch-zooming. */ |
| @Override |
| public boolean onScale(ScaleGestureDetector detector) { |
| if (Math.abs(detector.getScaleFactor() - 1) < MIN_ZOOM_FACTOR) { |
| return false; |
| } |
| |
| synchronized (mTransform) { |
| float scaleFactor = detector.getScaleFactor(); |
| mTransform.postScale( |
| scaleFactor, scaleFactor, detector.getFocusX(), detector.getFocusY()); |
| } |
| JniInterface.redrawGraphics(); |
| return true; |
| } |
| |
| /** Called whenever a gesture starts. Always accepts the gesture so it isn't ignored. */ |
| @Override |
| public boolean onDown(MotionEvent e) { |
| return true; |
| } |
| |
| /** |
| * Called when the user starts to zoom. Always accepts the zoom so that |
| * onScale() can decide whether to respond to it. |
| */ |
| @Override |
| public boolean onScaleBegin(ScaleGestureDetector detector) { |
| return true; |
| } |
| |
| /** Called when the user is done zooming. Defers to onScale()'s judgement. */ |
| @Override |
| public void onScaleEnd(ScaleGestureDetector detector) { |
| onScale(detector); |
| } |
| |
| /** Called when the user holds down on the screen. Starts a right-click. */ |
| @Override |
| public void onLongPress(MotionEvent e) { |
| if (e.getPointerCount() > 1) { |
| return; |
| } |
| |
| float x = e.getRawX(); |
| float y = e.getY(); |
| |
| Log.i("mouse", "Finger held down"); |
| if (mMousePressed) { |
| Log.i("mouse", "\tReleasing the currently-pressed button"); |
| handleMouseMovement(x, y, mMouseButton, false); |
| } |
| |
| Log.i("mouse", "\tStarting right click"); |
| mMouseButton = BUTTON_RIGHT; |
| mMousePressed = true; |
| handleMouseMovement(x, y, mMouseButton, mMousePressed); |
| } |
| } |
| } |