• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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