Android 5.0 アプリからスクリーンショットを撮影する


screencapture_dialogAndroidアプリからAPIをつかってスクリーンショットを撮影する方法を紹介します。Android 4.4(SDK Level.20以下)の端末では、Androidアプリからスクリーンショットを保存する汎用的なAPIはありませんでした。特にセキュリティが大きな要因です(記事の末尾に経緯を記載しました。読んでみてください)。

Android 5.0 (Lollipop / Android SDK Level 21)からはMediaProjection APIを使うことで、スクリーンショットが撮影できます。

またGoogleのサンプルプロジェクトではBitmapの取得ではなく、SurfaceViewへの常時反映(ミラーリング、スクリーンキャプチャし続ける)の例があります。Fragmentをつかっており、サンプルとしてはやや複雑な構成です。本記事を読んだあとに参照するとスムーズでしょう。

今回のサンプルは、MediaProjection APIをつかってスクリーンショットを撮影します。取得したBitmapをImageViewで表示する簡易なアプリです。実際に使うには、自分自身がスクリーンショットに映り込むことを避けるため、アプリを一旦非表示にしたほうがよいでしょう(Windowを消したり、Service化するなど)。

今回の記事からサンプルコードをGitHub上へ公開しています。以前の記事もサルベージしていきますが、順次対応になるので以前のポストについては気長にお待ちください

MediaProjectionManagerの取得

続きからどうぞ


まず、MediaProjectionのまえにMediaProjectionManagerを取得します。画面のキャプチャには権限が必要なためです。MediaProjectionManagerを使って使用者にコンテンツのキャプチャの許可を求めるダイアログを表示します(Android OSによって自動的に表示される)。ユーザーは確認して許可/拒否を選択します。

■src/MainActivity.java

public class MainActivity extends Activity {

    private static final String TAG = "ScreenCapture";
    private static final int REQUEST_CODE_SCREEN_CAPTURE = 1;

    private MediaProjectionManager mMediaProjectionManager;
    private MediaProjection mMediaProjection;
    private VirtualDisplay mVirtualDisplay;
    ...省略...

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        ...省略...

        // Lintによるサジェスト "Must be one of..."が赤い下線で出るケースがあります。
        // ここでは無視します(ビルドできる)
        mMediaProjectionManager = (MediaProjectionManager)
                getSystemService(Context.MEDIA_PROJECTION_SERVICE);

        // MediaProjectionの利用にはパーミッションが必要。
        // ユーザーへの問い合わせのため、Intentを発行
        Intent permissionIntent = mMediaProjectionManager.createScreenCaptureIntent();
        startActivityForResult(permissionIntent, REQUEST_CODE_SCREEN_CAPTURE);
    }
    ...省略...
}

MediaProjectionManagerをgetSystemServiceメソッドから取得します。この時、Android StudioがLintで警告してくる場合がありますが、ビルドできますのでこのサンプルでは無視してください。
パーミッション(権限)の確認のため、ユーザーにダイアログを表示します。MediaProjectionManagercreateScreenCaptureIntentメソッドでIntentを取得します。次にstartActivityForResultメソッドでAndroid OSに処理を渡しています。

ユーザーが何を選んだかは、このあとの処理で結果を受け取ります。もしユーザーがダイアログからキャプチャを許可すると、次のような表示が通知バーに追加されます(通知バーへのアイコン常駐と一覧、詳細表示)。

screencapture_notification screencapture_notification_detail
通知バーの表示 「画面のキャスト」詳細

MediaProjectionの取得とVirtualDisplayの生成

Intentの結果は、MainActivityのonActivityForResultメソッドで受け取ります。キャプチャの許可があればスクリーンショットの準備を行い、拒否されていれば何もしません。

■src/MainActivity.java

public class MainActivity extends Activity {
    ...省略...
    private ImageReader mImageReader; // スクリーンショット用
    private int mWidth;
    private int mHeight;
    ...省略...
    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent intent) {
        if (REQUEST_CODE_SCREEN_CAPTURE == requestCode) {
            if (resultCode != RESULT_OK) {
                //パーミッションなし
                Toast.makeText(this, "permission denied", Toast.LENGTH_LONG).show();
                return;
            }
            // MediaProjectionの取得
            mMediaProjection =
                    mMediaProjectionManager.getMediaProjection(resultCode, intent);

            DisplayMetrics metrics = getResources().getDisplayMetrics();
            mWidth = metrics.widthPixels;
            mHeight = metrics.heightPixels;
            int density = metrics.densityDpi;

            Log.d(TAG,"setup VirtualDisplay");
            mImageReader = ImageReader
                    .newInstance(mWidth, mHeight, ImageFormat.RGB_565, 2);
            mVirtualDisplay = mMediaProjection
                    .createVirtualDisplay("Capturing Display",
                    mWidth, mHeight, density,
                    DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
                    mImageReader.getSurface(), null, null);
        }
    }

    @Override
    protected void onPause() {
        if (mVirtualDisplay != null) {
            Log.d(TAG,"release VirtualDisplay");
            mVirtualDisplay.release();
        }
        super.onPause();
    }
    ...省略...
}

キャプチャが許可されていれば、getMediaProjectionメソッドMediaProjectionを取得します。許可されていない場合は、メッセージを表示して終了します。次に取得したMediaProjectionは、そのままでは画面をキャプチャできません。スクリーンショットの撮影のため画面サイズに合わせたImageReaderを用意しています。

このImageReaderを出力先として、VirtualDisplay(ミラーリングする仮想ディスプレイ)を作ります。
MediaProjectionのcreateVirtualDisplayメソッドを使います。第1引数に名前、その後パラメータが続きますが、第5引数のフラグ(DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR)がどのような仮想ディスプレイかを決定づけるポイントとなります。

次の表は仮想ディスプレイの属性を示すフラグです。

DisplayManagerの仮想ディスプレイ表示条件

定数名 説明
VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR コンテンツをミラーリング表示する
VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY 独自のコンテンツを表示。ミラーリングしない
VIRTUAL_DISPLAY_FLAG_PRESENTATION プレゼンテーションモード
VIRTUAL_DISPLAY_FLAG_PUBLIC HDMIやWirelessディスプレイ
VIRTUAL_DISPLAY_FLAG_SECURE 暗号化対策が施されたセキュアなディスプレイ

フラグには、VIRTUAL_DISPLAY_FLAG_AUTO_MIRRORとVIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLYは排他であるなど細かい条件があります。詳細はリファンレスを参照してください。

また、サンプルコードのImageReader部分がSurfaceViewであれば画面に表示する(常に端末の画面がキャプチャされSurfaceViewが更新される)、ということも可能です。このことからVirtualDisplayは実在するディスプレイを使わなくてもよい、柔軟性の高い仕組みでだとわかります。

スクリーンショットの取得

最後にボタンを押下したらスクリーンショットを撮る、という処理を追加します。撮影したスクリーンショットには通知バー、ソフトウェアキーが写っていることからも、画面全体をキャプチャできていることがわかります。
screencapture

■src/MainActivity.java

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        Button b = (Button) findViewById(R.id.button);
        b.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                Log.d(TAG,"getScreenshot");
                Bitmap screenshot = getScreenshot();
                ImageView iv = (ImageView) findViewById(R.id.imageView);
                iv.setImageBitmap(screenshot);
            }
        });
        ...省略...
    }

    private Bitmap getScreenshot() {
        // ImageReaderから画面を取り出す
        Image image = mImageReader.acquireLatestImage();
        Image.Plane[] planes = image.getPlanes();
        ByteBuffer buffer = planes[0].getBuffer();

        int pixelStride = planes[0].getPixelStride();
        int rowStride = planes[0].getRowStride();
        int rowPadding = rowStride - pixelStride * mWidth;

        // バッファからBitmapを生成
        Bitmap bitmap = Bitmap.createBitmap(
                mWidth + rowPadding / pixelStride, mHeight,
                Bitmap.Config.RGB_565);
        bitmap.copyPixelsFromBuffer(buffer);
        image.close();

        return bitmap;
    }
}

サンプルではボタンを押したときのView.OnClickListenerを登録して、getScreenshotメソッドを呼んでいます。getScreenshotメソッドでは、ImageReaderから画面を取り出し、Bitmapを生成して返却します。このとき、ImageReaderのフォーマットにあわせてBitmapを調整しています。

これで実装は完了です。

スクリーンショットとセキュリティ

いままでアプリからスクリーンショットの撮影が実現できなかった理由として、セキュリティ上の問題がありました。無制限に実行できると、ユーザー操作の監視につながるためです(銀行口座へのアクセスを観察する、クレジットカード、個人情報の盗み見など)。

今回、スクリーンショット(または動画撮影)できる契機となったのは、スマホのマルチプラットフォーム化、連携機能の強化という側面の影響があります。

MediaProjection APIはAndroid AutoChrome Castといった別のディスプレイに本体の表示をミラーリングする(または仮想的にセカンドディスプレイとして扱いたい)目的で開発されました。Googleは、これを「画面のキャスト」と表現しています。画面のキャストを利用するにあたっては、Android OSがダイアログを表示して、ユーザに利用可否を確認します。しかしながら、それでもなおスクリーンショット機能はユーザーにとっては脅威です。実装する場合は、必要性について十分に検討してください。デバッグ用途であればアプリ内のViewを取得する方法があります。多くの場合、アプリの責務を超えた画面全体のスクリーンショットは必要ありません

以上、おつかれさまでした。

謝辞

本記事の作成にあたり、Twitter上の発言を参考としています。 @ta9marさん、@zaki50さん、@8796nさん、@adakodaさん、@l_b__さん、@noritsunaさん、@hamatzさん、ありがとうございました。