/*
 * Copyright 2023 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *       http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.deviceaswebcam;

import static android.accessibilityservice.AccessibilityServiceInfo.FEEDBACK_ALL_MASK;

import android.accessibilityservice.AccessibilityServiceInfo;
import android.animation.ObjectAnimator;
import android.annotation.SuppressLint;
import android.app.AlertDialog;
import android.content.ComponentName;
import android.content.Intent;
import android.content.ServiceConnection;
import android.content.res.Configuration;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.SurfaceTexture;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.ShapeDrawable;
import android.graphics.drawable.shapes.OvalShape;
import android.hardware.camera2.CameraCharacteristics;
import android.hardware.camera2.CameraMetadata;
import android.os.Bundle;
import android.os.ConditionVariable;
import android.os.IBinder;
import android.util.Log;
import android.util.Range;
import android.util.Size;
import android.view.DisplayCutout;
import android.view.GestureDetector;
import android.view.Gravity;
import android.view.HapticFeedbackConstants;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.Surface;
import android.view.TextureView;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.view.WindowInsets;
import android.view.WindowInsetsController;
import android.view.WindowManager;
import android.view.accessibility.AccessibilityManager;
import android.view.animation.AccelerateDecelerateInterpolator;
import android.widget.Button;
import android.widget.CheckBox;
import android.widget.FrameLayout;
import android.widget.ImageButton;

import androidx.annotation.NonNull;
import androidx.cardview.widget.CardView;
import androidx.fragment.app.FragmentActivity;
import androidx.window.layout.WindowMetrics;
import androidx.window.layout.WindowMetricsCalculator;

import com.android.DeviceAsWebcam.R;
import com.android.deviceaswebcam.utils.UserPrefs;
import com.android.deviceaswebcam.view.CameraPickerDialog;
import com.android.deviceaswebcam.view.ZoomController;

import java.util.List;
import java.util.Objects;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.function.Consumer;

public class DeviceAsWebcamPreview extends FragmentActivity {
    private static final String TAG = DeviceAsWebcamPreview.class.getSimpleName();
    private static final boolean VERBOSE = Log.isLoggable(TAG, Log.VERBOSE);
    private static final int ROTATION_ANIMATION_DURATION_MS = 300;

    private final Executor mThreadExecutor = Executors.newFixedThreadPool(2);
    private final ConditionVariable mWebcamControllerReady = new ConditionVariable();

    private boolean mTextureViewSetup = false;
    private Size mPreviewSize;
    private WebcamControllerImpl mWebcamController;
    private AccessibilityManager mAccessibilityManager;
    private int mCurrRotation = Surface.ROTATION_0;
    private Size mCurrDisplaySize = new Size(0, 0);

    private View mRootView;
    private FrameLayout mTextureViewContainer;
    private CardView mTextureViewCard;
    private TextureView mTextureView;
    private View mFocusIndicator;
    private ZoomController mZoomController = null;
    private ImageButton mToggleCameraButton;
    private ImageButton mHighQualityToggleButton;
    private CameraPickerDialog mCameraPickerDialog;

    private UserPrefs mUserPrefs;

    // A listener to monitor the preview size change events. This might be invoked when toggling
    // camera or the webcam stream is started after the preview stream.
    Consumer<Size> mPreviewSizeChangeListener = size -> runOnUiThread(() -> {
                mPreviewSize = size;
                setTextureViewScale();
            }
    );

    // Listener for when Accessibility service are enabled or disabled.
    AccessibilityManager.AccessibilityServicesStateChangeListener mAccessibilityListener =
            accessibilityManager -> {
                List<AccessibilityServiceInfo> services =
                        accessibilityManager.getEnabledAccessibilityServiceList(FEEDBACK_ALL_MASK);
                boolean areServicesEnabled = !services.isEmpty();
                runOnUiThread(() ->
                        mZoomController.onAccessibilityServicesEnabled(areServicesEnabled));
            };


    /**
     * {@link View.OnLayoutChangeListener} to add to
     * {@link DeviceAsWebcamPreview#mTextureViewContainer} for when we need to know
     * when changes to the view are committed.
     * <p>
     * NOTE: This removes itself as a listener after one call to prevent spurious callbacks
     *       once the texture view has been resized.
     */
    View.OnLayoutChangeListener mTextureViewContainerLayoutListener =
            new View.OnLayoutChangeListener() {
                @Override
                public void onLayoutChange(View view, int left, int top, int right, int bottom,
                                           int oldLeft, int oldTop, int oldRight, int oldBottom) {
                    // Remove self to prevent further calls to onLayoutChange.
                    view.removeOnLayoutChangeListener(this);
                    // Update the texture view to fit the new bounds.
                    runOnUiThread(() -> {
                        if (mPreviewSize != null) {
                            setTextureViewScale();
                        }
                    });
                }
            };

    /**
     * {@link TextureView.SurfaceTextureListener} handles several lifecycle events on a {@link
     * TextureView}.
     */
    private final TextureView.SurfaceTextureListener mSurfaceTextureListener =
            new TextureView.SurfaceTextureListener() {
                @Override
                public void onSurfaceTextureAvailable(
                        SurfaceTexture texture, int width, int height) {
                    runOnUiThread(
                            () -> {
                                if (VERBOSE) {
                                    Log.v(
                                            TAG,
                                            "onSurfaceTextureAvailable " + width + " x " + height);
                                }
                                mWebcamControllerReady.block();

                                if (!mTextureViewSetup) {
                                    setupTextureViewLayout();
                                }

                                if (mWebcamController == null) {
                                    return;
                                }
                                mWebcamController.setOnDestroyedCallback(() -> onWebcamDestroyed());

                                if (mPreviewSize == null) {
                                    return;
                                }
                                mWebcamController.setPreviewSurfaceTexture(
                                        texture, mPreviewSize, mPreviewSizeChangeListener);
                                List<CameraId> availableCameraIds =
                                        mWebcamController.getAvailableCameraIds();
                                if (availableCameraIds != null && availableCameraIds.size() > 1) {
                                    setupSwitchCameraSelector();
                                    mToggleCameraButton.setVisibility(View.VISIBLE);
                                    if (canToggleCamera()) {
                                        mToggleCameraButton.setOnClickListener(v -> toggleCamera());
                                    } else {
                                        mToggleCameraButton.setOnClickListener(
                                                v ->
                                                        mCameraPickerDialog.show(
                                                                getSupportFragmentManager(),
                                                                "CameraPickerDialog"));
                                    }
                                    mToggleCameraButton.setOnLongClickListener(
                                            v -> {
                                                mCameraPickerDialog.show(
                                                        getSupportFragmentManager(),
                                                        "CameraPickerDialog");
                                                return true;
                                            });
                                } else {
                                    mToggleCameraButton.setVisibility(View.GONE);
                                }
                                rotateUiByRotationDegrees(mWebcamController.getCurrentRotation());
                                mWebcamController.setRotationUpdateListener(
                                        rotation -> {
                                            rotateUiByRotationDegrees(rotation);
                                        });
                            });
                }

                @Override
                public void onSurfaceTextureSizeChanged(
                        SurfaceTexture texture, int width, int height) {
                    if (VERBOSE) {
                        Log.v(TAG, "onSurfaceTextureSizeChanged " + width + " x " + height);
                    }
                }

                @Override
                public boolean onSurfaceTextureDestroyed(SurfaceTexture texture) {
                    runOnUiThread(
                            () -> {
                                if (mWebcamController != null) {
                                    mWebcamController.removePreviewSurfaceTexture();
                                }
                            });
                    return true;
                }

                @Override
                public void onSurfaceTextureUpdated(SurfaceTexture texture) {}
            };

    private ServiceConnection mConnection =
            new ServiceConnection() {
                @Override
                public void onServiceConnected(ComponentName className, IBinder serviceBinder) {
                    DeviceAsWebcamFgServiceImpl service =
                            ((DeviceAsWebcamFgServiceImpl.LocalBinder) serviceBinder).getService();
                    if (VERBOSE) {
                        Log.v(TAG, "Got Fg service");
                    }
                    if (service != null) {
                        mWebcamController = service.getWebcamControllerImpl();
                    }
                    mWebcamControllerReady.open();
                }

                @Override
                public void onServiceDisconnected(ComponentName className) {
                    // Serialize updating mWebcamController on UI Thread as all consumers of
                    // mWebcamController
                    // run on the UI Thread.
                    runOnUiThread(
                            () -> {
                                mWebcamController = null;
                                finish();
                            });
                }
            };

    private MotionEventToZoomRatioConverter mMotionEventToZoomRatioConverter = null;
    private final MotionEventToZoomRatioConverter.ZoomRatioUpdatedListener mZoomRatioListener =
            new MotionEventToZoomRatioConverter.ZoomRatioUpdatedListener() {
                @Override
                public void onZoomRatioUpdated(float updatedZoomRatio) {
                    if (mWebcamController == null) {
                        return;
                    }

                    mWebcamController.setZoomRatio(updatedZoomRatio);
                    mZoomController.setZoomRatio(
                            updatedZoomRatio, ZoomController.ZOOM_UI_SEEK_BAR_MODE);
                }
            };

    private GestureDetector.SimpleOnGestureListener mTapToFocusListener =
            new GestureDetector.SimpleOnGestureListener() {
                @Override
                public boolean onSingleTapUp(MotionEvent motionEvent) {
                    return tapToFocus(motionEvent);
                }
            };

    private void setTextureViewScale() {
        FrameLayout.LayoutParams frameLayout = new FrameLayout.LayoutParams(mPreviewSize.getWidth(),
                mPreviewSize.getHeight(), Gravity.CENTER);
        mTextureView.setLayoutParams(frameLayout);

        int pWidth = mTextureViewContainer.getWidth();
        int pHeight = mTextureViewContainer.getHeight();
        float scaleYToUnstretched = (float) mPreviewSize.getWidth() / mPreviewSize.getHeight();
        float scaleXToUnstretched = (float) mPreviewSize.getHeight() / mPreviewSize.getWidth();
        float additionalScaleForX = (float) pWidth / mPreviewSize.getHeight();
        float additionalScaleForY = (float) pHeight / mPreviewSize.getWidth();

        // To fit the preview, either letterbox or pillar box.
        float additionalScaleChosen = Math.min(additionalScaleForX, additionalScaleForY);

        float texScaleX = scaleXToUnstretched * additionalScaleChosen;
        float texScaleY = scaleYToUnstretched * additionalScaleChosen;

        mTextureView.setScaleX(texScaleX);
        mTextureView.setScaleY(texScaleY);

        // Resize the card view to match TextureView's final size exactly. This is to clip
        // textureView corners.
        ViewGroup.LayoutParams cardLayoutParams = mTextureViewCard.getLayoutParams();
        // Reduce size by two pixels to remove any rounding errors from casting to int.
        cardLayoutParams.height = ((int) (mPreviewSize.getHeight() * texScaleY)) - 2;
        cardLayoutParams.width = ((int) (mPreviewSize.getWidth() * texScaleX)) - 2;
        mTextureViewCard.setLayoutParams(cardLayoutParams);
    }

    @Override
    public void onConfigurationChanged(@NonNull Configuration newConfig) {
        super.onConfigurationChanged(newConfig);
        runOnUiThread(this::setupMainLayout);
    }

    private void setupMainLayout() {
        int currRotation = getDisplay().getRotation();
        WindowMetrics windowMetrics =
                WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(this);
        Size displaySize;
        int width = windowMetrics.getBounds().width();
        int height = windowMetrics.getBounds().height();
        if (currRotation == Surface.ROTATION_90 || currRotation == Surface.ROTATION_270) {
            // flip height and width because we want the height and width if the display
            // in its natural orientation
            displaySize = new Size(/*width=*/ height, /*height=*/ width);
        } else {
            displaySize = new Size(width, height);
        }

        if (mCurrRotation == currRotation && mCurrDisplaySize.equals(displaySize)) {
            // Exit early if we have already drawn the UI for this state.
            return;
        }

        mCurrDisplaySize = displaySize;
        mCurrRotation = currRotation;

        DisplayCutout displayCutout =
                getWindowManager().getCurrentWindowMetrics().getWindowInsets().getDisplayCutout();
        if (displayCutout == null) {
            displayCutout = DisplayCutout.NO_CUTOUT;
        }

        // We set up the UI to always be fixed to the device's natural orientation.
        // If the device is rotated, we counter-rotate the UI to ensure that
        // our UI has a "locked" orientation.

        // resize the root view to match the display. Full screen preview covers the entire
        // screen
        ViewGroup.LayoutParams rootParams = mRootView.getLayoutParams();
        rootParams.width = mCurrDisplaySize.getWidth();
        rootParams.height = mCurrDisplaySize.getHeight();
        mRootView.setLayoutParams(rootParams);

        // Counter rotate the main view and update padding values so we don't draw under
        // cutouts. The cutout values we get are relative to the user.
        int minTopPadding = (int) getResources().getDimension(R.dimen.root_view_padding_top_min);
        switch (mCurrRotation) {
            case Surface.ROTATION_90:
                mRootView.setRotation(-90);
                mRootView.setPadding(
                        /*left=*/ displayCutout.getSafeInsetBottom(),
                        /*top=*/ Math.max(minTopPadding, displayCutout.getSafeInsetLeft()),
                        /*right=*/ displayCutout.getSafeInsetTop(),
                        /*bottom=*/ displayCutout.getSafeInsetRight());
                break;
            case Surface.ROTATION_270:
                mRootView.setRotation(90);
                mRootView.setPadding(
                        /*left=*/ displayCutout.getSafeInsetTop(),
                        /*top=*/ Math.max(minTopPadding, displayCutout.getSafeInsetRight()),
                        /*right=*/ displayCutout.getSafeInsetBottom(),
                        /*bottom=*/ displayCutout.getSafeInsetLeft());
                break;
            case Surface.ROTATION_0:
                mRootView.setRotation(0);
                mRootView.setPadding(
                        /*left=*/ displayCutout.getSafeInsetLeft(),
                        /*top=*/ Math.max(minTopPadding, displayCutout.getSafeInsetTop()),
                        /*right=*/ displayCutout.getSafeInsetRight(),
                        /*bottom=*/ displayCutout.getSafeInsetBottom());
                break;
            case Surface.ROTATION_180:
                mRootView.setRotation(180);
                mRootView.setPadding(
                        /*left=*/displayCutout.getSafeInsetRight(),
                        /*top=*/Math.max(minTopPadding, displayCutout.getSafeInsetBottom()),
                        /*right=*/displayCutout.getSafeInsetLeft(),
                        /*bottom=*/displayCutout.getSafeInsetTop());
                break;
        }
        // subscribe to layout changes of the texture view container so we can
        // resize the texture view once the container has been drawn with the new
        // margins
        mTextureViewContainer.addOnLayoutChangeListener(mTextureViewContainerLayoutListener);
    }


    @SuppressLint("ClickableViewAccessibility")
    private void setupZoomUiControl() {
        if (mWebcamController == null || mWebcamController.getCameraInfo() == null) {
            return;
        }

        Range<Float> zoomRatioRange = mWebcamController.getCameraInfo().getZoomRatioRange();

        if (zoomRatioRange == null) {
            return;
        }

        // Retrieves current zoom ratio setting from CameraController so that the zoom ratio set by
        // the previous closed activity can be correctly restored
        float currentZoomRatio = mWebcamController.getZoomRatio();

        mMotionEventToZoomRatioConverter = new MotionEventToZoomRatioConverter(
                getApplicationContext(), zoomRatioRange, currentZoomRatio,
                mZoomRatioListener);

        GestureDetector tapToFocusGestureDetector = new GestureDetector(getApplicationContext(),
                mTapToFocusListener);

        // Restores the focus indicator if tap-to-focus points exist
        float[] tapToFocusPoints = mWebcamController.getTapToFocusPoints();
        if (tapToFocusPoints != null) {
            showFocusIndicator(tapToFocusPoints);
        }

        mTextureView.setOnTouchListener(
                (view, event) -> {
                    mMotionEventToZoomRatioConverter.onTouchEvent(event);
                    tapToFocusGestureDetector.onTouchEvent(event);
                    return true;
                });

        mZoomController.init(getLayoutInflater(), zoomRatioRange);
        mZoomController.setZoomRatio(currentZoomRatio, ZoomController.ZOOM_UI_TOGGLE_MODE);
        mZoomController.setOnZoomRatioUpdatedListener(
                value -> {
                    if (mWebcamController != null) {
                        mWebcamController.setZoomRatio(value);
                    }
                    mMotionEventToZoomRatioConverter.setZoomRatio(value);
                });
        if (mAccessibilityManager != null) {
            mAccessibilityListener.onAccessibilityServicesStateChanged(mAccessibilityManager);
        }
    }

    private void setupZoomRatioSeekBar() {
        if (mWebcamController == null || mWebcamController.getCameraInfo() == null) {
            return;
        }

        mZoomController.setSupportedZoomRatioRange(
                mWebcamController.getCameraInfo().getZoomRatioRange());
    }

    private void setupSwitchCameraSelector() {
        if (mWebcamController == null || mWebcamController.getCameraInfo() == null) {
            return;
        }
        setToggleCameraContentDescription();
        mCameraPickerDialog.updateAvailableCameras(
                createCameraListForPicker(), mWebcamController.getCameraInfo().getCameraId());

        updateHighQualityButtonState(mWebcamController.isHighQualityModeEnabled());
        mHighQualityToggleButton.setOnClickListener(v -> {
            // Disable the toggle button to prevent spamming
            mHighQualityToggleButton.setEnabled(false);
            toggleHQWithWarningIfNeeded();
        });
    }

    private void toggleHQWithWarningIfNeeded() {
        boolean targetHqMode = !mWebcamController.isHighQualityModeEnabled();
        boolean warningEnabled = mUserPrefs.fetchHighQualityWarningEnabled(
                /*defaultValue=*/ true);

        // No need to show the dialog if HQ mode is being turned off, or if the user has
        // explicitly clicked "Don't show again" before.
        if (!targetHqMode || !warningEnabled) {
            setHighQualityMode(targetHqMode);
            return;
        }

        AlertDialog alertDialog = new AlertDialog.Builder(/*context=*/ this)
                .setCancelable(false)
                .create();

        View customView = alertDialog.getLayoutInflater().inflate(
                R.layout.hq_dialog_warning, /*root=*/ null);
        alertDialog.setView(customView);
        CheckBox dontShow = customView.findViewById(R.id.hq_warning_dont_show_again_checkbox);
        dontShow.setOnCheckedChangeListener(
                (buttonView, isChecked) -> mUserPrefs.storeHighQualityWarningEnabled(!isChecked));

        Button ackButton = customView.findViewById(R.id.hq_warning_ack_button);
        ackButton.setOnClickListener(v -> {
            setHighQualityMode(true);
            alertDialog.dismiss();
        });

        alertDialog.show();
    }

    private void setHighQualityMode(boolean enabled) {
        Runnable callback =
                () -> {
                    // Immediately delegate callback to UI thread to prevent blocking the thread
                    // that
                    // callback was called from.
                    runOnUiThread(
                            () -> {
                                setupSwitchCameraSelector();
                                setupZoomUiControl();
                                rotateUiByRotationDegrees(
                                        mWebcamController.getCurrentRotation(),
                                        /*animationDuration*/ 0L);
                                mHighQualityToggleButton.setEnabled(true);
                            });
                };
        mWebcamController.setHighQualityModeEnabled(enabled, callback);
    }

    private void updateHighQualityButtonState(boolean highQualityModeEnabled) {
        int img = highQualityModeEnabled ?
                R.drawable.ic_high_quality_on : R.drawable.ic_high_quality_off;
        mHighQualityToggleButton.setImageResource(img);

        // NOTE: This is "flipped" because if High Quality mode is enabled, we want the content
        // description to say that it will be disabled when the button is pressed.
        int contentDesc = highQualityModeEnabled ?
                R.string.toggle_high_quality_description_off :
                R.string.toggle_high_quality_description_on;
        mHighQualityToggleButton.setContentDescription(getText(contentDesc));
    }

    private void rotateUiByRotationDegrees(int rotation) {
        rotateUiByRotationDegrees(rotation, /*animate*/ ROTATION_ANIMATION_DURATION_MS);
    }

    private void rotateUiByRotationDegrees(int rotation, long animationDuration) {
        if (mWebcamController == null) {
            // Don't do anything if webcam controller is not connected
            return;
        }
        int finalRotation = calculateUiRotation(rotation);
        runOnUiThread(() -> {
            ObjectAnimator anim = ObjectAnimator.ofFloat(mToggleCameraButton,
                            /*propertyName=*/"rotation", finalRotation)
                    .setDuration(animationDuration);
            anim.setInterpolator(new AccelerateDecelerateInterpolator());
            anim.start();
            mToggleCameraButton.performHapticFeedback(
                    HapticFeedbackConstants.GESTURE_THRESHOLD_ACTIVATE);

            mZoomController.setTextDisplayRotation(finalRotation, (int) animationDuration);
            mHighQualityToggleButton.animate()
                    .rotation(finalRotation).setDuration(animationDuration);
        });
    }

    private int calculateUiRotation(int rotation) {
        // Rotates the UI control container according to the device sensor rotation degrees and the
        // camera sensor orientation.

        int sensorOrientation = mWebcamController.getCameraInfo().getSensorOrientation();
        if (mWebcamController.getCameraInfo().getLensFacing()
                == CameraCharacteristics.LENS_FACING_BACK) {
            rotation = (rotation + sensorOrientation) % 360;
        } else {
            rotation = (360 + rotation - sensorOrientation) % 360;
        }

        // Rotation angle of the view must be [-179, 180] to ensure we always rotate the
        // view through the natural orientation (0)
        return rotation <= 180 ? rotation : rotation - 360;
    }

    private void setupTextureViewLayout() {
        mPreviewSize = mWebcamController.getSuitablePreviewSize();
        if (mPreviewSize != null) {
            setTextureViewScale();
            setupZoomUiControl();
        }
    }

    private void onWebcamDestroyed() {
        ConditionVariable cv = new ConditionVariable();
        cv.close();
        runOnUiThread(
                () -> {
                    try {
                        mWebcamController = null;
                        finish();
                    } finally {
                        cv.open();
                    }
                });
        cv.block();
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.preview_layout);
        mRootView = findViewById(R.id.container_view);
        mTextureViewContainer = findViewById(R.id.texture_view_container);
        mTextureViewCard = findViewById(R.id.texture_view_card);
        mTextureView = findViewById(R.id.texture_view);
        mFocusIndicator = findViewById(R.id.focus_indicator);
        mFocusIndicator.setBackground(createFocusIndicatorDrawable());
        mToggleCameraButton = findViewById(R.id.toggle_camera_button);
        mZoomController = findViewById(R.id.zoom_ui_controller);
        mHighQualityToggleButton = findViewById(R.id.high_quality_button);

        // Use "seamless" animation for rotations as we fix the UI relative to the device.
        // "seamless" will make the transition invisible to the users.
        WindowManager.LayoutParams windowAttrs = getWindow().getAttributes();
        windowAttrs.rotationAnimation = WindowManager.LayoutParams.ROTATION_ANIMATION_SEAMLESS;
        getWindow().setAttributes(windowAttrs);

        mAccessibilityManager = getSystemService(AccessibilityManager.class);
        if (mAccessibilityManager != null) {
            mAccessibilityManager.addAccessibilityServicesStateChangeListener(
                    mAccessibilityListener);
        }

        mUserPrefs = new UserPrefs(this.getApplicationContext());
        mCameraPickerDialog = new CameraPickerDialog(this::switchCamera);

        setupMainLayout();

        // Needed because onConfigChanged is not called when device rotates from landscape to
        // reverse-landscape or from portrait to reverse-portrait.
        mRootView.setOnApplyWindowInsetsListener((view, inset) -> {
            runOnUiThread(this::setupMainLayout);
            return WindowInsets.CONSUMED;
        });

        bindService(
                new Intent(this, DeviceAsWebcamFgServiceImpl.class),
                0,
                mThreadExecutor,
                mConnection);
    }

    private Drawable createFocusIndicatorDrawable() {
        int indicatorSize = getResources().getDimensionPixelSize(R.dimen.focus_indicator_size);
        Bitmap bitmap = Bitmap.createBitmap(indicatorSize, indicatorSize, Bitmap.Config.ARGB_8888);
        Canvas canvas = new Canvas(bitmap);

        OvalShape ovalShape = new OvalShape();
        ShapeDrawable shapeDrawable = new ShapeDrawable(ovalShape);
        Paint paint = shapeDrawable.getPaint();
        paint.setAntiAlias(true);
        paint.setStyle(Paint.Style.STROKE);

        int strokeWidth = getResources().getDimensionPixelSize(
                R.dimen.focus_indicator_stroke_width);
        paint.setStrokeWidth(strokeWidth);
        paint.setColor(getResources().getColor(android.R.color.white, null));
        int halfIndicatorSize = indicatorSize / 2;
        canvas.drawCircle(halfIndicatorSize, halfIndicatorSize, halfIndicatorSize - strokeWidth,
                paint);
        paint.setStyle(Paint.Style.FILL);
        paint.setColor(getResources().getColor(R.color.focus_indicator_background_color, null));
        canvas.drawCircle(halfIndicatorSize, halfIndicatorSize, halfIndicatorSize - strokeWidth,
                paint);

        return new BitmapDrawable(getResources(), bitmap);
    }

    private void hideSystemUiAndActionBar() {
        // Hides status bar
        Window window = getWindow();
        window.setStatusBarColor(android.R.color.system_neutral1_800);
        window.setDecorFitsSystemWindows(false);
        WindowInsetsController controller = window.getInsetsController();
        if (controller != null) {
            controller.hide(WindowInsets.Type.systemBars());
            controller.setSystemBarsBehavior(
                    WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
        }
    }

    @Override
    public void onResume() {
        super.onResume();
        hideSystemUiAndActionBar();
        // When the screen is turned off and turned back on, the SurfaceTexture is already
        // available, and "onSurfaceTextureAvailable" will not be called. In that case, we can open
        // a camera and start preview from here (otherwise, we wait until the surface is ready in
        // the SurfaceTextureListener).
        if (mTextureView.isAvailable()) {
            mWebcamControllerReady.block();
            if (!mTextureViewSetup) {
                setupTextureViewLayout();
                mTextureViewSetup = true;
            }
            if (mWebcamController != null && mPreviewSize != null) {
                mWebcamController.setPreviewSurfaceTexture(
                        mTextureView.getSurfaceTexture(), mPreviewSize, mPreviewSizeChangeListener);
                rotateUiByRotationDegrees(mWebcamController.getCurrentRotation());
                mWebcamController.setRotationUpdateListener(
                        rotation -> runOnUiThread(() -> rotateUiByRotationDegrees(rotation)));
                mZoomController.setZoomRatio(
                        mWebcamController.getZoomRatio(), ZoomController.ZOOM_UI_TOGGLE_MODE);
            }
        } else {
            mTextureView.setSurfaceTextureListener(mSurfaceTextureListener);
        }
    }

    @Override
    public void onPause() {
        if (mWebcamController != null) {
            mWebcamController.removePreviewSurfaceTexture();
            mWebcamController.setRotationUpdateListener(null);
        }
        super.onPause();
    }

    @Override
    public boolean onKeyDown(int keyCode, KeyEvent event) {
        if (mWebcamController == null
                || (keyCode != KeyEvent.KEYCODE_VOLUME_DOWN
                        && keyCode != KeyEvent.KEYCODE_VOLUME_UP)) {
            return super.onKeyDown(keyCode, event);
        }

        float zoomRatio = mWebcamController.getZoomRatio();

        // Uses volume key events to adjust zoom ratio
        if ((keyCode == KeyEvent.KEYCODE_VOLUME_DOWN)){
            zoomRatio -= 0.1f;
        } else {
            zoomRatio += 0.1f;
        }

        // Clamps the zoom ratio in the supported range
        Range<Float> zoomRatioRange = mWebcamController.getCameraInfo().getZoomRatioRange();
        zoomRatio =
                Math.min(Math.max(zoomRatio, zoomRatioRange.getLower()), zoomRatioRange.getUpper());

        // Updates the new value to all related controls
        mWebcamController.setZoomRatio(zoomRatio);
        mZoomController.setZoomRatio(zoomRatio, ZoomController.ZOOM_UI_SEEK_BAR_MODE);
        mMotionEventToZoomRatioConverter.setZoomRatio(zoomRatio);

        return true;
    }

    @Override
    public void onDestroy() {
        if (mAccessibilityManager != null) {
            mAccessibilityManager.removeAccessibilityServicesStateChangeListener(
                    mAccessibilityListener);
        }
        if (mWebcamController != null) {
            mWebcamController.setOnDestroyedCallback(null);
        }
        unbindService(mConnection);
        super.onDestroy();
    }

    /**
     * Returns {@code true} when the device has both available back and front cameras. Otherwise,
     * returns {@code false}.
     */
    private boolean canToggleCamera() {
        if (mWebcamController == null) {
            return false;
        }

        List<CameraId> availableCameraIds = mWebcamController.getAvailableCameraIds();
        boolean hasBackCamera = false;
        boolean hasFrontCamera = false;

        for (CameraId cameraId : availableCameraIds) {
            CameraInfo cameraInfo = mWebcamController.getOrCreateCameraInfo(cameraId);
            if (cameraInfo.getLensFacing() == CameraCharacteristics.LENS_FACING_BACK) {
                hasBackCamera = true;
            } else if (cameraInfo.getLensFacing() == CameraCharacteristics.LENS_FACING_FRONT) {
                hasFrontCamera = true;
            }
        }

        return hasBackCamera && hasFrontCamera;
    }

    private void setToggleCameraContentDescription() {
        if (mWebcamController == null) {
            return;
        }
        int lensFacing = mWebcamController.getCameraInfo().getLensFacing();
        CharSequence descr = getText(R.string.toggle_camera_button_description_front);
        if (lensFacing == CameraMetadata.LENS_FACING_FRONT) {
            descr = getText(R.string.toggle_camera_button_description_back);
        }
        mToggleCameraButton.setContentDescription(descr);
    }

    private void toggleCamera() {
        if (mWebcamController == null) {
            return;
        }

        mWebcamController.toggleCamera();
        setToggleCameraContentDescription();
        mFocusIndicator.setVisibility(View.GONE);
        mMotionEventToZoomRatioConverter.reset(
                mWebcamController.getZoomRatio(),
                mWebcamController.getCameraInfo().getZoomRatioRange());
        setupZoomRatioSeekBar();
        mZoomController.setZoomRatio(
                mWebcamController.getZoomRatio(), ZoomController.ZOOM_UI_TOGGLE_MODE);
        mCameraPickerDialog.updateSelectedCamera(mWebcamController.getCameraInfo().getCameraId());
    }

    private void switchCamera(CameraId cameraId) {
        if (mWebcamController == null) {
            return;
        }

        mWebcamController.switchCamera(cameraId);
        setToggleCameraContentDescription();
        mMotionEventToZoomRatioConverter.reset(
                mWebcamController.getZoomRatio(),
                mWebcamController.getCameraInfo().getZoomRatioRange());
        setupZoomRatioSeekBar();
        mZoomController.setZoomRatio(
                mWebcamController.getZoomRatio(), ZoomController.ZOOM_UI_TOGGLE_MODE);
        // CameraPickerDialog does not update its UI until the preview activity
        // notifies it of the change. So notify CameraPickerDialog about the camera change.
        mCameraPickerDialog.updateSelectedCamera(cameraId);
    }

    private boolean tapToFocus(MotionEvent motionEvent) {
        if (mWebcamController == null || mWebcamController.getCameraInfo() == null) {
            return false;
        }

        float[] normalizedPoint = calculateNormalizedPoint(motionEvent);

        if (isTapToResetAutoFocus(normalizedPoint)) {
            mFocusIndicator.setVisibility(View.GONE);
            mWebcamController.resetToAutoFocus();
        } else {
            showFocusIndicator(normalizedPoint);
            mWebcamController.tapToFocus(normalizedPoint);
        }

        return true;
    }

    /** Returns whether the new points overlap with the original tap-to-focus points or not. */
    private boolean isTapToResetAutoFocus(float[] newNormalizedPoints) {
        float[] oldNormalizedPoints = mWebcamController.getTapToFocusPoints();

        if (oldNormalizedPoints == null) {
            return false;
        }

        // Calculates the distance between the new and old points
        float distanceX = Math.abs(newNormalizedPoints[1] - oldNormalizedPoints[1])
                * mTextureViewCard.getWidth();
        float distanceY = Math.abs(newNormalizedPoints[0] - oldNormalizedPoints[0])
                * mTextureViewCard.getHeight();
        double distance = Math.sqrt(distanceX*distanceX + distanceY*distanceY);

        int indicatorRadius = getResources().getDimensionPixelSize(R.dimen.focus_indicator_size)
                / 2;

        // Checks whether the distance is less than the circle radius of focus indicator
        return indicatorRadius >= distance;
    }

    /**
     * Calculates the normalized point which will be the point between [0, 0] to [1, 1] mapping to
     * the preview size.
     */
    private float[] calculateNormalizedPoint(MotionEvent motionEvent) {
        return new float[]{motionEvent.getX() / mPreviewSize.getWidth(),
                motionEvent.getY() / mPreviewSize.getHeight()};
    }

    /**
     * Show the focus indicator and hide it automatically after a proper duration.
     */
    private void showFocusIndicator(float[] normalizedPoint) {
        int indicatorSize = getResources().getDimensionPixelSize(R.dimen.focus_indicator_size);
        float translationX =
                normalizedPoint[0] * mTextureViewCard.getWidth() - indicatorSize / 2f;
        float translationY = normalizedPoint[1] * mTextureViewCard.getHeight()
                - indicatorSize / 2f;
        mFocusIndicator.setTranslationX(translationX);
        mFocusIndicator.setTranslationY(translationY);
        mFocusIndicator.setVisibility(View.VISIBLE);
    }

    private List<CameraPickerDialog.ListItem> createCameraListForPicker() {
        List<CameraId> availableCameraIds = mWebcamController.getAvailableCameraIds();
        if (availableCameraIds == null) {
            Log.w(TAG, "No cameras listed for picker. Why is Webcam Preview running?");
            return List.of();
        }

        return availableCameraIds.stream()
                .map(mWebcamController::getOrCreateCameraInfo)
                .filter(Objects::nonNull)
                .map(CameraPickerDialog.ListItem::new)
                .toList();
    }
}
