blob: 2f62fb7a1aa6859cd0709901cb13662d8f71976d [file] [log] [blame]
// 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);
}
}