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