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