vaguely

和歌山に戻りました。ふらふらと色々なものに手を出す毎日。

Android6.0でもカメラを使いたい 1

はじめに

約一年ほど放置しておりましたが、改めてCamera2に触れてみたらやっぱりハマってしまったという記録です。

mslgt.hatenablog.com

github.com

TextureView

以下のサンプルに習い、カメラのプレビューを表示するTextureView用のクラスを追加しました。

PreviewTextureView.java

import android.content.Context;
import android.util.AttributeSet;
import android.view.TextureView;

public class PreviewTextureView extends TextureView {
    private int ratioWidth = 0;
    private int ratioHeight = 0;
    public AutoFitTextureView(Context context) {
        this(context, null);
    }
    public AutoFitTextureView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }
    public AutoFitTextureView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
    }
    public void setAspectRatio(int width, int height) {
        if (width < 0 || height < 0) {
            throw new IllegalArgumentException("Size cannot be negative.");
        }
        ratioWidth = width;
        ratioHeight = height;
        // サイズを指定してViewを更新する(onMeasure実行).
        requestLayout();
    }
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        // Viewのサイズを確定させる.
        int width = MeasureSpec.getSize(widthMeasureSpec);
        int height = MeasureSpec.getSize(heightMeasureSpec);
        if (ratioWidth == 0 || ratioHeight == 0) {
            setMeasuredDimension(width, height);
        } else {
            if (width < height * ratioWidth / ratioHeight) {
                setMeasuredDimension(width, width * ratioHeight / ratioWidth);
            } else {
                setMeasuredDimension(height * ratioWidth / ratioHeight, height);
            }
        }
    }
}

これでLayoutファイルでTextureViewを扱えるようになり、サイズ指定などをコードから削除できました。

activity_camera2.xml

< ?xml version="1.0" encoding="utf-8"? >
< RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width='match_parent'
    android:layout_height='match_parent'
    tools:context="jp.cameratest.Camera2Activity" >
    < !-- カメラの映像を表示するためのTextureView -- >
    < jp.cameratest.PreviewTextureView
        android:id="@+id/texture_preview_camera2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        / >
    < Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/btn_taking_photo"
        android:id="@+id/btn_taking_photo_camera2"
        android:layout_alignParentBottom="true"
        android:layout_centerHorizontal="true" / >
< /RelativeLayout >
  • Viewのサイズを算出して「setAspectRatio」に渡し、その中で「requestLayout」を実行することでViewサイズを決定して使用します。

Previewのサイズ

一つ目にハマったのがこれです。
なぜか、Nexus5Xで最大の画面サイズを取得してImageReaderを作成すると、CameraDevice.createCaptureSessionに失敗し、「onConfigureFailed」が呼ばれてしまうという問題が発生しました。

Nexus7では問題がなかったのであれこれ調べてみたところ、どうやら画面サイズが4000×3000(実際はもう少し大きかったと思いますが)のような大きな値であったことが原因のようでした。

そこで、画面比率は元のままで最大サイズを1920×1080として、これに一番近似するサイズを取得して設定することにしました。

Camera2Activity.java

~省略~
private void openCamera(int width, int height) {
~省略~  
    // ストリームの設定を取得(出力サイズを取得する).
    StreamConfigurationMap map = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP);
    if(map == null) {
        continue;
    }
    Size[] sizes = map.getOutputSizes(ImageFormat.JPEG);

    // 配列から最大の組み合わせを取得する.
    Size maxImageSize = new Size(width, height);
    Optional maxSize = Stream.of(sizes)
                        .max((a, b) -> Integer.compare(a.getWidth(), b.getWidth()));

    if(maxSize != null){
        maxImageSize = maxSize.get();
    }
~省略~  
    // 取得したSizeのうち、画面のアスペクト比に合致していて1920・1080以下の最大値をセット.
    previewSize = new Size(640, 480);

    // maxImageSize
    final float aspectRatio = ((float)maxImageSize.getHeight() / (float)maxImageSize.getWidth());

    Optional setSize = Stream.of(sizes)
            .filter(size ->
                    size.getWidth() <= 1920
                            && size.getHeight() <= 1080
                            && size.getHeight() == (size.getWidth() * aspectRatio))
            .max((a, b) -> Integer.compare(a.getWidth(), b.getWidth()));

    if(setSize != null){
        previewSize = setSize.get();
    }
    try {
        manager.openCamera(strCameraId, new CameraDevice.StateCallback() {
            @Override
            public void onOpened(@NonNull CameraDevice camera) {
                if(cameraDevice == null){
                    cameraDevice = camera;
                }
                runOnUiThread(
                    () -> {
                        // カメラ画面表示中はScreenをOffにしない.
                        getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
                    });
                createCameraPreviewSession();
            }
~省略~
private void createCameraPreviewSession(){
    if(cameraDevice == null || ! previewTextureView.isAvailable() || previewSize == null) {
        return;
    }
    // PreviewTextureView.javaで作成しているViewからSurfaceTextureを取得する.
    SurfaceTexture texture = previewTextureView.getSurfaceTexture();
    if(texture == null) {
        return;
    }
    texture.setDefaultBufferSize(previewSize.getWidth(), previewSize.getHeight());
    Surface surface = new Surface(texture);

    try {
        // プレビューウインドウのリクエスト.
        previewBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);
    } catch (CameraAccessException e) {
    }
    previewBuilder.addTarget(surface);

    try {
        cameraDevice.createCaptureSession(Arrays.asList(surface, imgReader.getSurface())
                , new CameraCaptureSession.StateCallback() {
                    @Override
                    public void onConfigured(@NonNull CameraCaptureSession session) {
                        previewSession = session;
                        if(cameraDevice == null) {
                            return;
                        }
                        setCameraMode(previewBuilder);
                        try {
                            // プレビューの開始.
                            previewSession.setRepeatingRequest(previewBuilder.build(), null, backgroundHandler);
                        } catch (CameraAccessException e) {
                        }
                    }
                    @Override
                    public void onConfigureFailed(@NonNull CameraCaptureSession session) {
                    }
        }, null);
~省略~

権限

Android6.0以降必須となっている権限の取得。
今回はCameraへのアクセスと画像を保存するためのStorageへのアクセスが必要となります。

CameraはこのActivityを開くときに、Storageへは撮影ボタンを押して、画像を保存するときにリクエストすることにします。

Camera

Camera2Activity.java

~省略~
private void initCameraView(){
    // プレビュー用のViewを追加.
    previewTextureView = (PreviewTextureView) findViewById(R.id.texture_preview_camera2);

    previewTextureView.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() {
        @Override
        public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {
            // Textureが有効化されたらプレビューを表示.
            // OS6.0以上ならCameraへのアクセス権確認.
            if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M){
                requestCameraPermission();
            }
            else{
                openCamera(width, height);
            }
        }
        @Override
        public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) {
            configureTransform(width, height);
        }
        @Override
        public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) {
            return true;
        }
        @Override
        public void onSurfaceTextureUpdated(SurfaceTexture surface) {
        }
    });
~省略~
@Override
public void onRequestPermissionsResult(int intRequestCode
        , @NonNull String[] strPermissions
        , @NonNull int[] intGrantResults) {
    super.onRequestPermissionsResult(intRequestCode, strPermissions, intGrantResults);
    
    // リクエスト中に画面を回転させるとintGrantResultsがカラで呼ばれるためチェック.
    if(intGrantResults.length <= 0){
        return;
    }
    switch (intRequestCode){
        case REQUEST_PERMISSION_CAMERA:
            if (intGrantResults[0] == PackageManager.PERMISSION_GRANTED) {
                runOnUiThread(
                    () ->{
                        // 権限が許可されたらプレビュー画面の使用準備.
                        openCamera(previewTextureView.getWidth(), previewTextureView.getHeight());
                    }
                );
            }
            else{
                // 権限付与を拒否されたらMainActivityに戻る.
                finish();
            }
            break;
~省略~
@TargetApi(Build.VERSION_CODES.M)
private void requestCameraPermission(){
    if(checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED){
        // 権限が許可されていたらプレビュー画面の使用準備.
        openCamera(previewTextureView.getWidth(), previewTextureView.getHeight());
    }
    else{
        requestPermissions(new String[]{Manifest.permission.CAMERA}, REQUEST_PERMISSION_CAMERA);
    }
}
~省略~
  • Cameraへの権限リクエストは、処理が始まっていないこともあってただ初期化処理の間に挟めばOKでした。

Storage

Camera2Activity.java

~省略~
@Override
public void onResume(){
    super.onResume();
    startBackgroundThread();

    if(displayManager != null
            && displayListener != null){
        displayManager.registerDisplayListener(displayListener, backgroundHandler);
    }
    // Storageの権限要求後は画像の保存処理を行う.
    if(isPictureTaken){
        prepareSavingImage();
        isPictureTaken = false;
    }
}
@Override
public void onPause(){
    if(previewSession != null) {
        previewSession.close();
        previewSession = null;
    }
    if(cameraDevice != null){
        cameraDevice.close();
        cameraDevice = null;
    }
    if(displayManager != null
            && displayListener != null){
        displayManager.unregisterDisplayListener(displayListener);
    }
    stopBackgroundThread();
    super.onPause();
}
~省略~
@Override
public void onRequestPermissionsResult(int intRequestCode
        , @NonNull String[] strPermissions
        , @NonNull int[] intGrantResults) {
    super.onRequestPermissionsResult(intRequestCode, strPermissions, intGrantResults);
    
    // リクエスト中に画面を回転させるとintGrantResultsがカラで呼ばれるためチェック.
    if(intGrantResults.length <= 0){
        return;
    }
    switch (intRequestCode){
~省略~
        case REQUEST_PERMISSION_STORAGE:
            
            if (intGrantResults[0] == PackageManager.PERMISSION_GRANTED) {
                // Storageへのアクセスが許可されたら、OnResumeで画像の保存処理実行.
                isPictureTaken = true;
            }
            else{
                // 権限付与を拒否されたらMainActivityに戻る.
                finish();
            }
            break;
    }
}
~省略~
private void initCameraView(){
~省略~
    Button btnTakingPhoto = (Button) findViewById(R.id.btn_taking_photo_camera2);
    if(btnTakingPhoto != null){
        btnTakingPhoto.setOnClickListener(
            (View v) ->{
                takePicture();
            }
        );
    }
}
~省略~
private void takePicture() {
    if(cameraDevice == null || previewSession == null) {
        return;
    }
    try {
        final CaptureRequest.Builder captureBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE);
        captureBuilder.addTarget(imgReader.getSurface());
        setCameraMode(captureBuilder);

        // 画像の回転を調整する.
        captureBuilder.set(CaptureRequest.JPEG_ORIENTATION
                , (ORIENTATIONS.get(getWindowManager().getDefaultDisplay().getRotation()) + intSensorOrientation + 270) % 360);
        // プレビュー画面の更新を一旦ストップ.
        previewSession.stopRepeating();
        // 画像の保存.
        previewSession.capture(captureBuilder.build(), null, null);
    } catch (CameraAccessException e) {
    }
}
~省略~
private void openCamera(int width, int height) {
~省略~
    // 画像を取得するためのImageReaderの作成.
    imgReader = ImageReader.newInstance(maxImageSize.getWidth(), maxImageSize.getHeight(), ImageFormat.JPEG, 2);
    
    // CameraCaptureSession.captureでImageを取得できたら実行.
    imgReader.setOnImageAvailableListener(
        (ImageReader reader)-> {
            // キャプチャされたImageを一時的に保存する.
            capturedImage = reader.acquireLatestImage();

            // OS6.0以上ならStorageへのアクセス権を確認.
            if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                requestStoragePermission();
            }
            else{
                prepareSavingImage();
            }
        }
        , backgroundHandler);
~省略~
@TargetApi(Build.VERSION_CODES.M)
private void requestStoragePermission(){
    if(checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED
            && checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED){
        // 権限付与済みであれば画像を保存する.
        prepareSavingImage();
    }
    else{
        // 権限がなければリクエスト.
        requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE}
                , REQUEST_PERMISSION_STORAGE);
    }
}
private void prepareSavingImage(){
    backgroundHandler.post(
        ()->{
            try {
                try {
                    ByteBuffer buffer = capturedImage.getPlanes()[0].getBuffer();
                    byte[] bytes = new byte[buffer.capacity()];
                    buffer.get(bytes);
                    saveImage(bytes);
                }catch (FileNotFoundException e) {
                }
            } catch (IOException e) {
            } finally {
                if (capturedImage != null) {
                    capturedImage.close();
                }
            }
        }
    );
}
private void saveImage(byte[] bytes) throws IOException {
    OutputStream output = null;
    try {
        // ファイルの保存先のディレクトリとファイル名.
        String strSaveDir = Environment.getExternalStorageDirectory().toString() + "/DCIM";
        String strSaveFileName = "pic_" + System.currentTimeMillis() +".jpg";

        final File file = new File(strSaveDir, strSaveFileName);

        // 生成した画像を出力する.
        output = new FileOutputStream(file);
        output.write(bytes);

        // 保存した画像を反映させる.
        MediaScannerConnection.scanFile(
                getApplicationContext()
                , new String[]{strSaveDir + "/" + strSaveFileName}
                , new String[]{"image/jpeg"}
                , (String path, Uri uri) ->{
                    runOnUiThread(
                        ()->{
                            Toast.makeText(getApplicationContext(), "Saved: " + path, Toast.LENGTH_SHORT).show();
                            // もう一度カメラのプレビュー表示を開始する.
                            if(cameraDevice == null){
                                // 権限確認でPause状態から復帰したらCameraDeviceの取得も行う.
                                openCamera(previewTextureView.getWidth(), previewTextureView.getHeight());
                            }
                            else{
                                createCameraPreviewSession();
                            }
                        }
                    );
                });
    } finally {
        if (output != null) {
            output.close();
        }
    }
}

権限リクエストのタイミング

撮影ボタン(btnTakingPhoto)を押したタイミングで権限をリクエストしてしまうと、許可して戻るまでの間もカメラが動き続けてしまうため、 CameraCaptureSession.captureが実行された後(ImageReaderのonImageAvailableが呼ばれます)、取得したImageを一時的に格納した状態で実行します。

また、リクエストのポップアップが表示されたタイミングでonPauseが呼ばれるため、CameraDeviceとCameraCaptureSessionがNullになります
(NullにするのはそのままBackgroundへ移行するとプレビュー表示用のTextureView更新が止まらずエラーになるためです)。

更に、権限が許可されたあと呼ばれるonRequestPermissionsResultがonResumeより先に呼ばれるため、onRequestPermissionsResultではフラグだけ立てるようにして、 画像の保存処理自体はonResumeで実行しています。

また、画像の保存処理はUIスレッドとは別のBackground処理用のスレッドで実行されているため、onResumeから実行するprepareSavingImageもBackgroundスレッドで行います(これを忘れて呼び出したまま処理が完了せず、これもしばらく考えてしまいましたorz)。

今回は対象の権限が必要になるタイミングでリクエストしたいと考えたため、なんだか複雑になってしまいましたが、少なくとも現状ではアプリの実行途中で権限が剥奪されることはない(設定からOffにした場合はアプリ再起動)ため、 Activityを開くタイミングなどにまとめてリクエストして、後の処理(今回はStorageへのアクセス)では権限が付与されているかどうかを確認するにとどめた方がシンプルかつ平和になりそうです。

長くなってきたので次回に続きます。

参考

Camera2

Stream API