/*
 * Copyright (C) 2015 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.messaging.ui.mediapicker;

import android.Manifest;
import android.app.Activity;
import android.content.Context;
import android.content.pm.ActivityInfo;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.hardware.Camera;
import android.hardware.Camera.CameraInfo;
import android.media.MediaRecorder;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Looper;
import androidx.annotation.NonNull;
import android.text.TextUtils;
import android.util.DisplayMetrics;
import android.view.MotionEvent;
import android.view.OrientationEventListener;
import android.view.Surface;
import android.view.View;
import android.view.WindowManager;

import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.datamodel.media.ImageRequest;
import com.android.messaging.sms.MmsConfig;
import com.android.messaging.ui.mediapicker.camerafocus.FocusOverlayManager;
import com.android.messaging.ui.mediapicker.camerafocus.RenderOverlay;
import com.android.messaging.util.Assert;
import com.android.messaging.util.BugleGservices;
import com.android.messaging.util.BugleGservicesKeys;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.OsUtil;
import com.android.messaging.util.UiUtils;
import com.google.common.annotations.VisibleForTesting;

import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;

/**
 * Class which manages interactions with the camera, but does not do any UI.  This class is
 * designed to be a singleton to ensure there is one component managing the camera and releasing
 * the native resources.
 * In order to acquire a camera, a caller must:
 * <ul>
 *     <li>Call selectCamera to select front or back camera
 *     <li>Call setSurface to control where the preview is shown
 *     <li>Call openCamera to request the camera start preview
 * </ul>
 * Callers should call onPause and onResume to ensure that the camera is release while the activity
 * is not active.
 * This class is not thread safe.  It should only be called from one thread (the UI thread or test
 * thread)
 */
class CameraManager implements FocusOverlayManager.Listener {
    /**
     * Wrapper around the framework camera API to allow mocking different hardware scenarios while
     * unit testing
     */
    interface CameraWrapper {
        int getNumberOfCameras();
        void getCameraInfo(int index, CameraInfo cameraInfo);
        Camera open(int cameraId);
        /** Add a wrapper for release because a final method cannot be mocked */
        void release(Camera camera);
    }

    /**
     * Callbacks for the camera manager listener
     */
    interface CameraManagerListener {
        void onCameraError(int errorCode, Exception e);
        void onCameraChanged();
    }

    /**
     * Callback when taking image or video
     */
    interface MediaCallback {
        static final int MEDIA_CAMERA_CHANGED = 1;
        static final int MEDIA_NO_DATA = 2;

        void onMediaReady(Uri uriToMedia, String contentType, int width, int height);
        void onMediaFailed(Exception exception);
        void onMediaInfo(int what);
    }

    // Error codes
    static final int ERROR_OPENING_CAMERA = 1;
    static final int ERROR_SHOWING_PREVIEW = 2;
    static final int ERROR_INITIALIZING_VIDEO = 3;
    static final int ERROR_STORAGE_FAILURE = 4;
    static final int ERROR_RECORDING_VIDEO = 5;
    static final int ERROR_HARDWARE_ACCELERATION_DISABLED = 6;
    static final int ERROR_TAKING_PICTURE = 7;

    private static final String TAG = LogUtil.BUGLE_TAG;
    private static final int NO_CAMERA_SELECTED = -1;

    private static CameraManager sInstance;

    /** Default camera wrapper which directs calls to the framework APIs */
    private static CameraWrapper sCameraWrapper = new CameraWrapper() {
        @Override
        public int getNumberOfCameras() {
            return Camera.getNumberOfCameras();
        }

        @Override
        public void getCameraInfo(final int index, final CameraInfo cameraInfo) {
            Camera.getCameraInfo(index, cameraInfo);
        }

        @Override
        public Camera open(final int cameraId) {
            return Camera.open(cameraId);
        }

        @Override
        public void release(final Camera camera) {
            camera.release();
        }
    };

    /** The CameraInfo for the currently selected camera */
    private final CameraInfo mCameraInfo;

    /**
     * The index of the selected camera or NO_CAMERA_SELECTED if a camera hasn't been selected yet
     */
    private int mCameraIndex;

    /** True if the device has front and back cameras */
    private final boolean mHasFrontAndBackCamera;

    /** True if the camera should be open (may not yet be actually open) */
    private boolean mOpenRequested;

    /** True if the camera is requested to be in video mode */
    private boolean mVideoModeRequested;

    /** The media recorder for video mode */
    private MmsVideoRecorder mMediaRecorder;

    /** Callback to call with video recording updates */
    private MediaCallback mVideoCallback;

    /** The preview view to show the preview on */
    private CameraPreview mCameraPreview;

    /** The helper classs to handle orientation changes */
    private OrientationHandler mOrientationHandler;

    /** Tracks whether the preview has hardware acceleration */
    private boolean mIsHardwareAccelerationSupported;

    /**
     * The task for opening the camera, so it doesn't block the UI thread
     * Using AsyncTask rather than SafeAsyncTask because the tasks need to be serialized, but don't
     * need to be on the UI thread
     * TODO: If we have other AyncTasks (not SafeAsyncTasks) this may contend and we may
     * need to create a dedicated thread, or synchronize the threads in the thread pool
     */
    private AsyncTask<Integer, Void, Camera> mOpenCameraTask;

    /**
     * The camera index that is queued to be opened, but not completed yet, or NO_CAMERA_SELECTED if
     * no open task is pending
     */
    private int mPendingOpenCameraIndex = NO_CAMERA_SELECTED;

    /** The instance of the currently opened camera */
    private Camera mCamera;

    /** The rotation of the screen relative to the camera's natural orientation */
    private int mRotation;

    /** The callback to notify when errors or other events occur */
    private CameraManagerListener mListener;

    /** True if the camera is currently in the process of taking an image */
    private boolean mTakingPicture;

    /** Provides subscription-related data to access per-subscription configurations. */
    private DraftMessageSubscriptionDataProvider mSubscriptionDataProvider;

    /** Manages auto focus visual and behavior */
    private final FocusOverlayManager mFocusOverlayManager;

    private CameraManager() {
        mCameraInfo = new CameraInfo();
        mCameraIndex = NO_CAMERA_SELECTED;

        // Check to see if a front and back camera exist
        boolean hasFrontCamera = false;
        boolean hasBackCamera = false;
        final CameraInfo cameraInfo = new CameraInfo();
        final int cameraCount = sCameraWrapper.getNumberOfCameras();
        try {
            for (int i = 0; i < cameraCount; i++) {
                sCameraWrapper.getCameraInfo(i, cameraInfo);
                if (cameraInfo.facing == CameraInfo.CAMERA_FACING_FRONT) {
                    hasFrontCamera = true;
                } else if (cameraInfo.facing == CameraInfo.CAMERA_FACING_BACK) {
                    hasBackCamera = true;
                }
                if (hasFrontCamera && hasBackCamera) {
                    break;
                }
            }
        } catch (final RuntimeException e) {
            LogUtil.e(TAG, "Unable to load camera info", e);
        }
        mHasFrontAndBackCamera = hasFrontCamera && hasBackCamera;
        mFocusOverlayManager = new FocusOverlayManager(this, Looper.getMainLooper());

        // Assume the best until we are proven otherwise
        mIsHardwareAccelerationSupported = true;
    }

    /** Gets the singleton instance */
    static CameraManager get() {
        if (sInstance == null) {
            sInstance = new CameraManager();
        }
        return sInstance;
    }

    /** Allows tests to inject a custom camera wrapper */
    @VisibleForTesting
    static void setCameraWrapper(final CameraWrapper cameraWrapper) {
        sCameraWrapper = cameraWrapper;
        sInstance = null;
    }

    /**
     * Sets the surface to use to display the preview
     * This must only be called AFTER the CameraPreview has a texture ready
     * @param preview The preview surface view
     */
    void setSurface(final CameraPreview preview) {
        if (preview == mCameraPreview) {
            return;
        }

        if (preview != null) {
            Assert.isTrue(preview.isValid());
            preview.setOnTouchListener(new View.OnTouchListener() {
                @Override
                public boolean onTouch(final View view, final MotionEvent motionEvent) {
                    if ((motionEvent.getActionMasked() & MotionEvent.ACTION_UP) ==
                            MotionEvent.ACTION_UP) {
                        mFocusOverlayManager.setPreviewSize(view.getWidth(), view.getHeight());
                        mFocusOverlayManager.onSingleTapUp(
                                (int) motionEvent.getX() + view.getLeft(),
                                (int) motionEvent.getY() + view.getTop());
                    }
                    return true;
                }
            });
        }
        mCameraPreview = preview;
        tryShowPreview();
    }

    void setRenderOverlay(final RenderOverlay renderOverlay) {
        mFocusOverlayManager.setFocusRenderer(renderOverlay != null ?
                renderOverlay.getPieRenderer() : null);
    }

    /** Convenience function to swap between front and back facing cameras */
    void swapCamera() {
        Assert.isTrue(mCameraIndex >= 0);
        selectCamera(mCameraInfo.facing == CameraInfo.CAMERA_FACING_FRONT ?
                CameraInfo.CAMERA_FACING_BACK :
                CameraInfo.CAMERA_FACING_FRONT);
    }

    /**
     * Selects the first camera facing the desired direction, or the first camera if there is no
     * camera in the desired direction
     * @param desiredFacing One of the CameraInfo.CAMERA_FACING_* constants
     * @return True if a camera was selected, or false if selecting a camera failed
     */
    boolean selectCamera(final int desiredFacing) {
        try {
            // We already selected a camera facing that direction
            if (mCameraIndex >= 0 && mCameraInfo.facing == desiredFacing) {
                return true;
            }

            final int cameraCount = sCameraWrapper.getNumberOfCameras();
            Assert.isTrue(cameraCount > 0);

            mCameraIndex = NO_CAMERA_SELECTED;
            setCamera(null);
            final CameraInfo cameraInfo = new CameraInfo();
            for (int i = 0; i < cameraCount; i++) {
                sCameraWrapper.getCameraInfo(i, cameraInfo);
                if (cameraInfo.facing == desiredFacing) {
                    mCameraIndex = i;
                    sCameraWrapper.getCameraInfo(i, mCameraInfo);
                    break;
                }
            }

            // There's no camera in the desired facing direction, just select the first camera
            // regardless of direction
            if (mCameraIndex < 0) {
                mCameraIndex = 0;
                sCameraWrapper.getCameraInfo(0, mCameraInfo);
            }

            if (mOpenRequested) {
                // The camera is open, so reopen with the newly selected camera
                openCamera();
            }
            return true;
        } catch (final RuntimeException e) {
            LogUtil.e(TAG, "RuntimeException in CameraManager.selectCamera", e);
            if (mListener != null) {
                mListener.onCameraError(ERROR_OPENING_CAMERA, e);
            }
            return false;
        }
    }

    int getCameraIndex() {
        return mCameraIndex;
    }

    void selectCameraByIndex(final int cameraIndex) {
        if (mCameraIndex == cameraIndex) {
            return;
        }

        try {
            mCameraIndex = cameraIndex;
            sCameraWrapper.getCameraInfo(mCameraIndex, mCameraInfo);
            if (mOpenRequested) {
                openCamera();
            }
        } catch (final RuntimeException e) {
            LogUtil.e(TAG, "RuntimeException in CameraManager.selectCameraByIndex", e);
            if (mListener != null) {
                mListener.onCameraError(ERROR_OPENING_CAMERA, e);
            }
        }
    }

    @VisibleForTesting
    CameraInfo getCameraInfo() {
        if (mCameraIndex == NO_CAMERA_SELECTED) {
            return null;
        }
        return mCameraInfo;
    }

    /** @return True if this device has camera capabilities */
    boolean hasAnyCamera() {
        return sCameraWrapper.getNumberOfCameras() > 0;
    }

    /** @return True if the device has both a front and back camera */
    boolean hasFrontAndBackCamera() {
        return mHasFrontAndBackCamera;
    }

    /**
     * Opens the camera on a separate thread and initiates the preview if one is available
     */
    void openCamera() {
        if (mCameraIndex == NO_CAMERA_SELECTED) {
            // Ensure a selected camera if none is currently selected. This may happen if the
            // camera chooser is not the default media chooser.
            selectCamera(CameraInfo.CAMERA_FACING_BACK);
        }
        mOpenRequested = true;
        // We're already opening the camera or already have the camera handle, nothing more to do
        if (mPendingOpenCameraIndex == mCameraIndex || mCamera != null) {
            return;
        }

        // True if the task to open the camera has to be delayed until the current one completes
        boolean delayTask = false;

        // Cancel any previous open camera tasks
        if (mOpenCameraTask != null) {
            mPendingOpenCameraIndex = NO_CAMERA_SELECTED;
            delayTask = true;
        }

        mPendingOpenCameraIndex = mCameraIndex;
        mOpenCameraTask = new AsyncTask<Integer, Void, Camera>() {
            private Exception mException;

            @Override
            protected Camera doInBackground(final Integer... params) {
                try {
                    final int cameraIndex = params[0];
                    if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
                        LogUtil.v(TAG, "Opening camera " + mCameraIndex);
                    }
                    return sCameraWrapper.open(cameraIndex);
                } catch (final Exception e) {
                    LogUtil.e(TAG, "Exception while opening camera", e);
                    mException = e;
                    return null;
                }
            }

            @Override
            protected void onPostExecute(final Camera camera) {
                // If we completed, but no longer want this camera, then release the camera
                if (mOpenCameraTask != this || !mOpenRequested) {
                    releaseCamera(camera);
                    cleanup();
                    return;
                }

                cleanup();

                if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
                    LogUtil.v(TAG, "Opened camera " + mCameraIndex + " " + (camera != null));
                }

                setCamera(camera);
                if (camera == null) {
                    if (mListener != null) {
                        mListener.onCameraError(ERROR_OPENING_CAMERA, mException);
                    }
                    LogUtil.e(TAG, "Error opening camera");
                }
            }

            @Override
            protected void onCancelled() {
                super.onCancelled();
                cleanup();
            }

            private void cleanup() {
                mPendingOpenCameraIndex = NO_CAMERA_SELECTED;
                if (mOpenCameraTask != null && mOpenCameraTask.getStatus() == Status.PENDING) {
                    // If there's another task waiting on this one to complete, start it now
                    mOpenCameraTask.execute(mCameraIndex);
                } else {
                    mOpenCameraTask = null;
                }

            }
        };
        if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
            LogUtil.v(TAG, "Start opening camera " + mCameraIndex);
        }

        if (!delayTask) {
            mOpenCameraTask.execute(mCameraIndex);
        }
    }

    boolean isVideoMode() {
        return mVideoModeRequested;
    }

    boolean isRecording() {
        return mVideoModeRequested && mVideoCallback != null;
    }

    void setVideoMode(final boolean videoMode) {
        if (mVideoModeRequested == videoMode) {
            return;
        }
        mVideoModeRequested = videoMode;
        tryInitOrCleanupVideoMode();
    }

    /** Closes the camera releasing the resources it uses */
    void closeCamera() {
        mOpenRequested = false;
        setCamera(null);
    }

    /** Temporarily closes the camera if it is open */
    void onPause() {
        setCamera(null);
    }

    /** Reopens the camera if it was opened when onPause was called */
    void onResume() {
        if (mOpenRequested) {
            openCamera();
        }
    }

    /**
     * Sets the listener which will be notified of errors or other events in the camera
     * @param listener The listener to notify
     */
    void setListener(final CameraManagerListener listener) {
        Assert.isMainThread();
        mListener = listener;
        if (!mIsHardwareAccelerationSupported && mListener != null) {
            mListener.onCameraError(ERROR_HARDWARE_ACCELERATION_DISABLED, null);
        }
    }

    void setSubscriptionDataProvider(final DraftMessageSubscriptionDataProvider provider) {
        mSubscriptionDataProvider = provider;
    }

    void takePicture(final float heightPercent, @NonNull final MediaCallback callback) {
        Assert.isTrue(!mVideoModeRequested);
        Assert.isTrue(!mTakingPicture);
        Assert.notNull(callback);
        if (mCamera == null) {
            // The caller should have checked isCameraAvailable first, but just in case, protect
            // against a null camera by notifying the callback that taking the picture didn't work
            callback.onMediaFailed(null);
            return;
        }
        final Camera.PictureCallback jpegCallback = new Camera.PictureCallback() {
            @Override
            public void onPictureTaken(final byte[] bytes, final Camera camera) {
                mTakingPicture = false;
                if (mCamera != camera) {
                    // This may happen if the camera was changed between front/back while the
                    // picture is being taken.
                    callback.onMediaInfo(MediaCallback.MEDIA_CAMERA_CHANGED);
                    return;
                }

                if (bytes == null) {
                    callback.onMediaInfo(MediaCallback.MEDIA_NO_DATA);
                    return;
                }

                final Camera.Size size = camera.getParameters().getPictureSize();
                int width;
                int height;
                if (mRotation == 90 || mRotation == 270) {
                    width = size.height;
                    height = size.width;
                } else {
                    width = size.width;
                    height = size.height;
                }
                new ImagePersistTask(
                        width, height, heightPercent, bytes, mCameraPreview.getContext(), callback)
                        .executeOnThreadPool();
            }
        };

        mTakingPicture = true;
        try {
            mCamera.takePicture(
                    null /* shutter */,
                    null /* raw */,
                    null /* postView */,
                    jpegCallback);
        } catch (final RuntimeException e) {
            LogUtil.e(TAG, "RuntimeException in CameraManager.takePicture", e);
            mTakingPicture = false;
            if (mListener != null) {
                mListener.onCameraError(ERROR_TAKING_PICTURE, e);
            }
        }
    }

    void startVideo(final MediaCallback callback) {
        Assert.notNull(callback);
        Assert.isTrue(!isRecording());
        mVideoCallback = callback;
        tryStartVideoCapture();
    }

    /**
     * Asynchronously releases a camera
     * @param camera The camera to release
     */
    private void releaseCamera(final Camera camera) {
        if (camera == null) {
            return;
        }

        mFocusOverlayManager.onCameraReleased();

        new AsyncTask<Void, Void, Void>() {
            @Override
            protected Void doInBackground(final Void... params) {
                if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
                    LogUtil.v(TAG, "Releasing camera " + mCameraIndex);
                }
                sCameraWrapper.release(camera);
                return null;
            }
        }.execute();
    }

    private void releaseMediaRecorder(final boolean cleanupFile) {
        if (mMediaRecorder == null) {
            return;
        }
        mVideoModeRequested = false;

        if (cleanupFile) {
            mMediaRecorder.cleanupTempFile();
            if (mVideoCallback != null) {
                final MediaCallback callback = mVideoCallback;
                mVideoCallback = null;
                // Notify the callback that we've stopped recording
                callback.onMediaReady(null /*uri*/, null /*contentType*/, 0 /*width*/,
                        0 /*height*/);
            }
        }

        mMediaRecorder.closeVideoFileDescriptor();
        mMediaRecorder.release();
        mMediaRecorder = null;

        if (mCamera != null) {
            try {
                mCamera.reconnect();
            } catch (final IOException e) {
                LogUtil.e(TAG, "IOException in CameraManager.releaseMediaRecorder", e);
                if (mListener != null) {
                    mListener.onCameraError(ERROR_OPENING_CAMERA, e);
                }
            } catch (final RuntimeException e) {
                LogUtil.e(TAG, "RuntimeException in CameraManager.releaseMediaRecorder", e);
                if (mListener != null) {
                    mListener.onCameraError(ERROR_OPENING_CAMERA, e);
                }
            }
        }
        restoreRequestedOrientation();
    }

    /** Updates the orientation of the camera to match the orientation of the device */
    private void updateCameraOrientation() {
        if (mCamera == null || mCameraPreview == null || mTakingPicture) {
            return;
        }

        final WindowManager windowManager =
                (WindowManager) mCameraPreview.getContext().getSystemService(
                        Context.WINDOW_SERVICE);

        int degrees = 0;
        switch (windowManager.getDefaultDisplay().getRotation()) {
            case Surface.ROTATION_0: degrees = 0; break;
            case Surface.ROTATION_90: degrees = 90; break;
            case Surface.ROTATION_180: degrees = 180; break;
            case Surface.ROTATION_270: degrees = 270; break;
        }

        // The display orientation of the camera (this controls the preview image).
        int orientation;

        // The clockwise rotation angle relative to the orientation of the camera. This affects
        // pictures returned by the camera in Camera.PictureCallback.
        int rotation;
        if (mCameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
            orientation = (mCameraInfo.orientation + degrees) % 360;
            rotation = orientation;
            // compensate the mirror but only for orientation
            orientation = (360 - orientation) % 360;
        } else {  // back-facing
            orientation = (mCameraInfo.orientation - degrees + 360) % 360;
            rotation = orientation;
        }
        mRotation = rotation;
        if (mMediaRecorder == null) {
            try {
                mCamera.setDisplayOrientation(orientation);
                final Camera.Parameters params = mCamera.getParameters();
                params.setRotation(rotation);
                mCamera.setParameters(params);
            } catch (final RuntimeException e) {
                LogUtil.e(TAG, "RuntimeException in CameraManager.updateCameraOrientation", e);
                if (mListener != null) {
                    mListener.onCameraError(ERROR_OPENING_CAMERA, e);
                }
            }
        }
    }

    /** Sets the current camera, releasing any previously opened camera */
    private void setCamera(final Camera camera) {
        if (mCamera == camera) {
            return;
        }

        releaseMediaRecorder(true /* cleanupFile */);
        releaseCamera(mCamera);
        mCamera = camera;
        tryShowPreview();
        if (mListener != null) {
            mListener.onCameraChanged();
        }
    }

    /** Shows the preview if the camera is open and the preview is loaded */
    private void tryShowPreview() {
        if (mCameraPreview == null || mCamera == null) {
            if (mOrientationHandler != null) {
                mOrientationHandler.disable();
                mOrientationHandler = null;
            }
            releaseMediaRecorder(true /* cleanupFile */);
            mFocusOverlayManager.onPreviewStopped();
            return;
        }
        try {
            mCamera.stopPreview();
            updateCameraOrientation();

            final Camera.Parameters params = mCamera.getParameters();
            final Camera.Size pictureSize = chooseBestPictureSize();
            final Camera.Size previewSize = chooseBestPreviewSize(pictureSize);
            params.setPreviewSize(previewSize.width, previewSize.height);
            params.setPictureSize(pictureSize.width, pictureSize.height);
            logCameraSize("Setting preview size: ", previewSize);
            logCameraSize("Setting picture size: ", pictureSize);
            mCameraPreview.setSize(previewSize, mCameraInfo.orientation);
            for (final String focusMode : params.getSupportedFocusModes()) {
                if (TextUtils.equals(focusMode, Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE)) {
                    // Use continuous focus if available
                    params.setFocusMode(focusMode);
                    break;
                }
            }

            mCamera.setParameters(params);
            mCameraPreview.startPreview(mCamera);
            mCamera.startPreview();
            mCamera.setAutoFocusMoveCallback(new Camera.AutoFocusMoveCallback() {
                @Override
                public void onAutoFocusMoving(final boolean start, final Camera camera) {
                    mFocusOverlayManager.onAutoFocusMoving(start);
                }
            });
            mFocusOverlayManager.setParameters(mCamera.getParameters());
            mFocusOverlayManager.setMirror(mCameraInfo.facing == CameraInfo.CAMERA_FACING_BACK);
            mFocusOverlayManager.onPreviewStarted();
            tryInitOrCleanupVideoMode();
            if (mOrientationHandler == null) {
                mOrientationHandler = new OrientationHandler(mCameraPreview.getContext());
                mOrientationHandler.enable();
            }
        } catch (final IOException e) {
            LogUtil.e(TAG, "IOException in CameraManager.tryShowPreview", e);
            if (mListener != null) {
                mListener.onCameraError(ERROR_SHOWING_PREVIEW, e);
            }
        } catch (final RuntimeException e) {
            LogUtil.e(TAG, "RuntimeException in CameraManager.tryShowPreview", e);
            if (mListener != null) {
                mListener.onCameraError(ERROR_SHOWING_PREVIEW, e);
            }
        }
    }

    private void tryInitOrCleanupVideoMode() {
        if (!mVideoModeRequested || mCamera == null || mCameraPreview == null) {
            releaseMediaRecorder(true /* cleanupFile */);
            return;
        }

        if (mMediaRecorder != null) {
            return;
        }

        try {
            mCamera.unlock();
            final int maxMessageSize = getMmsConfig().getMaxMessageSize();
            mMediaRecorder = new MmsVideoRecorder(mCamera, mCameraIndex, mRotation, maxMessageSize);
            mMediaRecorder.prepare();
        } catch (final FileNotFoundException e) {
            LogUtil.e(TAG, "FileNotFoundException in CameraManager.tryInitOrCleanupVideoMode", e);
            if (mListener != null) {
                mListener.onCameraError(ERROR_STORAGE_FAILURE, e);
            }
            setVideoMode(false);
            return;
        } catch (final IOException e) {
            LogUtil.e(TAG, "IOException in CameraManager.tryInitOrCleanupVideoMode", e);
            if (mListener != null) {
                mListener.onCameraError(ERROR_INITIALIZING_VIDEO, e);
            }
            setVideoMode(false);
            return;
        } catch (final RuntimeException e) {
            LogUtil.e(TAG, "RuntimeException in CameraManager.tryInitOrCleanupVideoMode", e);
            if (mListener != null) {
                mListener.onCameraError(ERROR_INITIALIZING_VIDEO, e);
            }
            setVideoMode(false);
            return;
        }

        tryStartVideoCapture();
    }

    private void tryStartVideoCapture() {
        if (mMediaRecorder == null || mVideoCallback == null) {
            return;
        }

        mMediaRecorder.setOnErrorListener(new MediaRecorder.OnErrorListener() {
            @Override
            public void onError(final MediaRecorder mediaRecorder, final int what,
                    final int extra) {
                if (mListener != null) {
                    mListener.onCameraError(ERROR_RECORDING_VIDEO, null);
                }
                restoreRequestedOrientation();
            }
        });

        mMediaRecorder.setOnInfoListener(new MediaRecorder.OnInfoListener() {
            @Override
            public void onInfo(final MediaRecorder mediaRecorder, final int what, final int extra) {
                if (what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED ||
                        what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) {
                    stopVideo();
                }
            }
        });

        try {
            mMediaRecorder.start();
            final Activity activity = UiUtils.getActivity(mCameraPreview.getContext());
            activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
            lockOrientation();
        } catch (final IllegalStateException e) {
            LogUtil.e(TAG, "IllegalStateException in CameraManager.tryStartVideoCapture", e);
            if (mListener != null) {
                mListener.onCameraError(ERROR_RECORDING_VIDEO, e);
            }
            setVideoMode(false);
            restoreRequestedOrientation();
        } catch (final RuntimeException e) {
            LogUtil.e(TAG, "RuntimeException in CameraManager.tryStartVideoCapture", e);
            if (mListener != null) {
                mListener.onCameraError(ERROR_RECORDING_VIDEO, e);
            }
            setVideoMode(false);
            restoreRequestedOrientation();
        }
    }

    void stopVideo() {
        int width = ImageRequest.UNSPECIFIED_SIZE;
        int height = ImageRequest.UNSPECIFIED_SIZE;
        Uri uri = null;
        String contentType = null;
        try {
            final Activity activity = UiUtils.getActivity(mCameraPreview.getContext());
            activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
            mMediaRecorder.stop();
            width = mMediaRecorder.getVideoWidth();
            height = mMediaRecorder.getVideoHeight();
            uri = mMediaRecorder.getVideoUri();
            contentType = mMediaRecorder.getContentType();
        } catch (final RuntimeException e) {
            // MediaRecorder.stop will throw a RuntimeException if the video was too short, let the
            // finally clause call the callback with null uri and handle cleanup
            LogUtil.e(TAG, "RuntimeException in CameraManager.stopVideo", e);
        } finally {
            final MediaCallback videoCallback = mVideoCallback;
            mVideoCallback = null;
            releaseMediaRecorder(false /* cleanupFile */);
            if (uri == null) {
                tryInitOrCleanupVideoMode();
            }
            videoCallback.onMediaReady(uri, contentType, width, height);
        }
    }

    boolean isCameraAvailable() {
        return mCamera != null && !mTakingPicture && mIsHardwareAccelerationSupported;
    }

    /**
     * External components call into this to report if hardware acceleration is supported.  When
     * hardware acceleration isn't supported, we need to report an error through the listener
     * interface
     * @param isHardwareAccelerationSupported True if the preview is rendering in a hardware
     *                                        accelerated view.
     */
    void reportHardwareAccelerationSupported(final boolean isHardwareAccelerationSupported) {
        Assert.isMainThread();
        if (mIsHardwareAccelerationSupported == isHardwareAccelerationSupported) {
            // If the value hasn't changed nothing more to do
            return;
        }

        mIsHardwareAccelerationSupported = isHardwareAccelerationSupported;
        if (!isHardwareAccelerationSupported) {
            LogUtil.e(TAG, "Software rendering - cannot open camera");
            if (mListener != null) {
                mListener.onCameraError(ERROR_HARDWARE_ACCELERATION_DISABLED, null);
            }
        }
    }

    /** Returns the scale factor to scale the width/height to max allowed in MmsConfig */
    private float getScaleFactorForMaxAllowedSize(final int width, final int height,
            final int maxWidth, final int maxHeight) {
        if (maxWidth <= 0 || maxHeight <= 0) {
            // MmsConfig initialization runs asynchronously on application startup, so there's a
            // chance (albeit a very slight one) that we don't have it yet.
            LogUtil.w(LogUtil.BUGLE_TAG, "Max image size not loaded in MmsConfig");
            return 1.0f;
        }

        if (width <= maxWidth && height <= maxHeight) {
            // Already meeting requirements.
            return 1.0f;
        }

        return Math.min(maxWidth * 1.0f / width, maxHeight * 1.0f / height);
    }

    private MmsConfig getMmsConfig() {
        final int subId = mSubscriptionDataProvider != null ?
                mSubscriptionDataProvider.getConversationSelfSubId() :
                ParticipantData.DEFAULT_SELF_SUB_ID;
        return MmsConfig.get(subId);
    }

    /**
     * Choose the best picture size by trying to find a size close to the MmsConfig's max size,
     * which is closest to the screen aspect ratio
     */
    private Camera.Size chooseBestPictureSize() {
        final Context context = mCameraPreview.getContext();
        final Resources resources = context.getResources();
        final DisplayMetrics displayMetrics = resources.getDisplayMetrics();
        final int displayOrientation = resources.getConfiguration().orientation;
        int cameraOrientation = mCameraInfo.orientation;

        int screenWidth;
        int screenHeight;
        if (displayOrientation == Configuration.ORIENTATION_LANDSCAPE) {
            // Rotate the camera orientation 90 degrees to compensate for the rotated display
            // metrics. Direction doesn't matter because we're just using it for width/height
            cameraOrientation += 90;
        }

        // Check the camera orientation relative to the display.
        // For 0, 180, 360, the screen width/height are the display width/height
        // For 90, 270, the screen width/height are inverted from the display
        if (cameraOrientation % 180 == 0) {
            screenWidth = displayMetrics.widthPixels;
            screenHeight = displayMetrics.heightPixels;
        } else {
            screenWidth = displayMetrics.heightPixels;
            screenHeight = displayMetrics.widthPixels;
        }

        final MmsConfig mmsConfig = getMmsConfig();
        final int maxWidth = mmsConfig.getMaxImageWidth();
        final int maxHeight = mmsConfig.getMaxImageHeight();

        // Constrain the size within the max width/height defined by MmsConfig.
        final float scaleFactor = getScaleFactorForMaxAllowedSize(screenWidth, screenHeight,
                maxWidth, maxHeight);
        screenWidth *= scaleFactor;
        screenHeight *= scaleFactor;

        final float aspectRatio = BugleGservices.get().getFloat(
                BugleGservicesKeys.CAMERA_ASPECT_RATIO,
                screenWidth / (float) screenHeight);
        final List<Camera.Size> sizes = new ArrayList<Camera.Size>(
                mCamera.getParameters().getSupportedPictureSizes());
        final int maxPixels = maxWidth * maxHeight;

        // Sort the sizes so the best size is first
        Collections.sort(sizes, new SizeComparator(maxWidth, maxHeight, aspectRatio, maxPixels));

        return sizes.get(0);
    }

    /**
     * Chose the best preview size based on the picture size.  Try to find a size with the same
     * aspect ratio and size as the picture if possible
     */
    private Camera.Size chooseBestPreviewSize(final Camera.Size pictureSize) {
        final List<Camera.Size> sizes = new ArrayList<Camera.Size>(
                mCamera.getParameters().getSupportedPreviewSizes());
        final float aspectRatio = pictureSize.width / (float) pictureSize.height;
        final int capturePixels = pictureSize.width * pictureSize.height;

        // Sort the sizes so the best size is first
        Collections.sort(sizes, new SizeComparator(Integer.MAX_VALUE, Integer.MAX_VALUE,
                aspectRatio, capturePixels));

        return sizes.get(0);
    }

    private class OrientationHandler extends OrientationEventListener {
        OrientationHandler(final Context context) {
            super(context);
        }

        @Override
        public void onOrientationChanged(final int orientation) {
            updateCameraOrientation();
        }
    }

    private static class SizeComparator implements Comparator<Camera.Size> {
        private static final int PREFER_LEFT = -1;
        private static final int PREFER_RIGHT = 1;

        // The max width/height for the preferred size. Integer.MAX_VALUE if no size limit
        private final int mMaxWidth;
        private final int mMaxHeight;

        // The desired aspect ratio
        private final float mTargetAspectRatio;

        // The desired size (width x height) to try to match
        private final int mTargetPixels;

        public SizeComparator(final int maxWidth, final int maxHeight,
                              final float targetAspectRatio, final int targetPixels) {
            mMaxWidth = maxWidth;
            mMaxHeight = maxHeight;
            mTargetAspectRatio = targetAspectRatio;
            mTargetPixels = targetPixels;
        }

        /**
         * Returns a negative value if left is a better choice than right, or a positive value if
         * right is a better choice is better than left.  0 if they are equal
         */
        @Override
        public int compare(final Camera.Size left, final Camera.Size right) {
            // If one size is less than the max size prefer it over the other
            if ((left.width <= mMaxWidth && left.height <= mMaxHeight) !=
                    (right.width <= mMaxWidth && right.height <= mMaxHeight)) {
                return left.width <= mMaxWidth ? PREFER_LEFT : PREFER_RIGHT;
            }

            // If one is closer to the target aspect ratio, prefer it.
            final float leftAspectRatio = left.width / (float) left.height;
            final float rightAspectRatio = right.width / (float) right.height;
            final float leftAspectRatioDiff = Math.abs(leftAspectRatio - mTargetAspectRatio);
            final float rightAspectRatioDiff = Math.abs(rightAspectRatio - mTargetAspectRatio);
            if (leftAspectRatioDiff != rightAspectRatioDiff) {
                return (leftAspectRatioDiff - rightAspectRatioDiff) < 0 ?
                        PREFER_LEFT : PREFER_RIGHT;
            }

            // At this point they have the same aspect ratio diff and are either both bigger
            // than the max size or both smaller than the max size, so prefer the one closest
            // to target size
            final int leftDiff = Math.abs((left.width * left.height) - mTargetPixels);
            final int rightDiff = Math.abs((right.width * right.height) - mTargetPixels);
            return leftDiff - rightDiff;
        }
    }

    @Override // From FocusOverlayManager.Listener
    public void autoFocus() {
        if (mCamera == null) {
            return;
        }

        try {
            mCamera.autoFocus(new Camera.AutoFocusCallback() {
                @Override
                public void onAutoFocus(final boolean success, final Camera camera) {
                    mFocusOverlayManager.onAutoFocus(success, false /* shutterDown */);
                }
            });
        } catch (final RuntimeException e) {
            LogUtil.e(TAG, "RuntimeException in CameraManager.autoFocus", e);
            // If autofocus fails, the camera should have called the callback with success=false,
            // but some throw an exception here
            mFocusOverlayManager.onAutoFocus(false /*success*/, false /*shutterDown*/);
        }
    }

    @Override // From FocusOverlayManager.Listener
    public void cancelAutoFocus() {
        if (mCamera == null) {
            return;
        }
        try {
            mCamera.cancelAutoFocus();
        } catch (final RuntimeException e) {
            // Ignore
            LogUtil.e(TAG, "RuntimeException in CameraManager.cancelAutoFocus", e);
        }
    }

    @Override // From FocusOverlayManager.Listener
    public boolean capture() {
        return false;
    }

    @Override // From FocusOverlayManager.Listener
    public void setFocusParameters() {
        if (mCamera == null) {
            return;
        }
        try {
            final Camera.Parameters parameters = mCamera.getParameters();
            parameters.setFocusMode(mFocusOverlayManager.getFocusMode());
            if (parameters.getMaxNumFocusAreas() > 0) {
                // Don't set focus areas (even to null) if focus areas aren't supported, camera may
                // crash
                parameters.setFocusAreas(mFocusOverlayManager.getFocusAreas());
            }
            parameters.setMeteringAreas(mFocusOverlayManager.getMeteringAreas());
            mCamera.setParameters(parameters);
        } catch (final RuntimeException e) {
            // This occurs when the device is out of space or when the camera is locked
            LogUtil.e(TAG, "RuntimeException in CameraManager setFocusParameters");
        }
    }

    private void logCameraSize(final String prefix, final Camera.Size size) {
        // Log the camera size and aspect ratio for help when examining bug reports for camera
        // failures
        LogUtil.i(TAG, prefix + size.width + "x" + size.height +
                " (" + (size.width / (float) size.height) + ")");
    }


    private Integer mSavedOrientation = null;

    private void lockOrientation() {
        // when we start recording, lock our orientation
        final Activity a = UiUtils.getActivity(mCameraPreview.getContext());
        final WindowManager windowManager =
                (WindowManager) a.getSystemService(Context.WINDOW_SERVICE);
        final int rotation = windowManager.getDefaultDisplay().getRotation();

        mSavedOrientation = a.getRequestedOrientation();
        switch (rotation) {
            case Surface.ROTATION_0:
                a.setRequestedOrientation(
                        ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
                break;
            case Surface.ROTATION_90:
                a.setRequestedOrientation(
                        ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);
                break;
            case Surface.ROTATION_180:
                a.setRequestedOrientation(
                        ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT);
                break;
            case Surface.ROTATION_270:
                a.setRequestedOrientation(
                        ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE);
                break;
        }

    }

    private void restoreRequestedOrientation() {
        if (mSavedOrientation != null) {
            final Activity a = UiUtils.getActivity(mCameraPreview.getContext());
            if (a != null) {
                a.setRequestedOrientation(mSavedOrientation);
            }
            mSavedOrientation = null;
        }
    }

    static boolean hasCameraPermission() {
        return OsUtil.hasPermission(Manifest.permission.CAMERA);
    }
}
