1 /*
2  * Copyright 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.camera.integration.core;
18 
19 import static android.hardware.camera2.CameraCharacteristics.LENS_POSE_REFERENCE;
20 import static android.view.View.GONE;
21 import static android.view.View.VISIBLE;
22 
23 import static androidx.camera.testing.impl.FileUtil.canDeviceWriteToMediaStore;
24 import static androidx.camera.testing.impl.FileUtil.createParentFolder;
25 import static androidx.camera.testing.impl.FileUtil.generateVideoFileOutputOptions;
26 import static androidx.camera.testing.impl.FileUtil.generateVideoMediaStoreOptions;
27 import static androidx.camera.testing.impl.FileUtil.getAbsolutePathFromUri;
28 import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_DURATION_LIMIT_REACHED;
29 import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_FILE_SIZE_LIMIT_REACHED;
30 import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_INSUFFICIENT_STORAGE;
31 import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_NONE;
32 import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_SOURCE_INACTIVE;
33 
34 import static java.util.Objects.requireNonNull;
35 
36 import android.annotation.SuppressLint;
37 import android.content.pm.PackageManager;
38 import android.hardware.camera2.CameraCharacteristics;
39 import android.media.MediaScannerConnection;
40 import android.net.Uri;
41 import android.os.Build;
42 import android.os.Bundle;
43 import android.provider.MediaStore;
44 import android.util.Log;
45 import android.view.Menu;
46 import android.view.MotionEvent;
47 import android.view.ScaleGestureDetector;
48 import android.view.View;
49 import android.view.ViewGroup;
50 import android.widget.Button;
51 import android.widget.FrameLayout;
52 import android.widget.LinearLayout;
53 import android.widget.PopupMenu;
54 import android.widget.TextView;
55 import android.widget.Toast;
56 import android.widget.ToggleButton;
57 
58 import androidx.activity.result.ActivityResultLauncher;
59 import androidx.activity.result.contract.ActivityResultContracts;
60 import androidx.annotation.OptIn;
61 import androidx.annotation.UiThread;
62 import androidx.appcompat.app.AppCompatActivity;
63 import androidx.camera.camera2.interop.ExperimentalCamera2Interop;
64 import androidx.camera.camera2.pipe.integration.CameraPipeConfig;
65 import androidx.camera.core.Camera;
66 import androidx.camera.core.CameraControl;
67 import androidx.camera.core.CameraInfo;
68 import androidx.camera.core.CameraSelector;
69 import androidx.camera.core.CompositionSettings;
70 import androidx.camera.core.ConcurrentCamera;
71 import androidx.camera.core.ConcurrentCamera.SingleCameraConfig;
72 import androidx.camera.core.DynamicRange;
73 import androidx.camera.core.ExperimentalMirrorMode;
74 import androidx.camera.core.FocusMeteringAction;
75 import androidx.camera.core.MeteringPoint;
76 import androidx.camera.core.MirrorMode;
77 import androidx.camera.core.Preview;
78 import androidx.camera.core.UseCaseGroup;
79 import androidx.camera.core.resolutionselector.AspectRatioStrategy;
80 import androidx.camera.core.resolutionselector.ResolutionSelector;
81 import androidx.camera.lifecycle.ExperimentalCameraProviderConfiguration;
82 import androidx.camera.lifecycle.ProcessCameraProvider;
83 import androidx.camera.video.ExperimentalPersistentRecording;
84 import androidx.camera.video.FileOutputOptions;
85 import androidx.camera.video.MediaStoreOutputOptions;
86 import androidx.camera.video.OutputOptions;
87 import androidx.camera.video.PendingRecording;
88 import androidx.camera.video.Quality;
89 import androidx.camera.video.QualitySelector;
90 import androidx.camera.video.Recorder;
91 import androidx.camera.video.Recording;
92 import androidx.camera.video.RecordingStats;
93 import androidx.camera.video.VideoCapabilities;
94 import androidx.camera.video.VideoCapture;
95 import androidx.camera.video.VideoRecordEvent;
96 import androidx.camera.view.PreviewView;
97 import androidx.core.content.ContextCompat;
98 import androidx.core.math.MathUtils;
99 import androidx.core.util.Consumer;
100 import androidx.lifecycle.LifecycleOwner;
101 import androidx.test.espresso.idling.CountingIdlingResource;
102 
103 import com.google.common.base.Objects;
104 import com.google.common.collect.ImmutableList;
105 import com.google.common.util.concurrent.ListenableFuture;
106 
107 import org.jspecify.annotations.NonNull;
108 import org.jspecify.annotations.Nullable;
109 
110 import java.util.Collections;
111 import java.util.HashSet;
112 import java.util.List;
113 import java.util.Set;
114 import java.util.concurrent.ExecutionException;
115 import java.util.concurrent.TimeUnit;
116 
117 /**
118  * Concurrent camera activity.
119  */
120 public class ConcurrentCameraActivity extends AppCompatActivity {
121     private static final String TAG = "ConcurrentCamera";
122     private static final int REQUEST_CODE_PERMISSIONS = 1001;
123     private static final String[] REQUIRED_PERMISSIONS = new String[] {
124             "android.permission.CAMERA"
125     };
126 
127     // For Video Capture
128     private RecordUi mRecordUi;
129     private VideoCapture<Recorder> mVideoCapture;
130     private final CountingIdlingResource mVideoSavedIdlingResource =
131             new CountingIdlingResource("videosaved");
132     private Recording mActiveRecording;
133     private long mVideoCaptureAutoStopLength = 0;
134     private SessionMediaUriSet
135             mSessionVideosUriSet = new SessionMediaUriSet();
136     private static final Quality QUALITY_AUTO = null;
137     private Quality mVideoQuality;
138 
139     private @NonNull PreviewView mSinglePreviewView;
140     private @NonNull PreviewView mFrontPreviewView;
141     private @NonNull PreviewView mBackPreviewView;
142     private @NonNull FrameLayout mFrontPreviewViewForPip;
143     private @NonNull FrameLayout mBackPreviewViewForPip;
144     private @NonNull FrameLayout mFrontPreviewViewForSideBySide;
145     private @NonNull FrameLayout mBackPreviewViewForSideBySide;
146     private @NonNull ToggleButton mModeButton;
147     private @NonNull ToggleButton mLayoutButton;
148     private @NonNull ToggleButton mToggleButton;
149     private @NonNull ToggleButton mDualSelfieButton;
150     private @NonNull ToggleButton mDualRecordButton;
151     private @NonNull LinearLayout mSideBySideLayout;
152     private @NonNull FrameLayout mPiPLayout;
153     private @Nullable ProcessCameraProvider mCameraProvider;
154     private boolean mIsConcurrentModeOn = false;
155     private boolean mIsLayoutPiP = true;
156     private boolean mIsFrontPrimary = true;
157     private boolean mIsDualSelfieEnabled = false;
158     private boolean mIsDualRecordEnabled = false;
159     private boolean mIsCameraPipeEnabled = false;
160 
161     @Override
onCreate(@ullable Bundle savedInstanceState)162     protected void onCreate(@Nullable Bundle savedInstanceState) {
163         super.onCreate(savedInstanceState);
164         setContentView(R.layout.activity_concurrent_camera);
165 
166         mFrontPreviewViewForPip = findViewById(R.id.camera_front_pip);
167         mBackPreviewViewForPip = findViewById(R.id.camera_back_pip);
168         mBackPreviewViewForSideBySide = findViewById(R.id.camera_back_side_by_side);
169         mFrontPreviewViewForSideBySide = findViewById(R.id.camera_front_side_by_side);
170         mSideBySideLayout = findViewById(R.id.layout_side_by_side);
171         mPiPLayout = findViewById(R.id.layout_pip);
172         mModeButton = findViewById(R.id.mode_button);
173         mLayoutButton = findViewById(R.id.layout_button);
174         mToggleButton = findViewById(R.id.toggle_button);
175         mDualSelfieButton = findViewById(R.id.dual_selfie);
176         mDualRecordButton = findViewById(R.id.dual_record);
177 
178         Recorder recorder = new Recorder.Builder()
179                 .setQualitySelector(QualitySelector.from(Quality.FHD))
180                 .build();
181         mVideoCapture = new VideoCapture.Builder<>(recorder)
182                 .setMirrorMode(MirrorMode.MIRROR_MODE_ON_FRONT_ONLY)
183                 .build();
184         mRecordUi = new RecordUi(
185                 findViewById(R.id.Video),
186                 findViewById(R.id.video_pause),
187                 findViewById(R.id.video_stats),
188                 findViewById(R.id.video_quality),
189                 findViewById(R.id.video_persistent),
190                 (newState) -> {});
191         setUpRecordButton();
192 
193         boolean isConcurrentCameraSupported =
194                 getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA_CONCURRENT);
195         mModeButton.setEnabled(isConcurrentCameraSupported);
196         mLayoutButton.setEnabled(false);
197         if (!isConcurrentCameraSupported) {
198             Toast.makeText(this, getString(R.string.concurrent_not_supported_warning),
199                     Toast.LENGTH_SHORT).show();
200         }
201         mModeButton.setOnClickListener(view -> {
202             if (mCameraProvider == null) {
203                 return;
204             }
205             mFrontPreviewView = null;
206             mBackPreviewView = null;
207             // Switch the concurrent mode
208             if (mCameraProvider != null && mIsConcurrentModeOn) {
209                 mIsFrontPrimary = true;
210                 mIsLayoutPiP = true;
211                 bindPreviewForSingle(mCameraProvider);
212                 mIsConcurrentModeOn = false;
213                 mIsDualSelfieEnabled = false;
214                 mDualSelfieButton.setChecked(false);
215                 mIsDualRecordEnabled = false;
216                 mDualRecordButton.setChecked(false);
217             } else {
218                 mIsLayoutPiP = true;
219                 bindPreviewForPiP(mCameraProvider);
220                 mIsConcurrentModeOn = true;
221             }
222             mLayoutButton.setEnabled(mCameraProvider != null && mIsConcurrentModeOn);
223         });
224         mLayoutButton.setOnClickListener(view -> {
225             if (mIsLayoutPiP) {
226                 bindPreviewForSideBySide();
227             } else {
228                 bindPreviewForPiP(mCameraProvider);
229             }
230             mIsLayoutPiP = !mIsLayoutPiP;
231         });
232         mToggleButton.setOnClickListener(view -> {
233             mIsFrontPrimary = !mIsFrontPrimary;
234             if (mIsConcurrentModeOn) {
235                 if (mIsLayoutPiP) {
236                     bindPreviewForPiP(mCameraProvider);
237                 } else {
238                     bindPreviewForSideBySide();
239                 }
240             } else {
241                 bindPreviewForSingle(mCameraProvider);
242             }
243         });
244         mDualSelfieButton.setOnClickListener(view -> {
245             mIsDualSelfieEnabled = mDualSelfieButton.isChecked();
246             mDualSelfieButton.setChecked(mIsDualSelfieEnabled);
247         });
248         mDualRecordButton.setOnClickListener(view -> {
249             mIsDualRecordEnabled = mDualRecordButton.isChecked();
250             mDualRecordButton.setChecked(mIsDualRecordEnabled);
251         });
252 
253         setupPermissions();
254     }
255 
256     @SuppressLint("NullAnnotationGroup")
257     @OptIn(markerClass = ExperimentalCameraProviderConfiguration.class)
startCamera()258     private void startCamera() {
259         if (mIsCameraPipeEnabled) {
260             ProcessCameraProvider.configureInstance(CameraPipeConfig.defaultConfig());
261         }
262 
263         final ListenableFuture<ProcessCameraProvider> cameraProviderFuture =
264                 ProcessCameraProvider.getInstance(this);
265         cameraProviderFuture.addListener(() -> {
266             try {
267                 mCameraProvider = cameraProviderFuture.get();
268                 bindPreviewForSingle(mCameraProvider);
269             } catch (ExecutionException | InterruptedException e) {
270                 // No errors need to be handled for this Future.
271                 // This should never be reached.
272             }
273         }, ContextCompat.getMainExecutor(this));
274     }
275 
bindPreviewForSingle(@onNull ProcessCameraProvider cameraProvider)276     void bindPreviewForSingle(@NonNull ProcessCameraProvider cameraProvider) {
277         cameraProvider.unbindAll();
278         mSideBySideLayout.setVisibility(GONE);
279         mFrontPreviewViewForPip.setVisibility(VISIBLE);
280         mBackPreviewViewForPip.setVisibility(GONE);
281         mPiPLayout.setVisibility(VISIBLE);
282         mToggleButton.setVisibility(VISIBLE);
283         mLayoutButton.setVisibility(VISIBLE);
284         mRecordUi.hideUi();
285         // Front
286         mSinglePreviewView = new PreviewView(this);
287         mSinglePreviewView.setImplementationMode(PreviewView.ImplementationMode.COMPATIBLE);
288         mFrontPreviewViewForPip.addView(mSinglePreviewView);
289         Preview previewFront = new Preview.Builder()
290                 .build();
291         CameraSelector cameraSelectorFront = new CameraSelector.Builder()
292                 .requireLensFacing(mIsFrontPrimary
293                         ? CameraSelector.LENS_FACING_FRONT : CameraSelector.LENS_FACING_BACK)
294                 .build();
295         previewFront.setSurfaceProvider(mSinglePreviewView.getSurfaceProvider());
296         Camera camera = cameraProvider.bindToLifecycle(
297                 this, cameraSelectorFront, previewFront);
298         mDualSelfieButton.setVisibility(camera.getCameraInfo().isLogicalMultiCameraSupported()
299                 ? VISIBLE : GONE);
300         mDualRecordButton.setVisibility(VISIBLE);
301         mIsDualSelfieEnabled = false;
302         mIsDualRecordEnabled = false;
303         setupZoomAndTapToFocus(camera, mSinglePreviewView);
304     }
305 
bindPreviewForPiP(@onNull ProcessCameraProvider cameraProvider)306     void bindPreviewForPiP(@NonNull ProcessCameraProvider cameraProvider) {
307         mSideBySideLayout.setVisibility(GONE);
308         mFrontPreviewViewForPip.setVisibility(VISIBLE);
309         mBackPreviewViewForPip.setVisibility(VISIBLE);
310         mPiPLayout.setVisibility(VISIBLE);
311         mDualSelfieButton.setVisibility(GONE);
312         mDualRecordButton.setVisibility(GONE);
313         if (mIsDualRecordEnabled) {
314             mRecordUi.showUi();
315         } else {
316             mRecordUi.hideUi();
317         }
318         mToggleButton.setVisibility(mIsDualRecordEnabled ? GONE : VISIBLE);
319         mLayoutButton.setVisibility(mIsDualRecordEnabled ? GONE : VISIBLE);
320         if (mFrontPreviewView == null && mBackPreviewView == null) {
321             // Front
322             mFrontPreviewView = new PreviewView(this);
323             mFrontPreviewView.setImplementationMode(PreviewView.ImplementationMode.COMPATIBLE);
324             mFrontPreviewViewForPip.removeAllViews();
325             mFrontPreviewViewForPip.addView(mFrontPreviewView,
326                     new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
327                             ViewGroup.LayoutParams.MATCH_PARENT));
328             // Back
329             mBackPreviewView = new PreviewView(this);
330             mBackPreviewView.setImplementationMode(PreviewView.ImplementationMode.COMPATIBLE);
331             mBackPreviewViewForPip.removeAllViews();
332             mBackPreviewViewForPip.addView(mBackPreviewView,
333                     new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
334                             ViewGroup.LayoutParams.MATCH_PARENT));
335             cameraProvider.unbindAll();
336             bindToLifecycleForConcurrentCamera(
337                     cameraProvider,
338                     this,
339                     mFrontPreviewView,
340                     mBackPreviewView);
341         } else {
342             updateFrontAndBackView(
343                     mIsFrontPrimary,
344                     mFrontPreviewViewForPip,
345                     mBackPreviewViewForPip,
346                     mFrontPreviewView,
347                     mBackPreviewView);
348         }
349     }
350 
bindPreviewForSideBySide()351     void bindPreviewForSideBySide() {
352         mSideBySideLayout.setVisibility(VISIBLE);
353         mPiPLayout.setVisibility(GONE);
354         mDualSelfieButton.setVisibility(GONE);
355         if (mFrontPreviewView == null && mBackPreviewView == null) {
356             mFrontPreviewView = new PreviewView(this);
357             mFrontPreviewView.setImplementationMode(PreviewView.ImplementationMode.COMPATIBLE);
358             mBackPreviewView = new PreviewView(this);
359             mBackPreviewView.setImplementationMode(PreviewView.ImplementationMode.COMPATIBLE);
360         }
361         updateFrontAndBackView(
362                 mIsFrontPrimary,
363                 mFrontPreviewViewForSideBySide,
364                 mBackPreviewViewForSideBySide,
365                 mFrontPreviewView,
366                 mBackPreviewView);
367     }
368 
369     @SuppressLint({"NullAnnotationGroup", "RestrictedApiAndroidX"})
370     @OptIn(markerClass = {ExperimentalCamera2Interop.class, ExperimentalMirrorMode.class,
371             androidx.camera.camera2.pipe.integration.interop.ExperimentalCamera2Interop.class})
bindToLifecycleForConcurrentCamera( @onNull ProcessCameraProvider cameraProvider, @NonNull LifecycleOwner lifecycleOwner, @NonNull PreviewView frontPreviewView, @NonNull PreviewView backPreviewView)372     private void bindToLifecycleForConcurrentCamera(
373             @NonNull ProcessCameraProvider cameraProvider,
374             @NonNull LifecycleOwner lifecycleOwner,
375             @NonNull PreviewView frontPreviewView,
376             @NonNull PreviewView backPreviewView) {
377         if (mIsDualSelfieEnabled) {
378             CameraInfo cameraInfoPrimary = null;
379             for (CameraInfo cameraInfo : cameraProvider.getAvailableCameraInfos()) {
380                 if (cameraInfo.getLensFacing() == CameraSelector.LENS_FACING_FRONT) {
381                     cameraInfoPrimary = cameraInfo;
382                     break;
383                 }
384             }
385             if (cameraInfoPrimary == null
386                     || cameraInfoPrimary.getPhysicalCameraInfos().size() != 2) {
387                 return;
388             }
389 
390             String innerPhysicalCameraId = null;
391             String outerPhysicalCameraId = null;
392             for (CameraInfo info : cameraInfoPrimary.getPhysicalCameraInfos()) {
393                 if (isPrimaryCamera(info)) {
394                     innerPhysicalCameraId = mIsCameraPipeEnabled
395                             ? androidx.camera.camera2.pipe.integration.interop.Camera2CameraInfo
396                                     .from(info).getCameraId()
397                             : androidx.camera.camera2.interop.Camera2CameraInfo
398                                     .from(info).getCameraId();
399                 } else {
400                     outerPhysicalCameraId = mIsCameraPipeEnabled
401                             ? androidx.camera.camera2.pipe.integration.interop.Camera2CameraInfo
402                                     .from(info).getCameraId()
403                             : androidx.camera.camera2.interop.Camera2CameraInfo
404                                     .from(info).getCameraId();
405                 }
406             }
407 
408             if (Objects.equal(innerPhysicalCameraId, outerPhysicalCameraId)) {
409                 return;
410             }
411 
412             Preview previewFront = new Preview.Builder()
413                     .build();
414             previewFront.setSurfaceProvider(frontPreviewView.getSurfaceProvider());
415             SingleCameraConfig primary = new SingleCameraConfig(
416                     new CameraSelector.Builder()
417                             .requireLensFacing(CameraSelector.LENS_FACING_FRONT)
418                             .setPhysicalCameraId(innerPhysicalCameraId)
419                             .build(),
420                     new UseCaseGroup.Builder()
421                             .addUseCase(previewFront)
422                             .build(),
423                     lifecycleOwner);
424             Preview previewBack = new Preview.Builder()
425                     .setMirrorMode(MirrorMode.MIRROR_MODE_OFF)
426                     .build();
427             previewBack.setSurfaceProvider(backPreviewView.getSurfaceProvider());
428             SingleCameraConfig secondary = new SingleCameraConfig(
429                     new CameraSelector.Builder()
430                             .requireLensFacing(CameraSelector.LENS_FACING_FRONT)
431                             .setPhysicalCameraId(outerPhysicalCameraId)
432                             .build(),
433                     new UseCaseGroup.Builder()
434                             .addUseCase(previewBack)
435                             .build(),
436                     lifecycleOwner);
437             cameraProvider.bindToLifecycle(ImmutableList.of(primary, secondary));
438         } else {
439             CameraSelector cameraSelectorPrimary = null;
440             CameraSelector cameraSelectorSecondary = null;
441             for (List<CameraInfo> cameraInfoList : cameraProvider
442                     .getAvailableConcurrentCameraInfos()) {
443                 for (CameraInfo cameraInfo : cameraInfoList) {
444                     if (cameraInfo.getLensFacing() == CameraSelector.LENS_FACING_FRONT) {
445                         cameraSelectorPrimary = cameraInfo.getCameraSelector();
446                     } else if (cameraInfo.getLensFacing() == CameraSelector.LENS_FACING_BACK) {
447                         cameraSelectorSecondary = cameraInfo.getCameraSelector();
448                     }
449                 }
450 
451                 if (cameraSelectorPrimary == null || cameraSelectorSecondary == null) {
452                     // If either a primary or secondary selector wasn't found, reset both
453                     // to move on to the next list of CameraInfos.
454                     cameraSelectorPrimary = null;
455                     cameraSelectorSecondary = null;
456                 } else {
457                     // If both primary and secondary camera selectors were found, we can
458                     // conclude the search.
459                     break;
460                 }
461             }
462             if (cameraSelectorPrimary == null || cameraSelectorSecondary == null) {
463                 return;
464             }
465             if (mIsDualRecordEnabled) {
466                 mFrontPreviewViewForPip.removeAllViews();
467                 mFrontPreviewViewForPip.addView(mSinglePreviewView);
468                 mBackPreviewViewForPip.setVisibility(GONE);
469 
470                 ResolutionSelector resolutionSelector = new ResolutionSelector.Builder()
471                         .setAspectRatioStrategy(
472                                 AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY)
473                         .build();
474                 Preview preview = new Preview.Builder()
475                         .setResolutionSelector(resolutionSelector)
476                         .build();
477                 preview.setSurfaceProvider(mSinglePreviewView.getSurfaceProvider());
478                 UseCaseGroup useCaseGroup = new UseCaseGroup.Builder()
479                         .addUseCase(preview)
480                         .addUseCase(mVideoCapture)
481                         .build();
482                 // PiP
483                 SingleCameraConfig primary = new SingleCameraConfig(
484                         cameraSelectorPrimary,
485                         useCaseGroup,
486                         new CompositionSettings.Builder()
487                                 .setAlpha(1.0f)
488                                 .setOffset(0.0f, 0.0f)
489                                 .setScale(1.0f, 1.0f)
490                                 .build(),
491                         lifecycleOwner);
492                 SingleCameraConfig secondary = new SingleCameraConfig(
493                         cameraSelectorSecondary,
494                         useCaseGroup,
495                         new CompositionSettings.Builder()
496                                 .setAlpha(1.0f)
497                                 .setOffset(-0.3f, -0.4f)
498                                 .setScale(0.3f, 0.3f)
499                                 .build(),
500                         lifecycleOwner);
501                 cameraProvider.bindToLifecycle(ImmutableList.of(primary, secondary));
502             } else {
503                 Preview previewFront = new Preview.Builder()
504                         .build();
505                 previewFront.setSurfaceProvider(frontPreviewView.getSurfaceProvider());
506                 SingleCameraConfig primary = new SingleCameraConfig(
507                         cameraSelectorPrimary,
508                         new UseCaseGroup.Builder()
509                                 .addUseCase(previewFront)
510                                 .build(),
511                         lifecycleOwner);
512                 Preview previewBack = new Preview.Builder()
513                         .build();
514                 previewBack.setSurfaceProvider(backPreviewView.getSurfaceProvider());
515                 SingleCameraConfig secondary = new SingleCameraConfig(
516                         cameraSelectorSecondary,
517                         new UseCaseGroup.Builder()
518                                 .addUseCase(previewBack)
519                                 .build(),
520                         lifecycleOwner);
521                 ConcurrentCamera concurrentCamera =
522                         cameraProvider.bindToLifecycle(ImmutableList.of(primary, secondary));
523 
524                 setupZoomAndTapToFocus(concurrentCamera.getCameras().get(0), frontPreviewView);
525                 setupZoomAndTapToFocus(concurrentCamera.getCameras().get(1), backPreviewView);
526             }
527         }
528     }
529 
530     @SuppressLint("NullAnnotationGroup")
531     @OptIn(markerClass = { ExperimentalCamera2Interop.class,
532             androidx.camera.camera2.pipe.integration.interop.ExperimentalCamera2Interop.class })
isPrimaryCamera(@onNull CameraInfo info)533     private boolean isPrimaryCamera(@NonNull CameraInfo info) {
534         if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
535             return true;
536         }
537         if (mIsCameraPipeEnabled) {
538             return androidx.camera.camera2.pipe.integration.interop.Camera2CameraInfo.from(info)
539                     .getCameraCharacteristic(LENS_POSE_REFERENCE)
540                     == CameraCharacteristics.LENS_POSE_REFERENCE_PRIMARY_CAMERA;
541         } else {
542             return androidx.camera.camera2.interop.Camera2CameraInfo.from(info)
543                     .getCameraCharacteristic(LENS_POSE_REFERENCE)
544                     == CameraCharacteristics.LENS_POSE_REFERENCE_PRIMARY_CAMERA;
545         }
546     }
547 
setupZoomAndTapToFocus(Camera camera, PreviewView previewView)548     private void setupZoomAndTapToFocus(Camera camera, PreviewView previewView) {
549         ScaleGestureDetector scaleDetector = new ScaleGestureDetector(this,
550                 new ScaleGestureDetector.SimpleOnScaleGestureListener() {
551                     @Override
552                     public boolean onScale(@NonNull ScaleGestureDetector detector) {
553                         CameraInfo cameraInfo = camera.getCameraInfo();
554                         CameraControl cameraControl = camera.getCameraControl();
555                         float newZoom =
556                                 cameraInfo.getZoomState().getValue().getZoomRatio()
557                                         * detector.getScaleFactor();
558                         float clampedNewZoom = MathUtils.clamp(newZoom,
559                                 cameraInfo.getZoomState().getValue().getMinZoomRatio(),
560                                 cameraInfo.getZoomState().getValue().getMaxZoomRatio());
561                         cameraControl.setZoomRatio(clampedNewZoom)
562                                 .addListener(() -> {}, cmd -> cmd.run());
563                         return true;
564                     }
565                 });
566 
567 
568         previewView.setOnTouchListener((view, motionEvent) -> {
569             scaleDetector.onTouchEvent(motionEvent);
570 
571             if (motionEvent.getAction() == MotionEvent.ACTION_UP) {
572                 MeteringPoint point =
573                         previewView.getMeteringPointFactory().createPoint(
574                                 motionEvent.getX(), motionEvent.getY());
575 
576                 camera.getCameraControl().startFocusAndMetering(
577                         new FocusMeteringAction.Builder(point).build()).addListener(() -> {},
578                         ContextCompat.getMainExecutor(ConcurrentCameraActivity.this));
579             }
580             return true;
581         });
582     }
583 
updateFrontAndBackView( boolean isFrontPrimary, @NonNull ViewGroup frontParent, @NonNull ViewGroup backParent, @NonNull View frontChild, @NonNull View backChild)584     private static void updateFrontAndBackView(
585             boolean isFrontPrimary,
586             @NonNull ViewGroup frontParent,
587             @NonNull ViewGroup backParent,
588             @NonNull View frontChild,
589             @NonNull View backChild) {
590         frontParent.removeAllViews();
591         if (frontChild.getParent() != null) {
592             ((ViewGroup) frontChild.getParent()).removeView(frontChild);
593         }
594         backParent.removeAllViews();
595         if (backChild.getParent() != null) {
596             ((ViewGroup) backChild.getParent()).removeView(backChild);
597         }
598         if (isFrontPrimary) {
599             frontParent.addView(frontChild,
600                     new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
601                             ViewGroup.LayoutParams.MATCH_PARENT));
602             backParent.addView(backChild,
603                     new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
604                             ViewGroup.LayoutParams.MATCH_PARENT));
605         } else {
606             frontParent.addView(backChild,
607                     new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
608                             ViewGroup.LayoutParams.MATCH_PARENT));
609             backParent.addView(frontChild,
610                     new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
611                             ViewGroup.LayoutParams.MATCH_PARENT));
612         }
613     }
614 
allPermissionsGranted()615     private boolean allPermissionsGranted() {
616         for (String permission : REQUIRED_PERMISSIONS) {
617             if (ContextCompat.checkSelfPermission(this, permission)
618                     != PackageManager.PERMISSION_GRANTED) {
619                 return false;
620             }
621         }
622         return true;
623     }
624 
625     @Override
onRequestPermissionsResult(int requestCode, String @NonNull [] permissions, int @NonNull [] grantResults)626     public void onRequestPermissionsResult(int requestCode,
627             String @NonNull [] permissions,
628             int @NonNull [] grantResults) {
629         super.onRequestPermissionsResult(requestCode, permissions, grantResults);
630         if (requestCode == REQUEST_CODE_PERMISSIONS) {
631             if (allPermissionsGranted()) {
632                 startCamera();
633             } else {
634                 Toast.makeText(this, getString(R.string.permission_warning),
635                         Toast.LENGTH_SHORT).show();
636                 this.finish();
637             }
638         }
639     }
640 
isPermissionMissing()641     private boolean isPermissionMissing() {
642         for (String permission : REQUIRED_PERMISSIONS) {
643             if (ContextCompat.checkSelfPermission(this, permission)
644                     != PackageManager.PERMISSION_GRANTED) {
645                 return true;
646             }
647         }
648         return false;
649     }
650 
setupPermissions()651     private void setupPermissions() {
652         if (isPermissionMissing()) {
653             ActivityResultLauncher<String[]> permissionLauncher =
654                     registerForActivityResult(
655                             new ActivityResultContracts.RequestMultiplePermissions(),
656                             result -> {
657                                 for (String permission : REQUIRED_PERMISSIONS) {
658                                     if (!requireNonNull(result.get(permission))) {
659                                         Toast.makeText(getApplicationContext(),
660                                                         "Camera permission denied.",
661                                                         Toast.LENGTH_SHORT)
662                                                 .show();
663                                         finish();
664                                         return;
665                                     }
666                                 }
667                                 startCamera();
668                             });
669 
670             permissionLauncher.launch(REQUIRED_PERMISSIONS);
671         } else {
672             // Permissions already granted. Start camera.
673             startCamera();
674         }
675     }
676 
createDefaultVideoFolderIfNotExist()677     private void createDefaultVideoFolderIfNotExist() {
678         String videoFilePath =
679                 getAbsolutePathFromUri(getApplicationContext().getContentResolver(),
680                         MediaStore.Video.Media.EXTERNAL_CONTENT_URI);
681         if (videoFilePath == null || !createParentFolder(videoFilePath)) {
682             Log.e(TAG, "Failed to create parent directory for: " + videoFilePath);
683         }
684     }
685 
resetVideoSavedIdlingResource()686     private void resetVideoSavedIdlingResource() {
687         // Make the video saved idling resource non-idle, until required video length recorded.
688         if (mVideoSavedIdlingResource.isIdleNow()) {
689             mVideoSavedIdlingResource.increment();
690         }
691     }
692 
isPersistentRecordingEnabled()693     private boolean isPersistentRecordingEnabled() {
694         return mRecordUi.getButtonPersistent().isChecked();
695     }
696 
updateRecordingStats(@onNull RecordingStats stats)697     private void updateRecordingStats(@NonNull RecordingStats stats) {
698         double durationMs = TimeUnit.NANOSECONDS.toMillis(stats.getRecordedDurationNanos());
699         // Show megabytes in International System of Units (SI)
700         double sizeMb = stats.getNumBytesRecorded() / (1000d * 1000d);
701         String msg = String.format("%.2f sec\n%.2f MB", durationMs / 1000d, sizeMb);
702         mRecordUi.getTextStats().setText(msg);
703 
704         if (mVideoCaptureAutoStopLength > 0 && durationMs >= mVideoCaptureAutoStopLength
705                 && mRecordUi.getState() == RecordUi.State.RECORDING) {
706             mRecordUi.getButtonRecord().callOnClick();
707         }
708     }
709 
updateVideoSavedSessionData(@onNull Uri uri)710     private void updateVideoSavedSessionData(@NonNull Uri uri) {
711         if (mSessionVideosUriSet != null) {
712             mSessionVideosUriSet.add(uri);
713         }
714 
715         if (!mVideoSavedIdlingResource.isIdleNow()) {
716             mVideoSavedIdlingResource.decrement();
717         }
718     }
719 
720     private final Consumer<VideoRecordEvent> mVideoRecordEventListener = event -> {
721         updateRecordingStats(event.getRecordingStats());
722 
723         if (event instanceof VideoRecordEvent.Finalize) {
724             VideoRecordEvent.Finalize finalize = (VideoRecordEvent.Finalize) event;
725 
726             switch (finalize.getError()) {
727                 case ERROR_NONE:
728                 case ERROR_FILE_SIZE_LIMIT_REACHED:
729                 case ERROR_DURATION_LIMIT_REACHED:
730                 case ERROR_INSUFFICIENT_STORAGE:
731                 case ERROR_SOURCE_INACTIVE:
732                     Uri uri = finalize.getOutputResults().getOutputUri();
733                     OutputOptions outputOptions = finalize.getOutputOptions();
734                     String msg;
735                     String videoFilePath;
736                     if (outputOptions instanceof MediaStoreOutputOptions) {
737                         msg = "Saved uri " + uri;
738                         videoFilePath = getAbsolutePathFromUri(
739                                 getApplicationContext().getContentResolver(),
740                                 uri
741                         );
742                         updateVideoSavedSessionData(uri);
743                     } else if (outputOptions instanceof FileOutputOptions) {
744                         videoFilePath = ((FileOutputOptions) outputOptions).getFile().getPath();
745                         MediaScannerConnection.scanFile(this,
746                                 new String[]{videoFilePath}, null,
747                                 (path, uri1) -> {
748                                     Log.i(TAG, "Scanned " + path + " -> uri= " + uri1);
749                                     updateVideoSavedSessionData(uri1);
750                                 });
751                         msg = "Saved file " + videoFilePath;
752                     } else {
753                         throw new AssertionError("Unknown or unsupported OutputOptions type: "
754                                 + outputOptions.getClass().getSimpleName());
755                     }
756                     // The video file path is used in tracing e2e test log. Don't remove it.
757                     Log.d(TAG, "Saved video file: " + videoFilePath);
758 
759                     if (finalize.getError() != ERROR_NONE) {
760                         msg += " with code (" + finalize.getError() + ")";
761                     }
762                     Log.d(TAG, msg, finalize.getCause());
763                     Toast.makeText(this, msg, Toast.LENGTH_LONG).show();
764                     break;
765                 default:
766                     String errMsg = "Video capture failed by (" + finalize.getError() + "): "
767                             + finalize.getCause();
768                     Log.e(TAG, errMsg, finalize.getCause());
769                     Toast.makeText(this, errMsg, Toast.LENGTH_LONG).show();
770             }
771             mRecordUi.setState(RecordUi.State.IDLE);
772         }
773     };
774 
getQualityIconName(@ullable Quality quality)775     private static @NonNull String getQualityIconName(@Nullable Quality quality) {
776         if (quality == QUALITY_AUTO) {
777             return "Auto";
778         } else if (quality == Quality.UHD) {
779             return "UHD";
780         } else if (quality == Quality.FHD) {
781             return "FHD";
782         } else if (quality == Quality.HD) {
783             return "HD";
784         } else if (quality == Quality.SD) {
785             return "SD";
786         }
787         return "?";
788     }
789 
qualityToItemId(@ullable Quality quality)790     private static int qualityToItemId(@Nullable Quality quality) {
791         if (quality == QUALITY_AUTO) {
792             return 0;
793         } else if (quality == Quality.UHD) {
794             return 1;
795         } else if (quality == Quality.FHD) {
796             return 2;
797         } else if (quality == Quality.HD) {
798             return 3;
799         } else if (quality == Quality.SD) {
800             return 4;
801         } else {
802             throw new IllegalArgumentException("Undefined quality: " + quality);
803         }
804     }
805 
itemIdToQuality(int itemId)806     private static @Nullable Quality itemIdToQuality(int itemId) {
807         switch (itemId) {
808             case 0:
809                 return QUALITY_AUTO;
810             case 1:
811                 return Quality.UHD;
812             case 2:
813                 return Quality.FHD;
814             case 3:
815                 return Quality.HD;
816             case 4:
817                 return Quality.SD;
818             default:
819                 throw new IllegalArgumentException("Undefined item id: " + itemId);
820         }
821     }
822 
getQualityMenuItemName(@ullable Quality quality)823     private static @NonNull String getQualityMenuItemName(@Nullable Quality quality) {
824         if (quality == QUALITY_AUTO) {
825             return "Auto";
826         } else if (quality == Quality.UHD) {
827             return "UHD (2160P)";
828         } else if (quality == Quality.FHD) {
829             return "FHD (1080P)";
830         } else if (quality == Quality.HD) {
831             return "HD (720P)";
832         } else if (quality == Quality.SD) {
833             return "SD (480P)";
834         }
835         return "Unknown quality";
836     }
837 
838     @SuppressLint({"MissingPermission", "NullAnnotationGroup"})
839     @OptIn(markerClass = ExperimentalPersistentRecording.class)
setUpRecordButton()840     private void setUpRecordButton() {
841         mRecordUi.getButtonRecord().setOnClickListener((view) -> {
842             RecordUi.State state = mRecordUi.getState();
843             switch (state) {
844                 case IDLE:
845                     createDefaultVideoFolderIfNotExist();
846                     final PendingRecording pendingRecording;
847                     String fileName = "video_" + System.currentTimeMillis();
848                     String extension = "mp4";
849                     if (canDeviceWriteToMediaStore()) {
850                         // Use MediaStoreOutputOptions for public share media storage.
851                         pendingRecording = mVideoCapture.getOutput().prepareRecording(
852                                 this,
853                                 generateVideoMediaStoreOptions(getContentResolver(), fileName));
854                     } else {
855                         // Use FileOutputOption for devices in MediaStoreVideoCannotWrite Quirk.
856                         pendingRecording = mVideoCapture.getOutput().prepareRecording(
857                                 this, generateVideoFileOutputOptions(fileName, extension));
858                     }
859 
860                     resetVideoSavedIdlingResource();
861 
862                     if (isPersistentRecordingEnabled()) {
863                         pendingRecording.asPersistentRecording();
864                     }
865                     mActiveRecording = pendingRecording
866                             .withAudioEnabled()
867                             .start(ContextCompat.getMainExecutor(this),
868                                     mVideoRecordEventListener);
869                     mRecordUi.setState(RecordUi.State.RECORDING);
870                     break;
871                 case RECORDING:
872                 case PAUSED:
873                     mActiveRecording.stop();
874                     mActiveRecording = null;
875                     mRecordUi.setState(RecordUi.State.STOPPING);
876                     break;
877                 case STOPPING:
878                     // Record button should be disabled.
879                 default:
880                     throw new IllegalStateException(
881                             "Unexpected state when click record button: " + state);
882             }
883         });
884 
885         mRecordUi.getButtonPause().setOnClickListener(view -> {
886             RecordUi.State state = mRecordUi.getState();
887             switch (state) {
888                 case RECORDING:
889                     mActiveRecording.pause();
890                     mRecordUi.setState(RecordUi.State.PAUSED);
891                     break;
892                 case PAUSED:
893                     mActiveRecording.resume();
894                     mRecordUi.setState(RecordUi.State.RECORDING);
895                     break;
896                 case IDLE:
897                 case STOPPING:
898                     // Pause button should be invisible.
899                 default:
900                     throw new IllegalStateException(
901                             "Unexpected state when click pause button: " + state);
902             }
903         });
904 
905         // Final reference to this record UI
906         mRecordUi.getButtonQuality().setText(getQualityIconName(mVideoQuality));
907         mRecordUi.getButtonQuality().setOnClickListener(view -> {
908             PopupMenu popup = new PopupMenu(this, view);
909             Menu menu = popup.getMenu();
910 
911             // Add Auto item
912             final int groupId = Menu.NONE;
913             final int autoOrder = 0;
914             final int autoMenuId = qualityToItemId(QUALITY_AUTO);
915             menu.add(groupId, autoMenuId, autoOrder, getQualityMenuItemName(QUALITY_AUTO));
916             if (mVideoQuality == QUALITY_AUTO) {
917                 menu.findItem(autoMenuId).setChecked(true);
918             }
919 
920             // Add device supported qualities
921             VideoCapabilities videoCapabilities = Recorder.getVideoCapabilities(
922                     mCameraProvider.getCameraInfo(CameraSelector.DEFAULT_BACK_CAMERA));
923             List<Quality> supportedQualities = videoCapabilities.getSupportedQualities(
924                     DynamicRange.SDR);
925             // supportedQualities has been sorted by descending order.
926             for (int i = 0; i < supportedQualities.size(); i++) {
927                 Quality quality = supportedQualities.get(i);
928                 int itemId = qualityToItemId(quality);
929                 menu.add(groupId, itemId, autoOrder + 1 + i, getQualityMenuItemName(quality));
930                 if (mVideoQuality == quality) {
931                     menu.findItem(itemId).setChecked(true);
932                 }
933 
934             }
935             // Make menu single checkable
936             menu.setGroupCheckable(groupId, true, true);
937 
938             popup.setOnMenuItemClickListener(item -> {
939                 Quality quality = itemIdToQuality(item.getItemId());
940                 if (quality != mVideoQuality) {
941                     mVideoQuality = quality;
942                     mRecordUi.getButtonQuality().setText(getQualityIconName(mVideoQuality));
943                     // Quality changed, rebind UseCases
944                     startCamera();
945                 }
946                 return true;
947             });
948 
949             popup.show();
950         });
951     }
952 
953     private static class SessionMediaUriSet {
954         private final Set<Uri> mSessionMediaUris;
955 
SessionMediaUriSet()956         SessionMediaUriSet() {
957             mSessionMediaUris = Collections.synchronizedSet(new HashSet<>());
958         }
959 
add(@onNull Uri uri)960         public void add(@NonNull Uri uri) {
961             mSessionMediaUris.add(uri);
962         }
963     }
964 
965     @UiThread
966     private static class RecordUi {
967 
968         enum State {
969             IDLE, RECORDING, PAUSED, STOPPING
970         }
971 
972         private final Button mButtonRecord;
973         private final Button mButtonPause;
974         private final TextView mTextStats;
975         private final Button mButtonQuality;
976         private final ToggleButton mButtonPersistent;
977         private boolean mEnabled = false;
978         private RecordUi.State mState = RecordUi.State.IDLE;
979         private final Consumer<RecordUi.State> mNewStateConsumer;
980 
RecordUi(@onNull Button buttonRecord, @NonNull Button buttonPause, @NonNull TextView textStats, @NonNull Button buttonQuality, @NonNull ToggleButton buttonPersistent, @NonNull Consumer<RecordUi.State> onNewState)981         RecordUi(@NonNull Button buttonRecord, @NonNull Button buttonPause,
982                 @NonNull TextView textStats, @NonNull Button buttonQuality,
983                 @NonNull ToggleButton buttonPersistent,
984                 @NonNull Consumer<RecordUi.State> onNewState) {
985             mButtonRecord = buttonRecord;
986             mButtonPause = buttonPause;
987             mTextStats = textStats;
988             mButtonQuality = buttonQuality;
989             mButtonPersistent = buttonPersistent;
990             mNewStateConsumer = onNewState;
991         }
992 
setState(RecordUi.@onNull State state)993         void setState(RecordUi.@NonNull State state) {
994             if (state != mState) {
995                 mState = state;
996                 updateUi();
997                 mNewStateConsumer.accept(state);
998             }
999         }
1000 
getState()1001         RecordUi.@NonNull State getState() {
1002             return mState;
1003         }
1004 
showUi()1005         void showUi() {
1006             mButtonRecord.setVisibility(VISIBLE);
1007             mButtonPause.setVisibility(VISIBLE);
1008             mTextStats.setVisibility(VISIBLE);
1009             mButtonPersistent.setVisibility(VISIBLE);
1010             mButtonQuality.setVisibility(VISIBLE);
1011         }
1012 
hideUi()1013         void hideUi() {
1014             mButtonRecord.setVisibility(GONE);
1015             mButtonPause.setVisibility(GONE);
1016             mTextStats.setVisibility(GONE);
1017             mButtonPersistent.setVisibility(GONE);
1018             mButtonQuality.setVisibility(GONE);
1019         }
1020 
updateUi()1021         private void updateUi() {
1022             if (!mEnabled) {
1023                 return;
1024             }
1025             switch (mState) {
1026                 case IDLE:
1027                     mButtonRecord.setText("Record");
1028                     mButtonRecord.setEnabled(true);
1029                     mButtonPause.setText("Pause");
1030                     mButtonPause.setVisibility(View.INVISIBLE);
1031                     mButtonPersistent.setEnabled(true);
1032                     mButtonQuality.setEnabled(true);
1033                     break;
1034                 case RECORDING:
1035                     mButtonRecord.setText("Stop");
1036                     mButtonRecord.setEnabled(true);
1037                     mButtonPause.setText("Pause");
1038                     mButtonPause.setVisibility(View.VISIBLE);
1039                     mButtonPersistent.setEnabled(false);
1040                     mButtonQuality.setEnabled(false);
1041                     break;
1042                 case STOPPING:
1043                     mButtonRecord.setText("Saving");
1044                     mButtonRecord.setEnabled(false);
1045                     mButtonPause.setText("Pause");
1046                     mButtonPause.setVisibility(View.INVISIBLE);
1047                     mButtonPersistent.setEnabled(false);
1048                     mButtonQuality.setEnabled(true);
1049                     break;
1050                 case PAUSED:
1051                     mButtonRecord.setText("Stop");
1052                     mButtonRecord.setEnabled(true);
1053                     mButtonPause.setText("Resume");
1054                     mButtonPause.setVisibility(View.VISIBLE);
1055                     mButtonPersistent.setEnabled(false);
1056                     mButtonQuality.setEnabled(true);
1057                     break;
1058             }
1059         }
1060 
getButtonRecord()1061         Button getButtonRecord() {
1062             return mButtonRecord;
1063         }
1064 
getButtonPause()1065         Button getButtonPause() {
1066             return mButtonPause;
1067         }
1068 
getTextStats()1069         TextView getTextStats() {
1070             return mTextStats;
1071         }
1072 
getButtonQuality()1073         @NonNull Button getButtonQuality() {
1074             return mButtonQuality;
1075         }
1076 
getButtonPersistent()1077         ToggleButton getButtonPersistent() {
1078             return mButtonPersistent;
1079         }
1080     }
1081 }
1082