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