package net.heartshield.sensors;

import android.Manifest;
import android.app.*;
import android.content.Context;
import android.content.DialogInterface;
import android.content.pm.PackageManager;
import android.graphics.ImageFormat;
import android.graphics.SurfaceTexture;
import android.hardware.camera2.*;
import android.hardware.camera2.params.StreamConfigurationMap;
import android.media.Image;
import android.media.ImageReader;
import android.os.Bundle;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Looper;
import android.support.v4.app.ActivityCompat;
import android.util.Log;
import android.util.Range;
import android.util.Size;
import android.view.Surface;
import android.view.TextureView;
import android.widget.Toast;

import net.heartshield.control.ReleaseConfig;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.util.*;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

import net.heartshield.prevent.BuildConfig;
import net.heartshield.prevent.R;

/**
 * @author David Madl (git@abanbytes.eu)
 * @date 2017-02-27
 */
public class Camera2Detector implements IntensityDetector {
    private static final String TAG = "Camera2Detector";

    private IntensityListener mIntensityListener;

    private boolean mDebugMode = false;
    private Activity mActivity;
    private CameraDevice mCameraDevice;
    private CameraCaptureSession mPreviewSession;
    private ImageReader mImageReader;
    private TextureView mCameraPreview;
    //private SurfaceTexture mTexture;
    private boolean mStickyUiThread; /* stick to UI thread, to work around OpenGL rendering quirks in kivy */
    private SensorData mData;
    private List<Double> mLocked;

    private State mState = State.STOPPED;

    private static final int NDIM = 4; // RGB channels, ae locked

    public Camera2Detector(Activity activity, TextureView cameraPreview, int width, int height, boolean stickyUiThread) {
        mActivity = activity;
        mCameraPreview = cameraPreview;
        mSize = new Size(width, height);
        mStickyUiThread = stickyUiThread;
        mData = new SensorData(NDIM);
        mLocked = new ArrayList<>();
    }

    /** must be called before start() */
    /*public void setSurfaceTexture(SurfaceTexture texture) {
        mTexture = texture;
    }*/

    /**
     * Start camera capture.
     *
     * Internal summary:
     * * {@link #start()} -> {@link #openCamera()}  // or via mSurfaceTextureListener
     * * {@link #openCamera()} -> manager.openCamera(cameraId, mStateCallback, mBackgroundHandler);
     * * CD.StateCallback.onOpened() -> {@link #startPreview()} -> mCameraDevice.createCaptureRequest()
     * * mCameraDevice.createCaptureSession(surfaces, CCS.StateCallback)
     * * CCS.StateCallback.onConfigured() -> {@link #updatePreview()} -> mPreviewSession.setRepeatingRequest()
     *
     * If necessary, this call will wait until the TextureView {@link #mCameraPreview} is available
     */
    @Override
    public void start() throws IllegalStateException {
        if(mState != State.STOPPED && mState != State.FAILED)
            throw new IllegalStateException("Tried to start Camera in invalid state: " + mState.toString());

        if(!hasPermissionsGranted(VIDEO_PERMISSIONS)) {
            // request permissions externally instead!
            Log.e(TAG, "no CAMERA permission granted");
            setState(State.FAILED);
            return;
        }

        Log.i(TAG, "start()");
        //setState(State.STARTING);
        mData.clear();
        mLocked.clear();
        mCameraMeta = new JSONObject();
        mCameraTimingStats = new JSONArray();
        startBackgroundThread();

        /*
         * from TextureView.java:
         * "It is important to know that a SurfaceTexture is available only after the
         * TextureView is attached to a window (and {@link #onAttachedToWindow()} has
         * been invoked.)"
         */
        if(mCameraPreview == null || mCameraPreview.isAvailable()) {
            openCamera();
        } else {
            mCameraPreview.setSurfaceTextureListener(mSurfaceTextureListener);
        }
    }

    @Override
    public void stop() {
        if(mState == State.WAITING || mState == State.LOCKED) {
            // note: I believe threading execution of the two may cause these to be off by ~ 1
            Log.i(TAG, "stop(): mImageAvailableI=" + mImageAvailableI + " and mCaptureResultI=" + mCaptureResultI);
        }

        try {
            mCameraMeta.put("mImageAvailableI", mImageAvailableI);
            mCameraMeta.put("mCaptureResultI", mCaptureResultI);
            //Log.i(TAG, "mCameraMeta=" + mCameraMeta.toString(2));

            mCameraMeta.put("mCameraTimingStats", mCameraTimingStats);

            System.out.println(mCameraMeta.toString(2));
        } catch (JSONException e) {}

        // note: emits State.STOPPED every time, even if camera was already stopped.
        Log.i(TAG, "stop()");
        stopBackgroundThread();
        closeCamera();
        setState(State.STOPPED);
        // setState() currently avoids emitting double STOPPED for now. For why we may call setState() twice, see onDisconnected().
        // (needed this, because if screen goes to sleep over some time, our Activity gets paused but apparently onDisconnected() didn't happen)
        // (then, subsequent start() failed due to wrong state...)
        // (also, evaluate an Android alarm jumping over our app)
    }

    @Override
    public State getState() {
        return mState;
    }

    @Override
    public int getDroppedCount() {
        return mImageQueueDropped.get();
    }

    private void setState(State newState) {
        Log.i(TAG, "setState(" + newState.toString() + ")");
        State prevState = mState;
        mState = newState;
        if(mIntensityListener != null && prevState != newState)
            mIntensityListener.onState(newState);
    }

    @Override
    public void lock() throws IllegalStateException {
        if(mState != State.WAITING)
            throw new IllegalStateException("Tried to lock Camera before it was ready, in invalid state: " + mState.toString());

        if(mSummaryBeforeLock != null) {
            try {
                mCameraMeta.put("mSummaryBeforeLock", mSummaryBeforeLock.serialize());
            } catch (JSONException e) {
                e.printStackTrace();
            }
        }

        mImageQueueDropped.set(0);
        setA3Locks(true);
        setState(State.LOCKED);
    }

    @Override
    public void unlock() throws IllegalStateException {
        if(mState != State.LOCKED && mState != State.WAITING)
            throw new IllegalStateException("Tried to unlock Camera before it was ready, in invalid state: " + mState.toString());
        setA3Locks(false);
        setState(State.WAITING);
    }

    @Override
    public void setIntensityListener(IntensityListener listener) {
        mIntensityListener = listener;
    }


    private static final String FRAGMENT_DIALOG = "dialog";

    private static final String[] VIDEO_PERMISSIONS = {
            Manifest.permission.CAMERA
    };

    /**
     * A {@link Semaphore} to prevent the app from exiting before closing the camera.
     */
    private Semaphore mCameraOpenCloseLock = new Semaphore(1);

    private StreamConfigurationMap mConfigMap;
    private CameraCharacteristics mCharacteristics;

    private Size mOptimalPreviewSize;

    public Size getOptimalPreviewSize() {
        final Activity activity = mActivity;

        if (null == activity || activity.isFinishing()) {
            Log.i(TAG, "activity finishing, ignoring openCamera() call");
            return new Size(0,0);
        }

        CameraManager manager = (CameraManager) activity.getSystemService(Context.CAMERA_SERVICE);
        try {
            String cameraId = manager.getCameraIdList()[0];

            // Choose the sizes for camera preview and video recording
            mCharacteristics = manager.getCameraCharacteristics(cameraId);
            mConfigMap = mCharacteristics
                    .get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP);

            mOptimalPreviewSize = chooseOptimalSize(mConfigMap.getOutputSizes(ImageFormat.YUV_420_888), mSize.getWidth(), mSize.getHeight(), mSize);

            return mOptimalPreviewSize;

        } catch (CameraAccessException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * Time series of each dimension: e.g. {{t1, t2, ...}, {x1, x2, ...}}
     * Deep copies are returned.
     *
     * @return sensor data recorded since {@link IntensityDetector#start} was called
     */
    @Override
    public List<double[]> getSamples() {
        //return mData.getSamples();
        List<double[]> data = mData.getSamples();
        int dlen = data.size();
        int llen = mLocked.size();
        /*
        // data gets lost. why?
        for(int i = 0; i < dlen; i++)
            data.get(i)[NDIM] = (i < llen) ? mLocked.get(i) : 0; // (i < llen): be lenient with onImageAvailable() vs onCaptureCompleted() call count
        */

        // attempt workaround: deep copy
        final int a = data.get(0).length;
        final int b = data.size();
        List<double[]> result = new ArrayList<>();

        for(int i = 0; i < b-1; i++) {
            double[] entry = new double[a];
            System.arraycopy(data.get(i), 0, entry, 0, a);
            result.add(entry);
        }

        assert data.size() == NDIM+1; // +1 because of implicit [0] being the timestamp
        double[] log = new double[a];
        for(int j = 0; j < a && j < llen; j++)
            log[j] = mLocked.get(j);
        result.add(log);

        return result;
    }

    private JSONObject mFlatCharacteristics = null;

    public JSONObject getCharacteristics() {
        return mFlatCharacteristics;
    }

    /**
     * Tries to open a {@link CameraDevice}. The result is listened by `mStateCallback`.
     */
    private void openCamera() {
        Log.i(TAG, "openCamera()");

        final Activity activity = mActivity;
        if (null == activity || activity.isFinishing()) {
            Log.i(TAG, "activity finishing, ignoring openCamera() call");
            return;
        }
        CameraManager manager = (CameraManager) activity.getSystemService(Context.CAMERA_SERVICE);
        try {
            Log.d(TAG, "tryAcquire");
            if (!mCameraOpenCloseLock.tryAcquire(2500, TimeUnit.MILLISECONDS)) {
                //throw new RuntimeException("Time out waiting to lock camera opening.");
                Log.e(TAG, "Time out waiting to lock camera opening.");
                setState(State.FAILED);
            }
            String cameraId = manager.getCameraIdList()[0];

            // Choose the sizes for camera preview and video recording
            mCharacteristics = manager.getCameraCharacteristics(cameraId);
            mConfigMap = mCharacteristics
                    .get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP);

            mFlatCharacteristics = getFlattenedCharacteristics(mCharacteristics);
            Log.i(TAG, mFlatCharacteristics.toString());
            try {
                mCameraMeta.put("cameraCharacteristics", getFlattenedCharacteristics(mCharacteristics));
                mCameraMeta.put("gitHash", BuildConfig.GIT_HASH);
            } catch (JSONException e) {}

            mOptimalPreviewSize = chooseOptimalSize(mConfigMap.getOutputSizes(ImageFormat.YUV_420_888), mSize.getWidth(), mSize.getHeight(), mSize);

            Log.i(TAG, "chose preview size: " + mOptimalPreviewSize.getWidth() + "x" + mOptimalPreviewSize.getHeight());

            //mSensorOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION);
            //mVideoSize = chooseVideoSize(map.getOutputSizes(MediaRecorder.class));
            //mPreviewSize = new Size(1280, 720);//chooseOptimalSize(map.getOutputSizes(SurfaceTexture.class), width, height, mVideoSize);

            /*
            //int orientation = getResources().getConfiguration().orientation;
            int orientation = Configuration.ORIENTATION_PORTRAIT;

            if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
                mTextureView.setAspectRatio(mPreviewSize.getWidth(), mPreviewSize.getHeight());
            } else {
                mTextureView.setAspectRatio(mPreviewSize.getHeight(), mPreviewSize.getWidth());
            }
            configureTransform(width, height);
            */

            manager.openCamera(cameraId, mStateCallback, mBackgroundHandler);
            // this callback is independent from the rest, but for kivy there is no default Handler, so we need to pass something.

        } catch (SecurityException e) {
            Toast.makeText(activity, "Cannot access the camera.", Toast.LENGTH_SHORT).show();
            setState(State.FAILED);
        } catch (CameraAccessException e) {
            Toast.makeText(activity, "Cannot access the camera.", Toast.LENGTH_SHORT).show();
            setState(State.FAILED);
        } catch (NullPointerException e) {
            Log.e(TAG, "Got NullPointerException while trying to access camera.");
            // Currently an NPE is thrown when the Camera2API is used but not supported on the
            // device this code runs.
            ErrorDialog.newInstance(getString(R.string.camera_error)) // getString(R.string.camera_error)
                    .show(mActivity.getFragmentManager(), FRAGMENT_DIALOG);
            // was: getChildFragmentManager()
            setState(State.FAILED);
        } catch (InterruptedException e) {
            //throw new RuntimeException("Interrupted while trying to lock camera opening.");
            Log.e(TAG, "Interrupted while trying to lock camera opening.");
            setState(State.FAILED);
        }
    }

    private void closeCamera() {
        try {
            mCameraOpenCloseLock.acquire();
            closePreviewSession();
            if (null != mCameraDevice) {
                mCameraDevice.close();
                mCameraDevice = null;
            } else {
                setState(State.STOPPED); // no onDisconnected() will happen, but we still send the onState()
            }
        } catch (InterruptedException e) {
            throw new RuntimeException("Interrupted while trying to lock camera closing.");
        } finally {
            mCameraOpenCloseLock.release();
        }
    }

    private CameraDevice.StateCallback mStateCallback = new CameraDevice.StateCallback() {

        @Override
        public void onOpened(CameraDevice cameraDevice) {
            mCameraDevice = cameraDevice;
            startPreview();
            mCameraOpenCloseLock.release();
            /*
            if (null != mTextureView) {
                configureTransform(mTextureView.getWidth(), mTextureView.getHeight());
            }
            */
        }

        @Override
        public void onDisconnected(CameraDevice cameraDevice) {
            mCameraOpenCloseLock.release();
            cameraDevice.close();
            mCameraDevice = null;

            Log.i(TAG, "CameraDevice.StateCallback onDisconnected()");

            setState(State.STOPPED);
        }

        @Override
        public void onError(CameraDevice cameraDevice, int error) {
            stopBackgroundThread();

            mCameraOpenCloseLock.release();
            cameraDevice.close();
            mCameraDevice = null;

            Log.e(TAG, "CameraDevice.StateCallback onError(error=" + Integer.toString(error) + ")");

            setState(State.FAILED);
        }

    };

    private void closePreviewSession() {
        if (mPreviewSession != null) {
            mPreviewSession.close();
            mPreviewSession = null;
        }
    }

    private static class FrameNotificationHandler implements Runnable {
        private IntensityListener mIntensityListener;
        private long mTimestamp;
        private long mCpuTimeNanos;
        private double[] mRgb;
        private ImageSummary mSummary;

        public FrameNotificationHandler(IntensityListener intensityListener, long timestampNanos, long cpuTimeNanos, double[] rgb, ImageSummary summary) {
            mIntensityListener = intensityListener;
            mTimestamp = timestampNanos;
            mCpuTimeNanos = cpuTimeNanos;
            mRgb = rgb;
            mSummary = summary;
        }

        @Override
        public void run() {
            if(mIntensityListener != null)
                mIntensityListener.onFrame(mTimestamp, mCpuTimeNanos, mRgb, mSummary);
        }
    }

    private static class ImageHandlingStats {
        long prev = 0;
        long prevTime = 0;
        long prevCount = 0;
        long onesecDeltas = 0;
        long fps = 0;
        long avgDelta = 0;
    }
    private final ImageHandlingStats mStats = new ImageHandlingStats();
    private JSONArray mCameraTimingStats = new JSONArray();
    private ImageSummary mSummaryBeforeLock = null;

    private class PreviewImageHandler implements Runnable {
        private final Image mImage;
        private final long mCpuTimeNanos;

        public PreviewImageHandler(Image image, long cpuTimeNanos) {
            mImage = image;
            mCpuTimeNanos = cpuTimeNanos;
        }

        @Override
        public void run() {
            long tnow = System.currentTimeMillis();

            if(mStats.prevTime == 0)
                mStats.prevTime = System.currentTimeMillis();

            boolean secTick = false;
            if(tnow - mStats.prevTime > 1000) {
                mStats.fps = mStats.prevCount;
                mStats.avgDelta = mStats.onesecDeltas / mStats.prevCount;
                mStats.prevCount = 0;
                mStats.prevTime += 1000;
                secTick = true;
            }
            mStats.prevCount += 1;

            /*
            ByteBuffer buffer = mImage.getPlanes()[0].getBuffer();
            byte[] bytes = new byte[buffer.remaining()];
            buffer.get(bytes);
            */

            Image.Plane Y = mImage.getPlanes()[0];
            Image.Plane U = mImage.getPlanes()[1];
            Image.Plane V = mImage.getPlanes()[2];

            int Yb = Y.getBuffer().remaining();
            int Ub = U.getBuffer().remaining();
            int Vb = V.getBuffer().remaining();

            long ts;
            double[] mean_rgb = new double[3];

            ImageSummary summary = null;

            //byte[] data = new byte[Yb + Ub + Vb];
            //avoid allocator churn, by allocating this in advance and recycling it.
            synchronized(mBufferLock) {
                byte[] data = mBufferYUV;

                long t1 = System.nanoTime();
                Y.getBuffer().get(data, 0, Yb);
                U.getBuffer().get(data, Yb, Ub);
                V.getBuffer().get(data, Yb + Ub, Vb);

                ts = mImage.getTimestamp();
                int w = mImage.getWidth(), h = mImage.getHeight();

                mImage.close();
                mImageQueueDepth.decrementAndGet();

                //int[] argb = new int[w*h]; // see mBufferARGB
                long t2 = System.nanoTime();
                CameraUtil.decodeYUV420SP(mBufferARGB, mean_rgb, data, w, h);
                long t3 = System.nanoTime();

                if(mState == State.WAITING && ReleaseConfig.CAMERA2_SUMMARIZE) {
                    // only compute these while waiting for user's finger to settle (save some CPU otherwise)
                    summary = new ImageSummary();
                    summary.meanRow = CameraUtil.meanRow(mBufferARGB, w, h);
                    summary.meanCol = CameraUtil.meanCol(mBufferARGB, w, h);
                    summary.stdRow = CameraUtil.stdRow(mBufferARGB, summary.meanRow, w, h);
                    summary.stdCol = CameraUtil.stdCol(mBufferARGB, summary.meanCol, w, h);
                    summary.fractionRed = CameraUtil.fractionRed(mBufferARGB, w, h);
                    mSummaryBeforeLock = summary;
                }
                long t4 = System.nanoTime();

                long t = ts / 1000L;

                // TODO: if decodeYUV420SP > 40000 us for longer time, then your device is too slow.
                if(ReleaseConfig.CAMERA2_DEBUG_PERFORMANCE && secTick) {
                    Log.i(TAG, String.format("ImageSaver image: fps = %d  avgDelta = %d  delta_time (us) = %d  buffer_copy = %d  decodeYUV420SP = %d  ImageSummary = %d", mStats.fps, mStats.avgDelta, t - mStats.prev, (t2 - t1) / 1000L, (t3 - t2) / 1000L, (t4 - t3) / 1000L));
                    // fps avgDelta delta_time buffer_copy decodeYUV420SP ImageSummary
                    JSONArray timingRow = new JSONArray(Arrays.asList(new double[]{mStats.fps, mStats.avgDelta, t - mStats.prev, (t2 - t1) / 1000., (t3 - t2) / 1000., (t4 - t3) / 1000.}));
                    mCameraTimingStats.put(timingRow);
                }
                mStats.onesecDeltas += t - mStats.prev;
                mStats.prev = t;
            }

            //mIntensityListener.onFrame(ts, mean_rgb);
            // notify via a separate message queue / thread, so we can continue doing the legwork without queueing up expensive Images
            mNotifierHandler.post(new FrameNotificationHandler(mIntensityListener, ts, mCpuTimeNanos, mean_rgb, summary));

            mData.addSample(new double[]{ts / 1e9, mean_rgb[0], mean_rgb[1], mean_rgb[2], 0.0});
        }
    }


    static class CompareSizesByArea implements Comparator<Size> {
        @Override
        public int compare(Size lhs, Size rhs) {
            // We cast here to ensure the multiplications won't overflow
            return Long.signum((long) lhs.getWidth() * lhs.getHeight() -
                    (long) rhs.getWidth() * rhs.getHeight());
        }
    }
    /**
     * Given {@code choices} of {@code Size}s supported by a camera, chooses the smallest one whose
     * width and height are at least as large as the respective requested values, and whose aspect
     * ratio matches with the specified value.
     *
     * @param choices     The list of sizes that the camera supports for the intended output class
     * @param width       The minimum desired width
     * @param height      The minimum desired height
     * @param aspectRatio The aspect ratio
     * @return The optimal {@code Size}, or an arbitrary one if none were big enough
     */
    private static Size chooseOptimalSize(Size[] choices, int width, int height, Size aspectRatio) {
        // Collect the supported resolutions that are at least as big as the preview Surface
        List<Size> bigEnough = new ArrayList<Size>();
        int w = aspectRatio.getWidth();
        int h = aspectRatio.getHeight();
        for (Size option : choices) {
            if (option.getHeight() == option.getWidth() * h / w &&
                    option.getWidth() >= width && option.getHeight() >= height) {
                bigEnough.add(option);
            }
        }

        // Pick the smallest of those, assuming we found any
        if (bigEnough.size() > 0) {
            return Collections.min(bigEnough, new CompareSizesByArea());
        } else {
            Log.e(TAG, "Couldn't find any suitable preview size");
            return choices[0];
        }
    }

    private AtomicInteger mImageQueueDepth = new AtomicInteger(0);
    private AtomicInteger mImageQueueDropped = new AtomicInteger(0);
    private static final int QUEUE_DEPTH = 5;

    private int mImageAvailableI = 0;

    private final ImageReader.OnImageAvailableListener mOnImageAvailableListener
            = new ImageReader.OnImageAvailableListener() {

        @Override
        public void onImageAvailable(ImageReader reader) {
            final long cpuTimeNanos = System.nanoTime(); // rough timebase

            mImageAvailableI++;

            // TODO: use a thread pool instead of spawning these on-the-fly. Then we can avoid running out of Images, too.

            // note: there is only one thread for the background handler, all PreviewImageHandler.run() is executed there.
            // However, the message queue itself hangs onto Image references, and may exhaust them if the processing rate is not fast enough.

            if(mImageQueueDepth.incrementAndGet() >= QUEUE_DEPTH) {
                // relieve some pressure on the queue by dropping frames
                Log.i(TAG, "onImageAvailable(): queue filling up, dropping frame");
                reader.acquireNextImage().close();
                mImageQueueDropped.incrementAndGet();
                mImageQueueDepth.decrementAndGet();
                return;
            }

            // TODO: if stopBackgroundThread() was called, this may be null. Locking? Better solution?
            // TODO: to reproduce, remove this code, and provoke the FailActivity (through putting device on a desk == noise)
            if(mBackgroundHandler != null)
                mBackgroundHandler.post(new PreviewImageHandler(reader.acquireNextImage(), cpuTimeNanos));
        }

    };

    private Size mSize; // the size wished for, not the actual preview size


    private int mFps;
    private Surface mPreviewSurface = null;

    /**
     * key 'cameraCharacteristics'
     * key 'lockedCaptureRequest'
     * key 'unlockedCaptureRequest'
     */
    private JSONObject mCameraMeta = new JSONObject();

    /** call after stop() */
    public JSONObject getCameraMeta() {
        return mCameraMeta;
    }

    private CaptureRequest.Builder makePreviewBuilder(boolean a3locks) throws CameraAccessException {
        CaptureRequest.Builder builder;

        builder = mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW); // TEMPLATE_MANUAL(not always supported),TEMPLATE_RECORD
        if(mPreviewSurface != null)
            builder.addTarget(mPreviewSurface);
        builder.addTarget(mImageReader.getSurface());

        setUpCaptureRequestBuilder(builder);

        setUpNiceTo(builder, a3locks);

        CaptureRequest req = builder.build();
        Log.i(TAG, getFlattenedRequest(req).toString());

        try {
            mCameraMeta.put(a3locks ? "lockedCaptureRequest" : "unlockedCaptureRequest", getFlattenedRequest(req));
        } catch (JSONException e) {}

        // see setUpNiceTo(..)
        //builder.set(CaptureRequest.CONTROL_AE_LOCK, a3locks);

        return builder;
    }

    private void setUpNiceTo(CaptureRequest.Builder builder, boolean a3locks) {
        builder.set(CaptureRequest.EDGE_MODE,
                CaptureRequest.EDGE_MODE_OFF);
        builder.set(
                CaptureRequest.LENS_OPTICAL_STABILIZATION_MODE,
                CaptureRequest.LENS_OPTICAL_STABILIZATION_MODE_OFF);

        builder.set(
                CaptureRequest.COLOR_CORRECTION_ABERRATION_MODE,
                CaptureRequest.COLOR_CORRECTION_ABERRATION_MODE_OFF);
        builder.set(CaptureRequest.NOISE_REDUCTION_MODE,
                CaptureRequest.NOISE_REDUCTION_MODE_OFF);
        builder.set(CaptureRequest.CONTROL_AF_TRIGGER,
                CaptureRequest.CONTROL_AF_TRIGGER_CANCEL);
        builder.set(CaptureRequest.EDGE_MODE,
                CaptureRequest.EDGE_MODE_OFF);

        //builder.set(CaptureRequest.SENSOR_EXPOSURE_TIME, _exp_time);
        // just use the preset from the template for this one. Don't know how this is calculated.
        // (should pick one from CameraCharacteristics.SENSOR_INFO_EXPOSURE_TIME_RANGE)

        builder.set(CaptureRequest.CONTROL_AF_MODE, CameraMetadata.CONTROL_AF_MODE_OFF);
        builder.set(CaptureRequest.LENS_FOCUS_DISTANCE, 0.0f);

        builder.set(CaptureRequest.CONTROL_AWB_MODE, CameraMetadata.CONTROL_AWB_MODE_AUTO);


        builder.set(CaptureRequest.CONTROL_AE_MODE, a3locks ? CameraMetadata.CONTROL_AE_MODE_OFF : CameraMetadata.CONTROL_AE_MODE_ON);
        builder.set(CaptureRequest.CONTROL_MODE, a3locks ? CameraMetadata.CONTROL_MODE_OFF_KEEP_STATE : CameraMetadata.CONTROL_MODE_AUTO); // not sure
        builder.set(CaptureRequest.BLACK_LEVEL_LOCK, a3locks);
        builder.set(CaptureRequest.CONTROL_AE_LOCK, a3locks);
        builder.set(CaptureRequest.CONTROL_AWB_LOCK, a3locks);

        /*
        // try purely manual camera control.
        builder.set(CaptureRequest.CONTROL_AE_MODE, CameraMetadata.CONTROL_AE_MODE_OFF);
        builder.set(CaptureRequest.CONTROL_MODE, CameraMetadata.CONTROL_MODE_OFF);
        builder.set(CaptureRequest.BLACK_LEVEL_LOCK, true);
        builder.set(CaptureRequest.CONTROL_AE_LOCK, true);
        builder.set(CaptureRequest.CONTROL_AWB_MODE, CameraMetadata.CONTROL_AWB_MODE_OFF);
        builder.set(CaptureRequest.CONTROL_AWB_LOCK, true);

        builder.set(CaptureRequest.COLOR_CORRECTION_MODE, CameraMetadata.COLOR_CORRECTION_MODE_TRANSFORM_MATRIX);
        builder.set(CaptureRequest.COLOR_CORRECTION_GAINS, new RggbChannelVector(1.0f, 1.0f, 1.0f, 1.0f));
        builder.set(CaptureRequest.COLOR_CORRECTION_TRANSFORM, new ColorSpaceTransform(new int[]{
                1, 1,  0, 1,  0, 1,
                0, 1,  1, 1,  0, 1,
                0, 1,  0, 1,  1, 1
        }));

        // instead of auto-exposure, the alternative is to hard-code an ISO level and EV (exposure value):
        builder.set(CaptureRequest.SENSOR_SENSITIVITY, 400); // ISO level
        //builder.set(CaptureRequest.CONTROL_AE_EXPOSURE_COMPENSATION, 0); // exposure value (EV) * EV step  ... ~>  brightness * 2**EV
        builder.set(CaptureRequest.CONTROL_AE_EXPOSURE_COMPENSATION, 12);
        */
    }

    private void startPreview() {
        //if (null == mCameraDevice || !mTextureView.isAvailable() || null == mPreviewSize) {
        if(mCameraDevice == null) {
            Log.e(TAG, "startPreview(): mCameraDevice == null");
            setState(State.FAILED);
            return;
        }
        try {
            mImageQueueDropped.set(0);
            closePreviewSession();

            Size req = mSize;
            Size optimalPreview = mOptimalPreviewSize;
            mSize = optimalPreview;
            int w = optimalPreview.getWidth(), h = optimalPreview.getHeight();
            mImageReader = ImageReader.newInstance(w, h,   //largest.getWidth(), largest.getHeight(),
                    ImageFormat.YUV_420_888, /*maxImages*/ QUEUE_DEPTH); // RGB_888 FLEX_RGB_888 YUV_420_888
            mImageReader.setOnImageAvailableListener(
                    mOnImageAvailableListener, mBackgroundHandler);
            mImageAvailableI = 0;

            // allocate buffers for processing in onImageAvailable()
            synchronized(mBufferLock) {
                mBufferYUV = new byte[w*h*2];
                mBufferARGB = new int[w*h];
            }

            List<Surface> surfaces = new ArrayList<Surface>();

            if(mCameraPreview != null) {
                SurfaceTexture texture = mCameraPreview.getSurfaceTexture();
                texture.setDefaultBufferSize(w, h);
                mPreviewSurface = new Surface(texture);
                //mPreviewBuilder.addTarget(mPreviewSurface);
                surfaces.add(mPreviewSurface);
            } else {
                mPreviewSurface = null;
            }

            // nb. Camera.Parameters.setPreviewFormat(ImageFormat.RGB_565);
            // but not all camera devices may support that./ maybe we should set YUV_420_888 as preview format, instead of assuming it implicitly
            // TODO: use parameters.getPreviewFormat()

            //mPreviewBuilder.addTarget(mImageReader.getSurface());
            surfaces.add(mImageReader.getSurface());

            //mCameraDevice.createConstrainedHighSpeedCaptureSession(Arrays.asList(previewSurface, mImageReader.getSurface()), new CameraCaptureSession.StateCallback() {
            mCameraDevice.createCaptureSession(surfaces, new CameraCaptureSession.StateCallback() {

                @Override
                public void onConfigured(CameraCaptureSession cameraCaptureSession) {
                    //mPreviewSession = (CameraConstrainedHighSpeedCaptureSession) cameraCaptureSession;
                    mPreviewSession = cameraCaptureSession;
                    updatePreview();
                }

                @Override
                public void onConfigureFailed(CameraCaptureSession cameraCaptureSession) {
                    Activity activity = mActivity;
                    if (null != activity) {
                        Toast.makeText(activity, "Failed", Toast.LENGTH_SHORT).show();
                    }

                    mBufferYUV = null;
                    mBufferARGB = null;

                    Log.e(TAG, "CameraCaptureSession.StateCallback onConfigureFailed()");
                    setState(State.FAILED);
                }
            }, mBackgroundHandler);
        } catch (CameraAccessException e) {
            e.printStackTrace();
            Log.e(TAG, "startPreview(): CameraAccessException");
            setState(State.FAILED);
        }
    }

    private final TextureView.SurfaceTextureListener mSurfaceTextureListener
            = new TextureView.SurfaceTextureListener() {

        @Override
        public void onSurfaceTextureAvailable(SurfaceTexture texture, int width, int height) {
            openCamera();
        }

        @Override
        public void onSurfaceTextureSizeChanged(SurfaceTexture texture, int width, int height) {
            //configureTransform(width, height);
            // TODO: is this necessary? do we see the entire camera image, or just a part?
        }

        @Override
        public boolean onSurfaceTextureDestroyed(SurfaceTexture texture) {
            return true;
        }

        @Override
        public void onSurfaceTextureUpdated(SurfaceTexture texture) {
        }

    };

    @Override
    public int getFps() { return mFps; }

    private void setUpCaptureRequestBuilder(CaptureRequest.Builder builder) {
        //builder.set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, new Range<Integer>(120, 120));

        {
            Range<Integer>[] availableFpsRange = mCharacteristics.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES);

            // Pick FPS range with highest max value, tiebreak on higher min value
            Range<Integer> bestRange = availableFpsRange[0];
            for (Range<Integer> r : availableFpsRange) {
                if (bestRange.getUpper() < r.getUpper()) {
                    bestRange = r;
                } else if (bestRange.getUpper().equals(r.getUpper()) &&
                        bestRange.getLower() < r.getLower()) {
                    bestRange = r;
                }
            }
            builder.set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, bestRange);
            mFps = bestRange.getUpper();
        }

        // always turn on flash...
        if(mCharacteristics.get(CameraCharacteristics.FLASH_INFO_AVAILABLE))
            builder.set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_TORCH);
    }

    /*
    private void setFlash(boolean flash) {
        if(mCharacteristics == null || mPreviewBuilder == null)
            return;

        if(mCharacteristics.get(CameraCharacteristics.FLASH_INFO_AVAILABLE))
            mPreviewBuilder.set(CaptureRequest.FLASH_MODE, flash ? CaptureRequest.FLASH_MODE_TORCH : CaptureRequest.FLASH_MODE_OFF);
        updatePreviewSettings(mPreviewBuilder);
    }
    */

    private static boolean contains(int[] a, int val) {
        for(int i = 0; i < a.length; i++)
            if(a[i] == val)
                return true;
        return false;
    }

    /*

    from Android's CameraCharacteristics.java

$ grep -o 'Key<[^>]*>' | sort | uniq

Key<?>
Key<android.graphics.Rect>
Key<android.graphics.Rect[]>
Key<android.hardware.camera2.params.BlackLevelPattern>
Key<android.hardware.camera2.params.ColorSpaceTransform>
Key<android.hardware.camera2.params.HighSpeedVideoConfiguration[]>
Key<android.hardware.camera2.params.ReprocessFormatsMap>
Key<android.hardware.camera2.params.StreamConfiguration[]>
Key<android.hardware.camera2.params.StreamConfigurationDuration[]>
Key<android.hardware.camera2.params.StreamConfigurationMap>
Key<android.util.Range<Integer>
Key<android.util.Range<Long>
Key<android.util.Size>
Key<android.util.Size[]>
Key<android.util.SizeF>
Key<boolean[]>
Key<Boolean>
Key<Byte>
Key<float[]>
Key<Float>
Key<int[]>
Key<Integer>
Key<long[]>
Key<Long>
Key<Rational>
Key<T>

a bit too much to stringify...

     */

    /*
    private String getKeys() {
        List<CameraCharacteristics.Key<?>> keys = mCharacteristics.getKeys();
        for(CameraCharacteristics.Key<?> key : keys) {
            String k = key.getName();
            String v = mCharacteristics.get(key).toString();
        }
        return "";
    }
    */

    private static Object[] objectify(Object obj) {
        if(obj == null)
            return null;
        Object[] o = null;
        if(obj.getClass().isArray()) {
            // array
            if(obj instanceof Object[]) {
                // array of Object
                Object[] oI = (Object[])obj;
                Object[] oO = new Object[oI.length];
                for(int i = 0; i < oI.length; i++) {
                    // objectify elements
                    //oO[i] = objectify(oI[i]);
                    oO[i] = oI[i].getClass().isArray() ? objectify(oI[i]) : oI[i];
                }
                //o = Arrays.asList(oO);
                o = oO;
            } else {
                // array of primitive
                int len = Array.getLength(obj);
                Object[] out = new Object[len];
                for(int i = 0; i < len; i++)
                    out[i] = Array.get(obj, i);
                //o = Arrays.asList(out);
                o = out;
            }
        }
        return o;
    }

    private String getArrayRep(Object val) {
        Object[] arr = objectify(val);

        StringBuilder sb = new StringBuilder();
        sb.append("{");
        for(int i = 0; i < arr.length; i++) {
            if(i != 0)
                sb.append(", ");
            sb.append(getStringRep(arr[i]));
        }
        sb.append("}");
        return sb.toString();
    }

    private String getStringRep(Object val) {
        if(val == null)
            return "null";

        if(val.getClass().isArray())
            return getArrayRep(val);

        return val.toString();

        /*
        if(val instanceof android.graphics.Rect)
            return ((android.graphics.Rect) val).toShortString();
        else if(val instanceof android.hardware.camera2.params.BlackLevelPattern)
            return ((android.hardware.camera2.params.BlackLevelPattern) val).toString();
        else if(val instanceof android.hardware.camera2.params.ColorSpaceTransform)
            return ((android.hardware.camera2.params.ColorSpaceTransform) val).toString();
        //else if(val instanceof android.hardware.camera2.params.HighSpeedVideoConfiguration)
        //else if(val instanceof android.hardware.camera2.params.ReprocessFormatsMap)
        //else if(val instanceof android.hardware.camera2.params.StreamConfiguration)
        //else if(val instanceof android.hardware.camera2.params.StreamConfigurationDuration)
        else if(val instanceof android.hardware.camera2.params.StreamConfigurationMap)
            return ((android.hardware.camera2.params.StreamConfigurationMap) val).toString();
        else if(val instanceof android.util.Range<?>)
        */
    }

    private JSONObject getFlattenedCharacteristics(CameraCharacteristics cc) {
        JSONObject obj = new JSONObject();
        try {
            List<CameraCharacteristics.Key<?>> ccKeys = cc.getKeys();
            for (CameraCharacteristics.Key<?> key : ccKeys) {
                String k = key.getName();
                String v = getStringRep(cc.get(key));
                obj.put(k, v);
            }
        } catch(JSONException e) {}
        return obj;
    }

    private JSONObject getFlattenedResult(CaptureResult res) {
        JSONObject obj = new JSONObject();
        if(res == null)
            return obj;
        try {
            List<CaptureResult.Key<?>> captureResultKeys = mCharacteristics.getAvailableCaptureResultKeys();
            for (CaptureResult.Key<?> key : captureResultKeys) {
                String k = key.getName();
                String v = getStringRep(res.get(key));
                obj.put(k, v);
            }
        } catch(JSONException e) {}
        return obj;
    }

    private JSONObject getFlattenedRequest(CaptureRequest req) {
        JSONObject obj = new JSONObject();
        if(req == null)
            return obj;
        try {
            List<CaptureRequest.Key<?>> captureResultKeys = mCharacteristics.getAvailableCaptureRequestKeys();
            for (CaptureRequest.Key<?> key : captureResultKeys) {
                String k = key.getName();
                String v = getStringRep(req.get(key));
                obj.put(k, v);
            }
        } catch(JSONException e) {}
        return obj;
    }

    private static class CamModeParser {
        private static String parseMode(Class<?> clazz, String prefix, int mode) {
            Map<Integer, String> modeNameDecoder;
            try {
                modeNameDecoder = getModes(clazz, prefix);
            } catch (IllegalAccessException e) {
                e.printStackTrace();
                return null;
            }

            return (modeNameDecoder.get(mode) == null) ? Integer.toString(mode) : modeNameDecoder.get(mode);
        }

        private static List<String> parseModes(Class<?> clazz, String prefix, int[] modes) {
            Map<Integer, String> modeNameDecoder;
            try {
                modeNameDecoder = getModes(clazz, prefix);
            } catch (IllegalAccessException e) {
                e.printStackTrace();
                return new ArrayList<>();
            }

            List<String> modeNames = new ArrayList<>();
            for(int m : modes)
                modeNames.add((modeNameDecoder.get(m) == null) ? Integer.toString(m) : modeNameDecoder.get(m));
            return modeNames;
        }

        private static Map<Integer, String> getModes(Class<?> clazz, String prefix) throws IllegalAccessException {
            Map<Integer, String> modes = new HashMap<>();

            //Class<CameraCharacteristics> clazz = CameraCharacteristics.class;
            Field[] fields = clazz.getFields();

            for(Field f : fields) {
                if(f.getName().startsWith(prefix) && f.getType().equals(int.class))
                    modes.put(f.getInt(null), f.getName());
            }

            return modes;
        }
    }

    public CameraFeatures getFeatures() {
        CameraFeatures features = new CameraFeatures();

        if(mCharacteristics == null)
            return null; // more graceful
            //throw new IllegalStateException("must call start() before getFeatures()");

        features.flash = mCharacteristics.get(CameraCharacteristics.FLASH_INFO_AVAILABLE);
        int[] ae_modes = mCharacteristics.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_MODES);
        features.aeLockAvailable = mCharacteristics.get(CameraCharacteristics.CONTROL_AE_LOCK_AVAILABLE) || contains(ae_modes, CameraMetadata.CONTROL_AE_MODE_OFF);
        features.awbLockAvailable = mCharacteristics.get(CameraCharacteristics.CONTROL_AWB_LOCK_AVAILABLE);
        int[] af_modes = mCharacteristics.get(CameraCharacteristics.CONTROL_AF_AVAILABLE_MODES);
        features.afModeOffAvailable = contains(af_modes, CameraMetadata.CONTROL_AF_MODE_OFF);

        int[] control_modes = mCharacteristics.get(CameraCharacteristics.CONTROL_AVAILABLE_MODES);
        features.aeModes = CamModeParser.parseModes(CameraMetadata.class, "CONTROL_AE_MODE", ae_modes);
        features.afModes = CamModeParser.parseModes(CameraMetadata.class, "CONTROL_AF_MODE", af_modes);
        features.controlModes = CamModeParser.parseModes(CameraMetadata.class, "CONTROL_MODE", control_modes);

        int timestampSource = mCharacteristics.get(CameraCharacteristics.SENSOR_INFO_TIMESTAMP_SOURCE);
        String ts;
        switch(timestampSource) {
            case CameraCharacteristics.SENSOR_INFO_TIMESTAMP_SOURCE_UNKNOWN: ts = "unknown"; break;
            case CameraCharacteristics.SENSOR_INFO_TIMESTAMP_SOURCE_REALTIME: ts = "realtime"; break;
            default: ts = "";
        }
        features.timestampSource = ts;

        return features;
    }

    private void setA3Locks(boolean locks) {
        if(mCharacteristics == null) // || mPreviewBuilder == null)
            return;

        /*
        // "lock AWB and AF before setting AE to OFF" -- https://developer.android.com/reference/android/hardware/camera2/CameraMetadata.html#CONTROL_AE_MODE_OFF
        if(mCharacteristics.get(CameraCharacteristics.CONTROL_AE_LOCK_AVAILABLE))
            mPreviewBuilder.set(CaptureRequest.CONTROL_AE_LOCK, locks);

        int[] af_modes = mCharacteristics.get(CameraCharacteristics.CONTROL_AF_AVAILABLE_MODES);
        if(contains(af_modes, CameraMetadata.CONTROL_AF_MODE_OFF))
            mPreviewBuilder.set(CaptureRequest.CONTROL_AF_MODE, locks ? CaptureRequest.CONTROL_AF_MODE_OFF : CaptureRequest.CONTROL_AF_MODE_AUTO);

        // lock AWB and AF first, see above.
        updatePreviewSettings();

        int[] ae_modes = mCharacteristics.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_MODES);
        if(mCharacteristics.get(CameraCharacteristics.CONTROL_AE_LOCK_AVAILABLE) && contains(ae_modes, CameraMetadata.CONTROL_AE_MODE_OFF)) {
            mPreviewBuilder.set(CaptureRequest.CONTROL_AE_LOCK, locks);
            mPreviewBuilder.set(CaptureRequest.CONTROL_AE_MODE,
                    locks ? CaptureRequest.CONTROL_AE_MODE_OFF : CaptureRequest.CONTROL_AE_MODE_ON);
        } else if(contains(ae_modes, CameraMetadata.CONTROL_AE_MODE_OFF)) {
            mPreviewBuilder.set(CaptureRequest.CONTROL_AE_MODE,
                    locks ? CaptureRequest.CONTROL_AE_MODE_OFF : CaptureRequest.CONTROL_AE_MODE_ON);
        }

        if(locks)
            mPreviewBuilder.set(CaptureRequest.CONTROL_MODE, CameraMetadata.CONTROL_MODE_OFF);
        else
            mPreviewBuilder.set(CaptureRequest.CONTROL_MODE, CameraMetadata.CONTROL_MODE_AUTO);
        */

        /*
        if(locks)
            mPreviewBuilder.set(CaptureRequest.CONTROL_AE_MODE, CameraMetadata.CONTROL_AE_MODE_OFF);
        else
            mPreviewBuilder.set(CaptureRequest.CONTROL_AE_MODE, CameraMetadata.CONTROL_AE_MODE_ON);
            */

        try {
            updatePreviewSettings(makePreviewBuilder(locks));
        } catch (CameraAccessException e) {
            e.printStackTrace();
            Log.e(TAG, "setA3Locks(): CameraAccessException");
            setState(State.FAILED);
        }
    }

    private void updatePreviewSettings(CaptureRequest.Builder builder) {
        if(mPreviewSession != null) {
            try {
                //mPreviewSession.stopRepeating(); // not necessary - setRepeatingRequest() replaces previous request
                mPreviewSession.setRepeatingRequest(builder.build(), mCaptureCallback, mBackgroundHandler);
            } catch (CameraAccessException e) {
                e.printStackTrace();
                Log.e(TAG, "updatePreviewSettings(): CameraAccessException");
                setState(State.FAILED);
            }
        }
    }

    /*
    class PreviewBuilder {
        CaptureRequest.Builder builder;

        public void reset(boolean a3locks) {
            try {
                builder = makePreviewBuilder(a3locks);
            } catch (CameraAccessException e) {
                e.printStackTrace();
                Log.e(TAG, "reset(): CameraAccessException");
                setState(State.FAILED);
            }
        }

        public void build() {
            if(mPreviewSession == null)
                return;

            try {
                //mPreviewSession.stopRepeating(); // not necessary - setRepeatingRequest() replaces previous request
                mPreviewSession.setRepeatingRequest(builder.build(), mCaptureCallback, mBackgroundHandler);
            } catch (CameraAccessException e) {
                e.printStackTrace();
                Log.e(TAG, "updatePreviewSettings(): CameraAccessException");
                setState(State.FAILED);
            }
        }
    }
    */

    private int mCaptureResultI = 0;

    private void updatePreview() {
        if (mCameraDevice == null) {
            Log.e(TAG, "updatePreview(): mCameraDevice == null");
            setState(State.FAILED);
            return;
        }
        try {
            //setUpCaptureRequestBuilder(mPreviewBuilder);
            HandlerThread thread = new HandlerThread("CameraPreview");
            thread.start();

            mCaptureResultI = 0;
            mCaptureCallback = new CameraCaptureSession.CaptureCallback() {
                private int mI = 0;

                private int intValElse(Integer i, int defaultValue) {
                    return (i != null) ? i : defaultValue;
                }
                private String boolValElse(Boolean b, String defaultValue) {
                    return (b != null) ? b.toString() : defaultValue;
                }

                // to do: onCaptureFailed() ??? but may also be called after updatePreviewSettings(), see stopRepeating() / abortCaptures() docs.

                /** map to double for the camera lock indicator in each frame[3] entry */
                private double mapLocked(Boolean locked) {
                    if(locked == null)
                        return -1.0; // no driver support
                    return locked ? 1.0 : 0.0;
                    // check with (locked > 0)
                }

                private CaptureResult mLastCaptureResult = null;

                @Override
                public void onCaptureCompleted(CameraCaptureSession session, CaptureRequest request, TotalCaptureResult result) {
                    super.onCaptureCompleted(session, request, result);

                    if(result.getFrameNumber() != mCaptureResultI)
                        Log.e(TAG, "frameNumber=" + result.getFrameNumber() + " != mCaptureResultI=" + mCaptureResultI);
                    mCaptureResultI++;

                    double locked = mapLocked(request.get(CaptureRequest.CONTROL_AE_LOCK));
                    mLocked.add(locked);

                    //Log.i(TAG, "frameNumber:" + result.getFrameNumber());

                    List<CaptureResult> partialResults = result.getPartialResults();
                    for(CaptureResult res : partialResults) {
                        if(mI == 0) {
                            try {
                                mCameraMeta.put("firstCaptureResult", getFlattenedResult(res)); // the very first camera frame captured
                            } catch (JSONException e) {}
                        }

                        if(locked > 0 && !mCameraMeta.has("lockedCaptureResult0")) {
                            try {
                                mCameraMeta.put("unlockedCaptureResult", getFlattenedResult(mLastCaptureResult)); // right before locking
                                mCameraMeta.put("lockedCaptureResult0", getFlattenedResult(res)); // right after locking
                            } catch (JSONException e) {}
                        } else if(locked > 0 && !mCameraMeta.has("lockedCaptureResult1")) {
                            try {
                                mCameraMeta.put("lockedCaptureResult1", getFlattenedResult(res)); // one frame after locking
                            } catch (JSONException e) {}
                        }

                        mLastCaptureResult = res;
                    }

                    if(++mI % 30 == 0 && mDebugMode) {
                        List<CaptureResult> partialResults2 = result.getPartialResults();
                        Log.i(TAG, "partialResults.size() = " + partialResults2.size());
                        for(CaptureResult res : partialResults2) {
                            //res.get

                            // CaptureRequest.CONTROL_MODE
                            // CaptureRequest.CONTROL_AE_MODE
                            // CaptureRequest.CONTROL_AE_LOCK
                            // CaptureRequest.CONTROL_AF_MODE

                            String controlMode = CamModeParser.parseMode(CameraMetadata.class, "CONTROL_MODE", intValElse(res.get(CaptureResult.CONTROL_MODE), -1));
                            String controlAeMode = CamModeParser.parseMode(CameraMetadata.class, "CONTROL_AE_MODE", intValElse(res.get(CaptureResult.CONTROL_AE_MODE), -1));
                            String controlAfMode = CamModeParser.parseMode(CameraMetadata.class, "CONTROL_AF_MODE", intValElse(res.get(CaptureResult.CONTROL_AF_MODE), -1));
                            String controlAeLock = boolValElse(res.get(CaptureResult.CONTROL_AE_LOCK), "null");

                            Log.i(TAG, "CaptureResult controlMode=" + controlMode + " controlAeMode=" + controlAeMode + " controlAfMode=" + controlAfMode + " controlAeLock=" + controlAeLock);

                            controlMode = CamModeParser.parseMode(CameraMetadata.class, "CONTROL_MODE", intValElse(request.get(CaptureRequest.CONTROL_MODE), -1));
                            controlAeMode = CamModeParser.parseMode(CameraMetadata.class, "CONTROL_AE_MODE", intValElse(request.get(CaptureRequest.CONTROL_AE_MODE), -1));
                            controlAfMode = CamModeParser.parseMode(CameraMetadata.class, "CONTROL_AF_MODE", intValElse(request.get(CaptureRequest.CONTROL_AF_MODE), -1));
                            controlAeLock = boolValElse(request.get(CaptureRequest.CONTROL_AE_LOCK), "null");
                            Log.i(TAG, "CaptureRequest controlMode=" + controlMode + " controlAeMode=" + controlAeMode + " controlAfMode=" + controlAfMode + " controlAeLock=" + controlAeLock);

                            Log.i(TAG, getFlattenedResult(res).toString());

                        }
                    }
                }
            };

            //mPreviewSession.setRepeatingRequest(makePreviewBuilder().build(), mCaptureCallback, mBackgroundHandler);
            updatePreviewSettings(makePreviewBuilder(false));

            // assume successful configuration here; now just waiting for the regular callbacks in onCaptureCompleted() and onImageAvailable()
            setState(State.WAITING);

        } catch (CameraAccessException e) {
            e.printStackTrace();
            Log.e(TAG, "updatePreview(): CameraAccessException");
            setState(State.FAILED);
        }
    }

    CameraCaptureSession.CaptureCallback mCaptureCallback;

    /////////////////////////


    private String getString(int resId) {
        return mActivity.getResources().getString(resId);
    }

    public static class ErrorDialog extends DialogFragment {

        private static final String ARG_MESSAGE = "message";

        public static ErrorDialog newInstance(String message) {
            ErrorDialog dialog = new ErrorDialog();
            Bundle args = new Bundle();
            args.putString(ARG_MESSAGE, message);
            dialog.setArguments(args);
            return dialog;
        }

        @Override
        public Dialog onCreateDialog(Bundle savedInstanceState) {
            final Activity activity = getActivity();
            return new AlertDialog.Builder(activity)
                    .setMessage(getArguments().getString(ARG_MESSAGE))
                    .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
                        @Override
                        public void onClick(DialogInterface dialogInterface, int i) {
                            activity.finish();
                        }
                    })
                    .create();
        }

    }

    /////////////////////////

    private boolean hasPermissionsGranted(String[] permissions) {
        for (String permission : permissions) {
            if (ActivityCompat.checkSelfPermission(mActivity, permission)
                    != PackageManager.PERMISSION_GRANTED) {
                return false;
            }
        }
        return true;
    }

    /////////////////////////

    private void startBackgroundThread() {
        if(mStickyUiThread) {
            // use UI thread for callbacks
            mBackgroundHandler = new Handler(Looper.getMainLooper());
            mNotifierHandler = new Handler(Looper.getMainLooper());
            return;
        }

        mBackgroundThread = new HandlerThread("CameraBackground");
        mBackgroundThread.start();
        mBackgroundHandler = new Handler(mBackgroundThread.getLooper());

        mNotifierThread = new HandlerThread("CameraBackgroundNotifier");
        mNotifierThread.start();
        mNotifierHandler = new Handler(mNotifierThread.getLooper());
    }

    private void stopBackgroundThread() {
        if(mBackgroundThread != null) {
            mBackgroundThread.quitSafely();
            try {
                mBackgroundThread.join();
                mBackgroundThread = null;
                mBackgroundHandler = null;
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        if(mNotifierThread != null) {
            mNotifierThread.quitSafely();
            try {
                mNotifierThread.join();
                mNotifierThread = null;
                mNotifierHandler = null;
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        synchronized(mBufferLock) {
            mBufferYUV = null;
            mBufferARGB = null;
        }
    }


    @Override
    protected void finalize() throws Throwable {
        try {
            stopBackgroundThread();
        } catch(Throwable t) {
            throw t;
        } finally {
            super.finalize();
        }
    }

    @Override
    public void setDebugMode(boolean debugMode) {
        mDebugMode = debugMode;
    }

    private HandlerThread mBackgroundThread;
    private Handler mBackgroundHandler;

    private HandlerThread mNotifierThread;
    private Handler mNotifierHandler;

    // these belong to the HandlerThread
    private int[] mBufferARGB = null;
    private byte[] mBufferYUV = null;
    private final Object mBufferLock = new Object();
}
