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 com.android.deviceaswebcam; 18 19 import static android.accessibilityservice.AccessibilityServiceInfo.FEEDBACK_ALL_MASK; 20 21 import android.accessibilityservice.AccessibilityServiceInfo; 22 import android.animation.ObjectAnimator; 23 import android.annotation.SuppressLint; 24 import android.app.AlertDialog; 25 import android.content.ComponentName; 26 import android.content.Intent; 27 import android.content.ServiceConnection; 28 import android.content.res.Configuration; 29 import android.graphics.Bitmap; 30 import android.graphics.Canvas; 31 import android.graphics.Paint; 32 import android.graphics.Rect; 33 import android.graphics.SurfaceTexture; 34 import android.graphics.drawable.BitmapDrawable; 35 import android.graphics.drawable.Drawable; 36 import android.graphics.drawable.ShapeDrawable; 37 import android.graphics.drawable.shapes.OvalShape; 38 import android.hardware.camera2.CameraCharacteristics; 39 import android.hardware.camera2.CameraMetadata; 40 import android.os.Bundle; 41 import android.os.ConditionVariable; 42 import android.os.IBinder; 43 import android.util.Log; 44 import android.util.Range; 45 import android.util.Size; 46 import android.view.DisplayCutout; 47 import android.view.GestureDetector; 48 import android.view.Gravity; 49 import android.view.HapticFeedbackConstants; 50 import android.view.KeyEvent; 51 import android.view.MotionEvent; 52 import android.view.Surface; 53 import android.view.TextureView; 54 import android.view.TouchDelegate; 55 import android.view.View; 56 import android.view.ViewGroup; 57 import android.view.Window; 58 import android.view.WindowInsets; 59 import android.view.WindowInsetsController; 60 import android.view.WindowManager; 61 import android.view.accessibility.AccessibilityManager; 62 import android.view.animation.AccelerateDecelerateInterpolator; 63 import android.widget.Button; 64 import android.widget.CheckBox; 65 import android.widget.FrameLayout; 66 import android.widget.ImageButton; 67 68 import androidx.annotation.NonNull; 69 import androidx.cardview.widget.CardView; 70 import androidx.fragment.app.FragmentActivity; 71 import androidx.window.layout.WindowMetrics; 72 import androidx.window.layout.WindowMetricsCalculator; 73 74 import com.android.DeviceAsWebcam.R; 75 import com.android.deviceaswebcam.utils.UserPrefs; 76 import com.android.deviceaswebcam.view.CameraPickerDialog; 77 import com.android.deviceaswebcam.view.ZoomController; 78 79 import java.util.List; 80 import java.util.Objects; 81 import java.util.concurrent.Executor; 82 import java.util.concurrent.Executors; 83 import java.util.function.Consumer; 84 85 public class DeviceAsWebcamPreview extends FragmentActivity { 86 private static final String TAG = DeviceAsWebcamPreview.class.getSimpleName(); 87 private static final boolean VERBOSE = Log.isLoggable(TAG, Log.VERBOSE); 88 private static final int ROTATION_ANIMATION_DURATION_MS = 300; 89 90 private final Executor mThreadExecutor = Executors.newFixedThreadPool(2); 91 private final ConditionVariable mWebcamControllerReady = new ConditionVariable(); 92 93 private boolean mTextureViewSetup = false; 94 private Size mPreviewSize; 95 private WebcamControllerImpl mWebcamController; 96 private AccessibilityManager mAccessibilityManager; 97 private int mCurrRotation = Surface.ROTATION_0; 98 private Size mCurrDisplaySize = new Size(0, 0); 99 100 private View mRootView; 101 private FrameLayout mTextureViewContainer; 102 private CardView mTextureViewCard; 103 private TextureView mTextureView; 104 private View mFocusIndicator; 105 private ZoomController mZoomController = null; 106 private ImageButton mToggleCameraButton; 107 private CameraPickerDialog mCameraPickerDialog; 108 109 private ImageButton mHighQualityToggleButton; 110 private boolean mIsWaitingOnHQToggle = false; // Read/Write on main thread only. 111 112 private UserPrefs mUserPrefs; 113 114 // A listener to monitor the preview size change events. This might be invoked when toggling 115 // camera or the webcam stream is started after the preview stream. 116 Consumer<Size> mPreviewSizeChangeListener = size -> runOnUiThread(() -> { 117 mPreviewSize = size; 118 setTextureViewScale(); 119 } 120 ); 121 122 // Listener for when Accessibility service are enabled or disabled. 123 AccessibilityManager.AccessibilityServicesStateChangeListener mAccessibilityListener = 124 accessibilityManager -> { 125 List<AccessibilityServiceInfo> services = 126 accessibilityManager.getEnabledAccessibilityServiceList(FEEDBACK_ALL_MASK); 127 boolean areServicesEnabled = !services.isEmpty(); 128 runOnUiThread(() -> 129 mZoomController.onAccessibilityServicesEnabled(areServicesEnabled)); 130 }; 131 132 133 /** 134 * {@link View.OnLayoutChangeListener} to add to 135 * {@link DeviceAsWebcamPreview#mTextureViewContainer} for when we need to know 136 * when changes to the view are committed. 137 * <p> 138 * NOTE: This removes itself as a listener after one call to prevent spurious callbacks 139 * once the texture view has been resized. 140 */ 141 View.OnLayoutChangeListener mTextureViewContainerLayoutListener = 142 new View.OnLayoutChangeListener() { 143 @Override 144 public void onLayoutChange(View view, int left, int top, int right, int bottom, 145 int oldLeft, int oldTop, int oldRight, int oldBottom) { 146 // Remove self to prevent further calls to onLayoutChange. 147 view.removeOnLayoutChangeListener(this); 148 // Update the texture view to fit the new bounds. 149 runOnUiThread(() -> { 150 if (mPreviewSize != null) { 151 setTextureViewScale(); 152 } 153 }); 154 } 155 }; 156 157 /** 158 * {@link TextureView.SurfaceTextureListener} handles several lifecycle events on a {@link 159 * TextureView}. 160 */ 161 private final TextureView.SurfaceTextureListener mSurfaceTextureListener = 162 new TextureView.SurfaceTextureListener() { 163 @Override 164 public void onSurfaceTextureAvailable( 165 SurfaceTexture texture, int width, int height) { 166 runOnUiThread( 167 () -> { 168 if (VERBOSE) { 169 Log.v( 170 TAG, 171 "onSurfaceTextureAvailable " + width + " x " + height); 172 } 173 mWebcamControllerReady.block(); 174 175 if (!mTextureViewSetup) { 176 setupTextureViewLayout(); 177 } 178 179 if (mWebcamController == null) { 180 return; 181 } 182 mWebcamController.setOnDestroyedCallback(() -> onWebcamDestroyed()); 183 184 if (mPreviewSize == null) { 185 return; 186 } 187 mWebcamController.setPreviewSurfaceTexture( 188 texture, mPreviewSize, mPreviewSizeChangeListener); 189 List<CameraId> availableCameraIds = 190 mWebcamController.getAvailableCameraIds(); 191 if (availableCameraIds != null && availableCameraIds.size() > 1) { 192 setupSwitchCameraSelector(); 193 mToggleCameraButton.setVisibility(View.VISIBLE); 194 if (canToggleCamera()) { 195 mToggleCameraButton.setOnClickListener(v -> toggleCamera()); 196 } else { 197 mToggleCameraButton.setOnClickListener( 198 v -> 199 mCameraPickerDialog.show( 200 getSupportFragmentManager(), 201 "CameraPickerDialog")); 202 } 203 mToggleCameraButton.setOnLongClickListener( 204 v -> { 205 mCameraPickerDialog.show( 206 getSupportFragmentManager(), 207 "CameraPickerDialog"); 208 return true; 209 }); 210 } else { 211 mToggleCameraButton.setVisibility(View.GONE); 212 } 213 rotateUiByRotationDegrees(mWebcamController.getCurrentRotation()); 214 mWebcamController.setRotationUpdateListener( 215 rotation -> { 216 rotateUiByRotationDegrees(rotation); 217 }); 218 }); 219 } 220 221 @Override 222 public void onSurfaceTextureSizeChanged( 223 SurfaceTexture texture, int width, int height) { 224 if (VERBOSE) { 225 Log.v(TAG, "onSurfaceTextureSizeChanged " + width + " x " + height); 226 } 227 } 228 229 @Override 230 public boolean onSurfaceTextureDestroyed(SurfaceTexture texture) { 231 runOnUiThread( 232 () -> { 233 if (mWebcamController != null) { 234 mWebcamController.removePreviewSurfaceTexture(); 235 } 236 }); 237 return true; 238 } 239 240 @Override 241 public void onSurfaceTextureUpdated(SurfaceTexture texture) {} 242 }; 243 244 private ServiceConnection mConnection = 245 new ServiceConnection() { 246 @Override 247 public void onServiceConnected(ComponentName className, IBinder serviceBinder) { 248 DeviceAsWebcamFgServiceImpl service = 249 ((DeviceAsWebcamFgServiceImpl.LocalBinder) serviceBinder).getService(); 250 if (VERBOSE) { 251 Log.v(TAG, "Got Fg service"); 252 } 253 if (service != null) { 254 mWebcamController = service.getWebcamControllerImpl(); 255 } 256 mWebcamControllerReady.open(); 257 } 258 259 @Override 260 public void onServiceDisconnected(ComponentName className) { 261 // Serialize updating mWebcamController on UI Thread as all consumers of 262 // mWebcamController 263 // run on the UI Thread. 264 runOnUiThread( 265 () -> { 266 mWebcamController = null; 267 finish(); 268 }); 269 } 270 }; 271 272 private MotionEventToZoomRatioConverter mMotionEventToZoomRatioConverter = null; 273 private final MotionEventToZoomRatioConverter.ZoomRatioUpdatedListener mZoomRatioListener = 274 new MotionEventToZoomRatioConverter.ZoomRatioUpdatedListener() { 275 @Override 276 public void onZoomRatioUpdated(float updatedZoomRatio) { 277 if (mWebcamController == null) { 278 return; 279 } 280 281 mWebcamController.setZoomRatio(updatedZoomRatio); 282 mZoomController.setZoomRatio( 283 updatedZoomRatio, ZoomController.ZOOM_UI_SEEK_BAR_MODE); 284 } 285 }; 286 287 private GestureDetector.SimpleOnGestureListener mTapToFocusListener = 288 new GestureDetector.SimpleOnGestureListener() { 289 @Override 290 public boolean onSingleTapUp(MotionEvent motionEvent) { 291 return tapToFocus(motionEvent); 292 } 293 }; 294 setTextureViewScale()295 private void setTextureViewScale() { 296 FrameLayout.LayoutParams frameLayout = new FrameLayout.LayoutParams(mPreviewSize.getWidth(), 297 mPreviewSize.getHeight(), Gravity.CENTER); 298 mTextureView.setLayoutParams(frameLayout); 299 300 int pWidth = mTextureViewContainer.getWidth(); 301 int pHeight = mTextureViewContainer.getHeight(); 302 float scaleYToUnstretched = (float) mPreviewSize.getWidth() / mPreviewSize.getHeight(); 303 float scaleXToUnstretched = (float) mPreviewSize.getHeight() / mPreviewSize.getWidth(); 304 float additionalScaleForX = (float) pWidth / mPreviewSize.getHeight(); 305 float additionalScaleForY = (float) pHeight / mPreviewSize.getWidth(); 306 307 // To fit the preview, either letterbox or pillar box. 308 float additionalScaleChosen = Math.min(additionalScaleForX, additionalScaleForY); 309 310 float texScaleX = scaleXToUnstretched * additionalScaleChosen; 311 float texScaleY = scaleYToUnstretched * additionalScaleChosen; 312 313 mTextureView.setScaleX(texScaleX); 314 mTextureView.setScaleY(texScaleY); 315 316 // Resize the card view to match TextureView's final size exactly. This is to clip 317 // textureView corners. 318 ViewGroup.LayoutParams cardLayoutParams = mTextureViewCard.getLayoutParams(); 319 // Reduce size by two pixels to remove any rounding errors from casting to int. 320 cardLayoutParams.height = ((int) (mPreviewSize.getHeight() * texScaleY)) - 2; 321 cardLayoutParams.width = ((int) (mPreviewSize.getWidth() * texScaleX)) - 2; 322 mTextureViewCard.setLayoutParams(cardLayoutParams); 323 } 324 325 @Override onConfigurationChanged(@onNull Configuration newConfig)326 public void onConfigurationChanged(@NonNull Configuration newConfig) { 327 super.onConfigurationChanged(newConfig); 328 runOnUiThread(this::setupMainLayout); 329 } 330 setupMainLayout()331 private void setupMainLayout() { 332 int currRotation = getDisplay().getRotation(); 333 WindowMetrics windowMetrics = 334 WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(this); 335 Size displaySize; 336 int width = windowMetrics.getBounds().width(); 337 int height = windowMetrics.getBounds().height(); 338 if (currRotation == Surface.ROTATION_90 || currRotation == Surface.ROTATION_270) { 339 // flip height and width because we want the height and width if the display 340 // in its natural orientation 341 displaySize = new Size(/*width=*/ height, /*height=*/ width); 342 } else { 343 displaySize = new Size(width, height); 344 } 345 346 if (mCurrRotation == currRotation && mCurrDisplaySize.equals(displaySize)) { 347 // Exit early if we have already drawn the UI for this state. 348 return; 349 } 350 351 mCurrDisplaySize = displaySize; 352 mCurrRotation = currRotation; 353 354 DisplayCutout displayCutout = 355 getWindowManager().getCurrentWindowMetrics().getWindowInsets().getDisplayCutout(); 356 if (displayCutout == null) { 357 displayCutout = DisplayCutout.NO_CUTOUT; 358 } 359 360 // We set up the UI to always be fixed to the device's natural orientation. 361 // If the device is rotated, we counter-rotate the UI to ensure that 362 // our UI has a "locked" orientation. 363 364 // resize the root view to match the display. Full screen preview covers the entire 365 // screen 366 ViewGroup.LayoutParams rootParams = mRootView.getLayoutParams(); 367 rootParams.width = mCurrDisplaySize.getWidth(); 368 rootParams.height = mCurrDisplaySize.getHeight(); 369 mRootView.setLayoutParams(rootParams); 370 371 // Counter rotate the main view and update padding values so we don't draw under 372 // cutouts. The cutout values we get are relative to the user. 373 int minTopPadding = (int) getResources().getDimension(R.dimen.root_view_padding_top_min); 374 switch (mCurrRotation) { 375 case Surface.ROTATION_90: 376 mRootView.setRotation(-90); 377 mRootView.setPadding( 378 /*left=*/ displayCutout.getSafeInsetBottom(), 379 /*top=*/ Math.max(minTopPadding, displayCutout.getSafeInsetLeft()), 380 /*right=*/ displayCutout.getSafeInsetTop(), 381 /*bottom=*/ displayCutout.getSafeInsetRight()); 382 break; 383 case Surface.ROTATION_270: 384 mRootView.setRotation(90); 385 mRootView.setPadding( 386 /*left=*/ displayCutout.getSafeInsetTop(), 387 /*top=*/ Math.max(minTopPadding, displayCutout.getSafeInsetRight()), 388 /*right=*/ displayCutout.getSafeInsetBottom(), 389 /*bottom=*/ displayCutout.getSafeInsetLeft()); 390 break; 391 case Surface.ROTATION_0: 392 mRootView.setRotation(0); 393 mRootView.setPadding( 394 /*left=*/ displayCutout.getSafeInsetLeft(), 395 /*top=*/ Math.max(minTopPadding, displayCutout.getSafeInsetTop()), 396 /*right=*/ displayCutout.getSafeInsetRight(), 397 /*bottom=*/ displayCutout.getSafeInsetBottom()); 398 break; 399 case Surface.ROTATION_180: 400 mRootView.setRotation(180); 401 mRootView.setPadding( 402 /*left=*/displayCutout.getSafeInsetRight(), 403 /*top=*/Math.max(minTopPadding, displayCutout.getSafeInsetBottom()), 404 /*right=*/displayCutout.getSafeInsetLeft(), 405 /*bottom=*/displayCutout.getSafeInsetTop()); 406 break; 407 } 408 409 // Ensure the touch target of HQ button is at least the required size. 410 View hqButtonParent = (View) mHighQualityToggleButton.getParent(); 411 // Post to the parent so we get an accurate number from getHitRect. 412 hqButtonParent.post( 413 () -> { 414 int minSize = 415 getResources() 416 .getDimensionPixelSize(R.dimen.hq_button_min_touch_target_size); 417 Rect hitRect = new Rect(); 418 mHighQualityToggleButton.getHitRect(hitRect); 419 420 int hitHeight = hitRect.height(); 421 int hitWidth = hitRect.width(); 422 423 if (hitHeight < minSize) { 424 // Clamp to a minimum of 1, so we're never smaller than the required size 425 int padding = Math.max((minSize - hitHeight) / 2, 1); 426 hitRect.top -= padding; 427 hitRect.bottom += padding; 428 } 429 430 if (hitWidth < minSize) { 431 // Clamp to a minimum of 1, so we're never smaller than the required size 432 int padding = Math.max((minSize - hitWidth / 2), 1); 433 hitRect.left -= padding; 434 hitRect.right += padding; 435 } 436 437 hqButtonParent.setTouchDelegate( 438 new TouchDelegate(hitRect, mHighQualityToggleButton)); 439 }); 440 441 // subscribe to layout changes of the texture view container so we can 442 // resize the texture view once the container has been drawn with the new 443 // margins 444 mTextureViewContainer.addOnLayoutChangeListener(mTextureViewContainerLayoutListener); 445 } 446 447 448 @SuppressLint("ClickableViewAccessibility") setupZoomUiControl()449 private void setupZoomUiControl() { 450 if (mWebcamController == null || mWebcamController.getCameraInfo() == null) { 451 return; 452 } 453 454 Range<Float> zoomRatioRange = mWebcamController.getCameraInfo().getZoomRatioRange(); 455 456 if (zoomRatioRange == null) { 457 return; 458 } 459 460 // Retrieves current zoom ratio setting from CameraController so that the zoom ratio set by 461 // the previous closed activity can be correctly restored 462 float currentZoomRatio = mWebcamController.getZoomRatio(); 463 464 mMotionEventToZoomRatioConverter = new MotionEventToZoomRatioConverter( 465 getApplicationContext(), zoomRatioRange, currentZoomRatio, 466 mZoomRatioListener); 467 468 GestureDetector tapToFocusGestureDetector = new GestureDetector(getApplicationContext(), 469 mTapToFocusListener); 470 471 // Restores the focus indicator if tap-to-focus points exist 472 float[] tapToFocusPoints = mWebcamController.getTapToFocusPoints(); 473 if (tapToFocusPoints != null) { 474 showFocusIndicator(tapToFocusPoints); 475 } 476 477 mTextureView.setOnTouchListener( 478 (view, event) -> { 479 mMotionEventToZoomRatioConverter.onTouchEvent(event); 480 tapToFocusGestureDetector.onTouchEvent(event); 481 return true; 482 }); 483 484 mZoomController.init(getLayoutInflater(), zoomRatioRange); 485 mZoomController.setZoomRatio(currentZoomRatio, ZoomController.ZOOM_UI_TOGGLE_MODE); 486 mZoomController.setOnZoomRatioUpdatedListener( 487 value -> { 488 if (mWebcamController != null) { 489 mWebcamController.setZoomRatio(value); 490 } 491 mMotionEventToZoomRatioConverter.setZoomRatio(value); 492 }); 493 if (mAccessibilityManager != null) { 494 mAccessibilityListener.onAccessibilityServicesStateChanged(mAccessibilityManager); 495 } 496 } 497 setupZoomRatioSeekBar()498 private void setupZoomRatioSeekBar() { 499 if (mWebcamController == null || mWebcamController.getCameraInfo() == null) { 500 return; 501 } 502 503 mZoomController.setSupportedZoomRatioRange( 504 mWebcamController.getCameraInfo().getZoomRatioRange()); 505 } 506 setupSwitchCameraSelector()507 private void setupSwitchCameraSelector() { 508 if (mWebcamController == null || mWebcamController.getCameraInfo() == null) { 509 return; 510 } 511 setToggleCameraContentDescription(); 512 mCameraPickerDialog.updateAvailableCameras( 513 createCameraListForPicker(), mWebcamController.getCameraInfo().getCameraId()); 514 515 updateHighQualityButtonState(mWebcamController.isHighQualityModeEnabled()); 516 mHighQualityToggleButton.setOnClickListener( 517 v -> { 518 // Don't do anything if we're waiting on HQ mode to be toggled. This prevents 519 // queuing up of HQ toggle events in case WebcamController gets delayed. 520 if (mIsWaitingOnHQToggle) { 521 return; 522 } 523 mIsWaitingOnHQToggle = true; 524 toggleHQWithWarningIfNeeded(); 525 }); 526 } 527 toggleHQWithWarningIfNeeded()528 private void toggleHQWithWarningIfNeeded() { 529 boolean targetHqMode = !mWebcamController.isHighQualityModeEnabled(); 530 boolean warningEnabled = mUserPrefs.fetchHighQualityWarningEnabled( 531 /*defaultValue=*/ true); 532 533 // No need to show the dialog if HQ mode is being turned off, or if the user has 534 // explicitly clicked "Don't show again" before. 535 if (!targetHqMode || !warningEnabled) { 536 setHighQualityMode(targetHqMode); 537 return; 538 } 539 540 AlertDialog alertDialog = new AlertDialog.Builder(/*context=*/ this) 541 .setCancelable(false) 542 .create(); 543 544 View customView = alertDialog.getLayoutInflater().inflate( 545 R.layout.hq_dialog_warning, /*root=*/ null); 546 alertDialog.setView(customView); 547 CheckBox dontShow = customView.findViewById(R.id.hq_warning_dont_show_again_checkbox); 548 dontShow.setOnCheckedChangeListener( 549 (buttonView, isChecked) -> mUserPrefs.storeHighQualityWarningEnabled(!isChecked)); 550 551 Button ackButton = customView.findViewById(R.id.hq_warning_ack_button); 552 ackButton.setOnClickListener(v -> { 553 setHighQualityMode(true); 554 alertDialog.dismiss(); 555 }); 556 557 alertDialog.show(); 558 } 559 setHighQualityMode(boolean enabled)560 private void setHighQualityMode(boolean enabled) { 561 Runnable callback = 562 () -> { 563 // Immediately delegate callback to UI thread to prevent blocking the thread 564 // that 565 // callback was called from. 566 runOnUiThread( 567 () -> { 568 setupSwitchCameraSelector(); 569 setupZoomUiControl(); 570 rotateUiByRotationDegrees( 571 mWebcamController.getCurrentRotation(), 572 /*animationDuration*/ 0L); 573 mIsWaitingOnHQToggle = false; 574 }); 575 }; 576 mWebcamController.setHighQualityModeEnabled(enabled, callback); 577 } 578 updateHighQualityButtonState(boolean highQualityModeEnabled)579 private void updateHighQualityButtonState(boolean highQualityModeEnabled) { 580 int img = highQualityModeEnabled ? 581 R.drawable.ic_high_quality_on : R.drawable.ic_high_quality_off; 582 mHighQualityToggleButton.setImageResource(img); 583 584 // NOTE: This is "flipped" because if High Quality mode is enabled, we want the content 585 // description to say that it will be disabled when the button is pressed. 586 int contentDesc = highQualityModeEnabled ? 587 R.string.toggle_high_quality_description_off : 588 R.string.toggle_high_quality_description_on; 589 mHighQualityToggleButton.setContentDescription(getText(contentDesc)); 590 } 591 rotateUiByRotationDegrees(int rotation)592 private void rotateUiByRotationDegrees(int rotation) { 593 rotateUiByRotationDegrees(rotation, /*animate*/ ROTATION_ANIMATION_DURATION_MS); 594 } 595 rotateUiByRotationDegrees(int rotation, long animationDuration)596 private void rotateUiByRotationDegrees(int rotation, long animationDuration) { 597 if (mWebcamController == null) { 598 // Don't do anything if webcam controller is not connected 599 return; 600 } 601 int finalRotation = calculateUiRotation(rotation); 602 runOnUiThread(() -> { 603 ObjectAnimator anim = ObjectAnimator.ofFloat(mToggleCameraButton, 604 /*propertyName=*/"rotation", finalRotation) 605 .setDuration(animationDuration); 606 anim.setInterpolator(new AccelerateDecelerateInterpolator()); 607 anim.start(); 608 mToggleCameraButton.performHapticFeedback( 609 HapticFeedbackConstants.GESTURE_THRESHOLD_ACTIVATE); 610 611 mZoomController.setTextDisplayRotation(finalRotation, (int) animationDuration); 612 mHighQualityToggleButton.animate() 613 .rotation(finalRotation).setDuration(animationDuration); 614 }); 615 } 616 calculateUiRotation(int rotation)617 private int calculateUiRotation(int rotation) { 618 // Rotates the UI control container according to the device sensor rotation degrees and the 619 // camera sensor orientation. 620 621 int sensorOrientation = mWebcamController.getCameraInfo().getSensorOrientation(); 622 if (mWebcamController.getCameraInfo().getLensFacing() 623 == CameraCharacteristics.LENS_FACING_BACK) { 624 rotation = (rotation + sensorOrientation) % 360; 625 } else { 626 rotation = (360 + rotation - sensorOrientation) % 360; 627 } 628 629 // Rotation angle of the view must be [-179, 180] to ensure we always rotate the 630 // view through the natural orientation (0) 631 return rotation <= 180 ? rotation : rotation - 360; 632 } 633 setupTextureViewLayout()634 private void setupTextureViewLayout() { 635 mPreviewSize = mWebcamController.getSuitablePreviewSize(); 636 if (mPreviewSize != null) { 637 setTextureViewScale(); 638 setupZoomUiControl(); 639 } 640 } 641 onWebcamDestroyed()642 private void onWebcamDestroyed() { 643 ConditionVariable cv = new ConditionVariable(); 644 cv.close(); 645 runOnUiThread( 646 () -> { 647 try { 648 mWebcamController = null; 649 finish(); 650 } finally { 651 cv.open(); 652 } 653 }); 654 cv.block(); 655 } 656 657 @Override onCreate(Bundle savedInstanceState)658 public void onCreate(Bundle savedInstanceState) { 659 super.onCreate(savedInstanceState); 660 setContentView(R.layout.preview_layout); 661 mRootView = findViewById(R.id.container_view); 662 mTextureViewContainer = findViewById(R.id.texture_view_container); 663 mTextureViewCard = findViewById(R.id.texture_view_card); 664 mTextureView = findViewById(R.id.texture_view); 665 mFocusIndicator = findViewById(R.id.focus_indicator); 666 mFocusIndicator.setBackground(createFocusIndicatorDrawable()); 667 mToggleCameraButton = findViewById(R.id.toggle_camera_button); 668 mZoomController = findViewById(R.id.zoom_ui_controller); 669 mHighQualityToggleButton = findViewById(R.id.high_quality_button); 670 671 // Use "seamless" animation for rotations as we fix the UI relative to the device. 672 // "seamless" will make the transition invisible to the users. 673 WindowManager.LayoutParams windowAttrs = getWindow().getAttributes(); 674 windowAttrs.rotationAnimation = WindowManager.LayoutParams.ROTATION_ANIMATION_SEAMLESS; 675 getWindow().setAttributes(windowAttrs); 676 677 mAccessibilityManager = getSystemService(AccessibilityManager.class); 678 if (mAccessibilityManager != null) { 679 mAccessibilityManager.addAccessibilityServicesStateChangeListener( 680 mAccessibilityListener); 681 } 682 683 mUserPrefs = new UserPrefs(this.getApplicationContext()); 684 mCameraPickerDialog = new CameraPickerDialog(this::switchCamera); 685 686 setupMainLayout(); 687 688 // Needed because onConfigChanged is not called when device rotates from landscape to 689 // reverse-landscape or from portrait to reverse-portrait. 690 mRootView.setOnApplyWindowInsetsListener((view, inset) -> { 691 runOnUiThread(this::setupMainLayout); 692 return WindowInsets.CONSUMED; 693 }); 694 695 bindService( 696 new Intent(this, DeviceAsWebcamFgServiceImpl.class), 697 0, 698 mThreadExecutor, 699 mConnection); 700 } 701 createFocusIndicatorDrawable()702 private Drawable createFocusIndicatorDrawable() { 703 int indicatorSize = getResources().getDimensionPixelSize(R.dimen.focus_indicator_size); 704 Bitmap bitmap = Bitmap.createBitmap(indicatorSize, indicatorSize, Bitmap.Config.ARGB_8888); 705 Canvas canvas = new Canvas(bitmap); 706 707 OvalShape ovalShape = new OvalShape(); 708 ShapeDrawable shapeDrawable = new ShapeDrawable(ovalShape); 709 Paint paint = shapeDrawable.getPaint(); 710 paint.setAntiAlias(true); 711 paint.setStyle(Paint.Style.STROKE); 712 713 int strokeWidth = getResources().getDimensionPixelSize( 714 R.dimen.focus_indicator_stroke_width); 715 paint.setStrokeWidth(strokeWidth); 716 paint.setColor(getResources().getColor(android.R.color.white, null)); 717 int halfIndicatorSize = indicatorSize / 2; 718 canvas.drawCircle(halfIndicatorSize, halfIndicatorSize, halfIndicatorSize - strokeWidth, 719 paint); 720 paint.setStyle(Paint.Style.FILL); 721 paint.setColor(getResources().getColor(R.color.focus_indicator_background_color, null)); 722 canvas.drawCircle(halfIndicatorSize, halfIndicatorSize, halfIndicatorSize - strokeWidth, 723 paint); 724 725 return new BitmapDrawable(getResources(), bitmap); 726 } 727 hideSystemUiAndActionBar()728 private void hideSystemUiAndActionBar() { 729 // Hides status bar 730 Window window = getWindow(); 731 window.setStatusBarColor(android.R.color.system_neutral1_800); 732 window.setDecorFitsSystemWindows(false); 733 WindowInsetsController controller = window.getInsetsController(); 734 if (controller != null) { 735 controller.hide(WindowInsets.Type.systemBars()); 736 controller.setSystemBarsBehavior( 737 WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); 738 } 739 } 740 741 @Override onResume()742 public void onResume() { 743 super.onResume(); 744 hideSystemUiAndActionBar(); 745 // When the screen is turned off and turned back on, the SurfaceTexture is already 746 // available, and "onSurfaceTextureAvailable" will not be called. In that case, we can open 747 // a camera and start preview from here (otherwise, we wait until the surface is ready in 748 // the SurfaceTextureListener). 749 if (mTextureView.isAvailable()) { 750 mWebcamControllerReady.block(); 751 if (!mTextureViewSetup) { 752 setupTextureViewLayout(); 753 mTextureViewSetup = true; 754 } 755 if (mWebcamController != null && mPreviewSize != null) { 756 mWebcamController.setPreviewSurfaceTexture( 757 mTextureView.getSurfaceTexture(), mPreviewSize, mPreviewSizeChangeListener); 758 rotateUiByRotationDegrees(mWebcamController.getCurrentRotation()); 759 mWebcamController.setRotationUpdateListener( 760 rotation -> runOnUiThread(() -> rotateUiByRotationDegrees(rotation))); 761 mZoomController.setZoomRatio( 762 mWebcamController.getZoomRatio(), ZoomController.ZOOM_UI_TOGGLE_MODE); 763 } 764 } else { 765 mTextureView.setSurfaceTextureListener(mSurfaceTextureListener); 766 } 767 } 768 769 @Override onPause()770 public void onPause() { 771 if (mWebcamController != null) { 772 mWebcamController.removePreviewSurfaceTexture(); 773 mWebcamController.setRotationUpdateListener(null); 774 } 775 super.onPause(); 776 } 777 778 @Override onKeyDown(int keyCode, KeyEvent event)779 public boolean onKeyDown(int keyCode, KeyEvent event) { 780 if (mWebcamController == null 781 || (keyCode != KeyEvent.KEYCODE_VOLUME_DOWN 782 && keyCode != KeyEvent.KEYCODE_VOLUME_UP)) { 783 return super.onKeyDown(keyCode, event); 784 } 785 786 float zoomRatio = mWebcamController.getZoomRatio(); 787 788 // Uses volume key events to adjust zoom ratio 789 if ((keyCode == KeyEvent.KEYCODE_VOLUME_DOWN)){ 790 zoomRatio -= 0.1f; 791 } else { 792 zoomRatio += 0.1f; 793 } 794 795 // Clamps the zoom ratio in the supported range 796 Range<Float> zoomRatioRange = mWebcamController.getCameraInfo().getZoomRatioRange(); 797 zoomRatio = 798 Math.min(Math.max(zoomRatio, zoomRatioRange.getLower()), zoomRatioRange.getUpper()); 799 800 // Updates the new value to all related controls 801 mWebcamController.setZoomRatio(zoomRatio); 802 mZoomController.setZoomRatio(zoomRatio, ZoomController.ZOOM_UI_SEEK_BAR_MODE); 803 mMotionEventToZoomRatioConverter.setZoomRatio(zoomRatio); 804 805 return true; 806 } 807 808 @Override onDestroy()809 public void onDestroy() { 810 if (mAccessibilityManager != null) { 811 mAccessibilityManager.removeAccessibilityServicesStateChangeListener( 812 mAccessibilityListener); 813 } 814 if (mWebcamController != null) { 815 mWebcamController.setOnDestroyedCallback(null); 816 } 817 unbindService(mConnection); 818 super.onDestroy(); 819 } 820 821 /** 822 * Returns {@code true} when the device has both available back and front cameras. Otherwise, 823 * returns {@code false}. 824 */ canToggleCamera()825 private boolean canToggleCamera() { 826 if (mWebcamController == null) { 827 return false; 828 } 829 830 List<CameraId> availableCameraIds = mWebcamController.getAvailableCameraIds(); 831 boolean hasBackCamera = false; 832 boolean hasFrontCamera = false; 833 834 for (CameraId cameraId : availableCameraIds) { 835 CameraInfo cameraInfo = mWebcamController.getOrCreateCameraInfo(cameraId); 836 if (cameraInfo.getLensFacing() == CameraCharacteristics.LENS_FACING_BACK) { 837 hasBackCamera = true; 838 } else if (cameraInfo.getLensFacing() == CameraCharacteristics.LENS_FACING_FRONT) { 839 hasFrontCamera = true; 840 } 841 } 842 843 return hasBackCamera && hasFrontCamera; 844 } 845 setToggleCameraContentDescription()846 private void setToggleCameraContentDescription() { 847 if (mWebcamController == null) { 848 return; 849 } 850 int lensFacing = mWebcamController.getCameraInfo().getLensFacing(); 851 CharSequence descr = getText(R.string.toggle_camera_button_description_front); 852 if (lensFacing == CameraMetadata.LENS_FACING_FRONT) { 853 descr = getText(R.string.toggle_camera_button_description_back); 854 } 855 mToggleCameraButton.setContentDescription(descr); 856 } 857 toggleCamera()858 private void toggleCamera() { 859 if (mWebcamController == null) { 860 return; 861 } 862 863 mWebcamController.toggleCamera(); 864 setToggleCameraContentDescription(); 865 mFocusIndicator.setVisibility(View.GONE); 866 mMotionEventToZoomRatioConverter.reset( 867 mWebcamController.getZoomRatio(), 868 mWebcamController.getCameraInfo().getZoomRatioRange()); 869 setupZoomRatioSeekBar(); 870 mZoomController.setZoomRatio( 871 mWebcamController.getZoomRatio(), ZoomController.ZOOM_UI_TOGGLE_MODE); 872 mCameraPickerDialog.updateSelectedCamera(mWebcamController.getCameraInfo().getCameraId()); 873 } 874 switchCamera(CameraId cameraId)875 private void switchCamera(CameraId cameraId) { 876 if (mWebcamController == null) { 877 return; 878 } 879 880 mWebcamController.switchCamera(cameraId); 881 setToggleCameraContentDescription(); 882 mMotionEventToZoomRatioConverter.reset( 883 mWebcamController.getZoomRatio(), 884 mWebcamController.getCameraInfo().getZoomRatioRange()); 885 setupZoomRatioSeekBar(); 886 mZoomController.setZoomRatio( 887 mWebcamController.getZoomRatio(), ZoomController.ZOOM_UI_TOGGLE_MODE); 888 // CameraPickerDialog does not update its UI until the preview activity 889 // notifies it of the change. So notify CameraPickerDialog about the camera change. 890 mCameraPickerDialog.updateSelectedCamera(cameraId); 891 } 892 tapToFocus(MotionEvent motionEvent)893 private boolean tapToFocus(MotionEvent motionEvent) { 894 if (mWebcamController == null || mWebcamController.getCameraInfo() == null) { 895 return false; 896 } 897 898 float[] normalizedPoint = calculateNormalizedPoint(motionEvent); 899 900 if (isTapToResetAutoFocus(normalizedPoint)) { 901 mFocusIndicator.setVisibility(View.GONE); 902 mWebcamController.resetToAutoFocus(); 903 } else { 904 showFocusIndicator(normalizedPoint); 905 mWebcamController.tapToFocus(normalizedPoint); 906 } 907 908 return true; 909 } 910 911 /** Returns whether the new points overlap with the original tap-to-focus points or not. */ isTapToResetAutoFocus(float[] newNormalizedPoints)912 private boolean isTapToResetAutoFocus(float[] newNormalizedPoints) { 913 float[] oldNormalizedPoints = mWebcamController.getTapToFocusPoints(); 914 915 if (oldNormalizedPoints == null) { 916 return false; 917 } 918 919 // Calculates the distance between the new and old points 920 float distanceX = Math.abs(newNormalizedPoints[1] - oldNormalizedPoints[1]) 921 * mTextureViewCard.getWidth(); 922 float distanceY = Math.abs(newNormalizedPoints[0] - oldNormalizedPoints[0]) 923 * mTextureViewCard.getHeight(); 924 double distance = Math.sqrt(distanceX*distanceX + distanceY*distanceY); 925 926 int indicatorRadius = getResources().getDimensionPixelSize(R.dimen.focus_indicator_size) 927 / 2; 928 929 // Checks whether the distance is less than the circle radius of focus indicator 930 return indicatorRadius >= distance; 931 } 932 933 /** 934 * Calculates the normalized point which will be the point between [0, 0] to [1, 1] mapping to 935 * the preview size. 936 */ calculateNormalizedPoint(MotionEvent motionEvent)937 private float[] calculateNormalizedPoint(MotionEvent motionEvent) { 938 return new float[]{motionEvent.getX() / mPreviewSize.getWidth(), 939 motionEvent.getY() / mPreviewSize.getHeight()}; 940 } 941 942 /** 943 * Show the focus indicator and hide it automatically after a proper duration. 944 */ showFocusIndicator(float[] normalizedPoint)945 private void showFocusIndicator(float[] normalizedPoint) { 946 int indicatorSize = getResources().getDimensionPixelSize(R.dimen.focus_indicator_size); 947 float translationX = 948 normalizedPoint[0] * mTextureViewCard.getWidth() - indicatorSize / 2f; 949 float translationY = normalizedPoint[1] * mTextureViewCard.getHeight() 950 - indicatorSize / 2f; 951 mFocusIndicator.setTranslationX(translationX); 952 mFocusIndicator.setTranslationY(translationY); 953 mFocusIndicator.setVisibility(View.VISIBLE); 954 } 955 createCameraListForPicker()956 private List<CameraPickerDialog.ListItem> createCameraListForPicker() { 957 List<CameraId> availableCameraIds = mWebcamController.getAvailableCameraIds(); 958 if (availableCameraIds == null) { 959 Log.w(TAG, "No cameras listed for picker. Why is Webcam Preview running?"); 960 return List.of(); 961 } 962 963 return availableCameraIds.stream() 964 .map(mWebcamController::getOrCreateCameraInfo) 965 .filter(Objects::nonNull) 966 .map(CameraPickerDialog.ListItem::new) 967 .toList(); 968 } 969 } 970