| // Copyright 2016 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.media; |
| |
| import android.app.Activity; |
| import android.app.Fragment; |
| import android.app.FragmentManager; |
| import android.app.FragmentTransaction; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.graphics.ImageFormat; |
| import android.graphics.PixelFormat; |
| import android.hardware.display.DisplayManager; |
| import android.hardware.display.VirtualDisplay; |
| import android.media.Image; |
| import android.media.ImageReader; |
| import android.media.projection.MediaProjection; |
| import android.media.projection.MediaProjectionManager; |
| import android.os.Handler; |
| import android.os.HandlerThread; |
| import android.util.DisplayMetrics; |
| import android.view.Display; |
| import android.view.Surface; |
| |
| import androidx.annotation.IntDef; |
| |
| import org.chromium.base.ApplicationStatus; |
| import org.chromium.base.ContextUtils; |
| import org.chromium.base.Log; |
| import org.chromium.base.annotations.CalledByNative; |
| import org.chromium.base.annotations.JNINamespace; |
| import org.chromium.base.annotations.NativeMethods; |
| |
| import java.lang.annotation.Retention; |
| import java.lang.annotation.RetentionPolicy; |
| import java.nio.ByteBuffer; |
| |
| /** |
| * This class implements Screen Capture using projection API, introduced in Android |
| * API 21 (L Release). Capture takes place in the current Looper, while pixel |
| * download takes place in another thread used by ImageReader. |
| **/ |
| @JNINamespace("media") |
| @SuppressWarnings("ValidFragment") // This fragment is created by native. |
| public class ScreenCapture extends Fragment { |
| private static final String TAG = "ScreenCapture"; |
| |
| private static final int REQUEST_MEDIA_PROJECTION = 1; |
| |
| @IntDef({CaptureState.ATTACHED, CaptureState.ALLOWED, CaptureState.STARTED, |
| CaptureState.STOPPING, CaptureState.STOPPED}) |
| @Retention(RetentionPolicy.SOURCE) |
| private @interface CaptureState { |
| int ATTACHED = 0; |
| int ALLOWED = 1; |
| int STARTED = 2; |
| int STOPPING = 3; |
| int STOPPED = 4; |
| } |
| |
| @IntDef({DeviceOrientation.PORTRAIT, DeviceOrientation.LANDSCAPE}) |
| @Retention(RetentionPolicy.SOURCE) |
| private @interface DeviceOrientation { |
| int PORTRAIT = 0; |
| int LANDSCAPE = 1; |
| } |
| |
| // Native callback context variable. |
| private final long mNativeScreenCaptureMachineAndroid; |
| |
| private final Object mCaptureStateLock = new Object(); |
| private @CaptureState int mCaptureState = CaptureState.STOPPED; |
| |
| private MediaProjection mMediaProjection; |
| private MediaProjectionManager mMediaProjectionManager; |
| private VirtualDisplay mVirtualDisplay; |
| private Surface mSurface; |
| private ImageReader mImageReader; |
| private HandlerThread mThread; |
| private Handler mBackgroundHandler; |
| private Display mDisplay; |
| private @DeviceOrientation int mCurrentOrientation; |
| private Intent mResultData; |
| |
| private int mScreenDensity; |
| private int mWidth; |
| private int mHeight; |
| private int mFormat; |
| private int mResultCode; |
| |
| ScreenCapture(long nativeScreenCaptureMachineAndroid) { |
| mNativeScreenCaptureMachineAndroid = nativeScreenCaptureMachineAndroid; |
| } |
| |
| // Factory method. |
| @CalledByNative |
| static ScreenCapture createScreenCaptureMachine(long nativeScreenCaptureMachineAndroid) { |
| return new ScreenCapture(nativeScreenCaptureMachineAndroid); |
| } |
| |
| // Internal class implementing the ImageReader listener. Gets pinged when a |
| // new frame is been captured and downloaded to memory-backed buffers. |
| private class CrImageReaderListener implements ImageReader.OnImageAvailableListener { |
| @Override |
| public void onImageAvailable(ImageReader reader) { |
| synchronized (mCaptureStateLock) { |
| if (mCaptureState != CaptureState.STARTED) { |
| Log.e(TAG, "Get captured frame in unexpected state."); |
| return; |
| } |
| } |
| |
| // If device is rotated, inform native, then re-create ImageReader and VirtualDisplay |
| // with the new orientation, and drop the current frame. |
| if (maybeDoRotation()) { |
| createImageReaderWithFormat(); |
| createVirtualDisplay(); |
| return; |
| } |
| |
| try (Image image = reader.acquireLatestImage()) { |
| if (image == null) return; |
| if (reader.getWidth() != image.getWidth() |
| || reader.getHeight() != image.getHeight()) { |
| Log.e(TAG, "ImageReader size (" + reader.getWidth() + "x" + reader.getHeight() |
| + ") did not match Image size (" + image.getWidth() + "x" |
| + image.getHeight() + ")"); |
| throw new IllegalStateException(); |
| } |
| |
| switch (image.getFormat()) { |
| case PixelFormat.RGBA_8888: |
| if (image.getPlanes().length != 1) { |
| Log.e(TAG, "Unexpected image planes for RGBA_8888 format: " |
| + image.getPlanes().length); |
| throw new IllegalStateException(); |
| } |
| |
| ScreenCaptureJni.get().onRGBAFrameAvailable( |
| mNativeScreenCaptureMachineAndroid, ScreenCapture.this, |
| image.getPlanes()[0].getBuffer(), |
| image.getPlanes()[0].getRowStride(), image.getCropRect().left, |
| image.getCropRect().top, image.getCropRect().width(), |
| image.getCropRect().height(), image.getTimestamp()); |
| break; |
| case ImageFormat.YUV_420_888: |
| if (image.getPlanes().length != 3) { |
| Log.e(TAG, "Unexpected image planes for YUV_420_888 format: " |
| + image.getPlanes().length); |
| throw new IllegalStateException(); |
| } |
| |
| // The pixel stride of Y plane is always 1. The U/V planes are guaranteed |
| // to have the same row stride and pixel stride. |
| ScreenCaptureJni.get().onI420FrameAvailable( |
| mNativeScreenCaptureMachineAndroid, ScreenCapture.this, |
| image.getPlanes()[0].getBuffer(), |
| image.getPlanes()[0].getRowStride(), |
| image.getPlanes()[1].getBuffer(), image.getPlanes()[2].getBuffer(), |
| image.getPlanes()[1].getRowStride(), |
| image.getPlanes()[1].getPixelStride(), image.getCropRect().left, |
| image.getCropRect().top, image.getCropRect().width(), |
| image.getCropRect().height(), image.getTimestamp()); |
| break; |
| default: |
| Log.e(TAG, "Unexpected image format: " + image.getFormat()); |
| throw new IllegalStateException(); |
| } |
| } catch (IllegalStateException ex) { |
| Log.e(TAG, "acquireLatestImage():" + ex); |
| } catch (UnsupportedOperationException ex) { |
| Log.i(TAG, "acquireLatestImage():" + ex); |
| if (mFormat == ImageFormat.YUV_420_888) { |
| // YUV_420_888 is the preference, but not all devices support it, |
| // fall-back to RGBA_8888 then. |
| mFormat = PixelFormat.RGBA_8888; |
| createImageReaderWithFormat(); |
| createVirtualDisplay(); |
| } |
| } |
| } |
| } |
| |
| private class MediaProjectionCallback extends MediaProjection.Callback { |
| @Override |
| public void onStop() { |
| changeCaptureStateAndNotify(CaptureState.STOPPED); |
| mMediaProjection = null; |
| if (mVirtualDisplay == null) return; |
| mVirtualDisplay.release(); |
| mVirtualDisplay = null; |
| } |
| } |
| |
| @Override |
| public void onAttach(Context context) { |
| super.onAttach(context); |
| Log.d(TAG, "onAttach"); |
| changeCaptureStateAndNotify(CaptureState.ATTACHED); |
| } |
| |
| // This method was deprecated in API level 23 by onAttach(Context). |
| // TODO(braveyao): remove this method after the minSdkVersion of chrome is 23, |
| // https://crbug.com/614172. |
| @SuppressWarnings("deprecation") |
| @Override |
| public void onAttach(Activity activity) { |
| super.onAttach(activity); |
| Log.d(TAG, "onAttach"); |
| changeCaptureStateAndNotify(CaptureState.ATTACHED); |
| } |
| |
| @Override |
| public void onDetach() { |
| super.onDetach(); |
| Log.d(TAG, "onDetach"); |
| stopCapture(); |
| } |
| |
| @CalledByNative |
| public boolean allocate(int width, int height) { |
| mWidth = width; |
| mHeight = height; |
| |
| mMediaProjectionManager = |
| (MediaProjectionManager) ContextUtils.getApplicationContext().getSystemService( |
| Context.MEDIA_PROJECTION_SERVICE); |
| if (mMediaProjectionManager == null) { |
| Log.e(TAG, "mMediaProjectionManager is null"); |
| return false; |
| } |
| |
| DisplayManager displayManager = |
| (DisplayManager) ContextUtils.getApplicationContext().getSystemService( |
| Context.DISPLAY_SERVICE); |
| mDisplay = displayManager.getDisplay(Display.DEFAULT_DISPLAY); |
| |
| DisplayMetrics metrics = new DisplayMetrics(); |
| mDisplay.getMetrics(metrics); |
| mScreenDensity = metrics.densityDpi; |
| |
| return true; |
| } |
| |
| @CalledByNative |
| public boolean startPrompt() { |
| Log.d(TAG, "startPrompt"); |
| Activity activity = ApplicationStatus.getLastTrackedFocusedActivity(); |
| if (activity == null) { |
| Log.e(TAG, "activity is null"); |
| return false; |
| } |
| FragmentManager fragmentManager = activity.getFragmentManager(); |
| FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); |
| fragmentTransaction.add(this, "screencapture"); |
| try { |
| fragmentTransaction.commit(); |
| } catch (RuntimeException e) { |
| Log.e(TAG, "ScreenCaptureExcaption " + e); |
| return false; |
| } |
| |
| synchronized (mCaptureStateLock) { |
| while (mCaptureState != CaptureState.ATTACHED) { |
| try { |
| mCaptureStateLock.wait(); |
| } catch (InterruptedException ex) { |
| Log.e(TAG, "ScreenCaptureException: " + ex); |
| } |
| } |
| } |
| |
| try { |
| startActivityForResult( |
| mMediaProjectionManager.createScreenCaptureIntent(), REQUEST_MEDIA_PROJECTION); |
| } catch (android.content.ActivityNotFoundException e) { |
| Log.e(TAG, "ScreenCaptureException " + e); |
| return false; |
| } |
| return true; |
| } |
| |
| @Override |
| public void onActivityResult(int requestCode, int resultCode, Intent data) { |
| if (requestCode != REQUEST_MEDIA_PROJECTION) return; |
| |
| if (resultCode == Activity.RESULT_OK) { |
| mResultCode = resultCode; |
| mResultData = data; |
| changeCaptureStateAndNotify(CaptureState.ALLOWED); |
| } |
| ScreenCaptureJni.get().onActivityResult(mNativeScreenCaptureMachineAndroid, |
| ScreenCapture.this, resultCode == Activity.RESULT_OK); |
| } |
| |
| @CalledByNative |
| public boolean startCapture() { |
| Log.d(TAG, "startCapture"); |
| synchronized (mCaptureStateLock) { |
| if (mCaptureState != CaptureState.ALLOWED) { |
| Log.e(TAG, "startCapture() invoked without user permission."); |
| return false; |
| } |
| } |
| mMediaProjection = mMediaProjectionManager.getMediaProjection(mResultCode, mResultData); |
| if (mMediaProjection == null) { |
| Log.e(TAG, "mMediaProjection is null"); |
| return false; |
| } |
| mMediaProjection.registerCallback(new MediaProjectionCallback(), null); |
| |
| mThread = new HandlerThread("ScreenCapture"); |
| mThread.start(); |
| mBackgroundHandler = new Handler(mThread.getLooper()); |
| |
| // YUV420 is preferred. But not all devices supports it and it even will |
| // crash some devices. See https://crbug.com/674989 . A feature request |
| // was already filed to support YUV420 in VirturalDisplay. Before YUV420 |
| // is available, stay with RGBA_8888 at present. |
| mFormat = PixelFormat.RGBA_8888; |
| |
| maybeDoRotation(); |
| createImageReaderWithFormat(); |
| createVirtualDisplay(); |
| |
| changeCaptureStateAndNotify(CaptureState.STARTED); |
| return true; |
| } |
| |
| @CalledByNative |
| public void stopCapture() { |
| Log.d(TAG, "stopCapture"); |
| synchronized (mCaptureStateLock) { |
| if (mMediaProjection != null && mCaptureState == CaptureState.STARTED) { |
| mMediaProjection.stop(); |
| changeCaptureStateAndNotify(CaptureState.STOPPING); |
| |
| while (mCaptureState != CaptureState.STOPPED) { |
| try { |
| mCaptureStateLock.wait(); |
| } catch (InterruptedException ex) { |
| Log.e(TAG, "ScreenCaptureEvent: " + ex); |
| } |
| } |
| } else { |
| changeCaptureStateAndNotify(CaptureState.STOPPED); |
| } |
| } |
| } |
| |
| private void createImageReaderWithFormat() { |
| if (mImageReader != null) { |
| mImageReader.close(); |
| } |
| |
| final int maxImages = 2; |
| mImageReader = ImageReader.newInstance(mWidth, mHeight, mFormat, maxImages); |
| mSurface = mImageReader.getSurface(); |
| final CrImageReaderListener imageReaderListener = new CrImageReaderListener(); |
| mImageReader.setOnImageAvailableListener(imageReaderListener, mBackgroundHandler); |
| } |
| |
| private void createVirtualDisplay() { |
| if (mVirtualDisplay != null) { |
| mVirtualDisplay.release(); |
| } |
| |
| mVirtualDisplay = mMediaProjection.createVirtualDisplay("ScreenCapture", mWidth, mHeight, |
| mScreenDensity, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, mSurface, null, |
| null); |
| } |
| |
| private void changeCaptureStateAndNotify(@CaptureState int state) { |
| synchronized (mCaptureStateLock) { |
| mCaptureState = state; |
| mCaptureStateLock.notifyAll(); |
| } |
| } |
| |
| private int getDeviceRotation() { |
| switch (mDisplay.getRotation()) { |
| case Surface.ROTATION_0: |
| return 0; |
| case Surface.ROTATION_90: |
| return 90; |
| case Surface.ROTATION_180: |
| return 180; |
| case Surface.ROTATION_270: |
| return 270; |
| default: |
| // This should not happen. |
| assert false; |
| return 0; |
| } |
| } |
| |
| private @DeviceOrientation int getDeviceOrientation(int rotation) { |
| switch (rotation) { |
| case 0: |
| case 180: |
| return DeviceOrientation.PORTRAIT; |
| case 90: |
| case 270: |
| return DeviceOrientation.LANDSCAPE; |
| default: |
| // This should not happen; |
| assert false; |
| return DeviceOrientation.LANDSCAPE; |
| } |
| } |
| |
| private boolean maybeDoRotation() { |
| final int rotation = getDeviceRotation(); |
| final @DeviceOrientation int orientation = getDeviceOrientation(rotation); |
| if (orientation == mCurrentOrientation) { |
| return false; |
| } |
| |
| mCurrentOrientation = orientation; |
| rotateCaptureOrientation(orientation); |
| ScreenCaptureJni.get().onOrientationChange( |
| mNativeScreenCaptureMachineAndroid, ScreenCapture.this, rotation); |
| return true; |
| } |
| |
| private void rotateCaptureOrientation(@DeviceOrientation int orientation) { |
| if ((orientation == DeviceOrientation.LANDSCAPE && mWidth < mHeight) |
| || (orientation == DeviceOrientation.PORTRAIT && mHeight < mWidth)) { |
| mWidth += mHeight - (mHeight = mWidth); |
| } |
| } |
| |
| @NativeMethods |
| interface Natives { |
| // Method for ScreenCapture implementations to call back native code. |
| void onRGBAFrameAvailable(long nativeScreenCaptureMachineAndroid, ScreenCapture caller, |
| ByteBuffer buf, int rowStride, int left, int top, int width, int height, |
| long timestamp); |
| |
| void onI420FrameAvailable(long nativeScreenCaptureMachineAndroid, ScreenCapture caller, |
| ByteBuffer yBuffer, int yStride, ByteBuffer uBuffer, ByteBuffer vBuffer, |
| int uvRowStride, int uvPixelStride, int left, int top, int width, int height, |
| long timestamp); |
| // Method for ScreenCapture implementations to notify activity result. |
| void onActivityResult( |
| long nativeScreenCaptureMachineAndroid, ScreenCapture caller, boolean result); |
| |
| // Method for ScreenCapture implementations to notify orientation change. |
| void onOrientationChange( |
| long nativeScreenCaptureMachineAndroid, ScreenCapture caller, int rotation); |
| } |
| } |