Android6.0でもカメラを使いたい 1
はじめに
約一年ほど放置しておりましたが、改めてCamera2に触れてみたらやっぱりハマってしまったという記録です。
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);
~省略~
- ここではGradle Retrolambda PluginとLightweight-Stream-APIを使用しています。
- 行数を減らすため、catch句のe.printStackTrace()は削除しています。処理を適宜追加してください。
権限
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へのアクセス)では権限が付与されているかどうかを確認するにとどめた方がシンプルかつ平和になりそうです。
長くなってきたので次回に続きます。