Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 9055b3cf authored by Johnson Lu's avatar Johnson Lu Committed by Android (Google) Code Review
Browse files

Merge "Add QrCamera for QrCode scanner implementation"

parents 4864624c 881ba4e7
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -43,6 +43,7 @@ LOCAL_STATIC_JAVA_LIBRARIES := \
    settings-contextual-card-protos-lite \
    contextualcards \
    settings-logtags \
    zxing-core-1.7

LOCAL_PROGUARD_FLAG_FILES := proguard.flags

+328 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2018 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.settings.wifi.qrcode;

import android.content.Context;
import android.graphics.Rect;
import android.hardware.Camera;
import android.hardware.Camera.CameraInfo;
import android.hardware.Camera.Parameters;
import android.hardware.Camera.PreviewCallback;
import android.os.AsyncTask;
import android.os.Handler;
import android.os.Message;
import android.util.ArrayMap;
import android.util.Log;
import android.util.Size;
import android.view.Surface;
import android.view.SurfaceHolder;
import android.view.WindowManager;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.BinaryBitmap;
import com.google.zxing.DecodeHintType;
import com.google.zxing.MultiFormatReader;
import com.google.zxing.ReaderException;
import com.google.zxing.Result;
import com.google.zxing.common.HybridBinarizer;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Semaphore;

import androidx.annotation.VisibleForTesting;

/**
 * Manage the camera for the QR scanner and help the decoder to get the image inside the scanning
 * frame. Caller prepares a {@link SurfaceHolder} then call {@link #start(SurfaceHolder)} to
 * start QR Code scanning. The scanning result will return by ScannerCallback interface. Caller
 * can also call {@link #stop()} to halt QR Code scanning before the result returned.
 */
public class QrCamera extends Handler {
    private static final String TAG = "QrCamera";

    private static final int MSG_AUTO_FOCUS = 1;

    private static double MIN_RATIO_DIFF_PERCENT = 0.1;
    private static long AUTOFOCUS_INTERVAL_MS = 1500L;

    private static Map<DecodeHintType, List<BarcodeFormat>> HINTS = new ArrayMap<>();
    private static List<BarcodeFormat> FORMATS = new ArrayList<>();

    static {
        FORMATS.add(BarcodeFormat.QR_CODE);
        HINTS.put(DecodeHintType.POSSIBLE_FORMATS, FORMATS);
    }

    private Camera mCamera;
    private Size mPreviewSize;
    private WeakReference<Context> mContext;
    private ScannerCallback mScannerCallback;
    private MultiFormatReader mReader;
    private DecodingTask mDecodeTask;
    private int mCameraOrientation;
    private Camera.Parameters mParameters;

    public QrCamera(Context context, ScannerCallback callback) {
        mContext =  new WeakReference<Context>(context);
        mScannerCallback = callback;
        mReader = new MultiFormatReader();
        mReader.setHints(HINTS);
    }

    void start(SurfaceHolder surfaceHolder) {
        if (mDecodeTask == null) {
            mDecodeTask = new DecodingTask(surfaceHolder);
            mDecodeTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
        }
    }

    void stop() {
        removeMessages(MSG_AUTO_FOCUS);
        if (mDecodeTask != null) {
            mDecodeTask.cancel(true);
            mDecodeTask = null;
        }
        if (mCamera != null) {
            mCamera.stopPreview();
        }
    }

    /** The scanner which includes this QrCamera class should implement this */
    interface ScannerCallback {

        /**
         * The function used to handle the decoding result of the QR code.
         *
         * @param result the result QR code after decoding.
         */
        void handleSuccessfulResult(String result);

        /** Request the QR code scanner to handle the failure happened. */
        void handleCameraFailure();

        /**
         * The function used to get the background View size.
         *
         * @return Includes the background view size.
         */
        Size getViewSize();

        /**
         * The function used to get the frame position inside the view
         *
         * @param previewSize Is the preview size set by camera
         * @param cameraOrientation Is the orientation of current Camera
         * @return The rectangle would like to crop from the camera preview shot.
         */
        Rect getFramePosition(Size previewSize, int cameraOrientation);
    }

    private void setCameraParameter() {
        mParameters = mCamera.getParameters();
        mPreviewSize = getBestPreviewSize(mParameters);
        mParameters.setPreviewSize(mPreviewSize.getWidth(), mPreviewSize.getHeight());
        mParameters.setPictureSize(mPreviewSize.getWidth(), mPreviewSize.getHeight());

        if (mParameters.getSupportedFlashModes().contains(Parameters.FLASH_MODE_OFF)) {
            mParameters.setFlashMode(Parameters.FLASH_MODE_OFF);
        }

        final List<String> supportedFocusModes = mParameters.getSupportedFocusModes();
        if (supportedFocusModes.contains(Parameters.FOCUS_MODE_CONTINUOUS_PICTURE)) {
            mParameters.setFocusMode(Parameters.FOCUS_MODE_CONTINUOUS_PICTURE);
        } else if (supportedFocusModes.contains(Parameters.FOCUS_MODE_AUTO)) {
            mParameters.setFocusMode(Parameters.FOCUS_MODE_AUTO);
        }
        mCamera.setParameters(mParameters);
    }

    private boolean startPreview() {
        if (mContext.get() == null) {
            return false;
        }

        final WindowManager winManager =
                (WindowManager) mContext.get().getSystemService(Context.WINDOW_SERVICE);
        final int rotation = winManager.getDefaultDisplay().getRotation();
        int degrees = 0;
        switch (rotation) {
            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;
        }
        final int rotateDegrees = (mCameraOrientation - degrees + 360) % 360;
        mCamera.setDisplayOrientation(rotateDegrees);
        mCamera.startPreview();
        if (mParameters.getFocusMode() == Parameters.FOCUS_MODE_AUTO) {
            mCamera.autoFocus(/* Camera.AutoFocusCallback */ null);
            sendMessageDelayed(obtainMessage(MSG_AUTO_FOCUS), AUTOFOCUS_INTERVAL_MS);
        }
        return true;
    }

    private class DecodingTask extends AsyncTask<Void, Void, String> {
        private QrYuvLuminanceSource mImage;
        private SurfaceHolder mSurfaceHolder;

        private DecodingTask(SurfaceHolder surfaceHolder) {
            mSurfaceHolder = surfaceHolder;
        }

        @Override
        protected String doInBackground(Void... tmp) {
            if (!initCamera(mSurfaceHolder)) {
                return null;
            }

            final Semaphore imageGot = new Semaphore(0);
            while (true) {
                // This loop will try to capture preview image continuously until a valid QR Code
                // decoded. The caller can also call {@link #stop()} to inturrupts scanning loop.
                mCamera.setOneShotPreviewCallback(
                        (imageData, camera) -> {
                            mImage = getFrameImage(imageData);
                            imageGot.release();
                        });
                try {
                    // Semaphore.acquire() blocking until permit is available, or the thread is
                    // interrupted.
                    imageGot.acquire();
                    Result qrCode = null;
                    try {
                        qrCode =
                                mReader.decodeWithState(
                                        new BinaryBitmap(new HybridBinarizer(mImage)));
                    } catch (ReaderException e) {
                        // No logging since every time the reader cannot decode the
                        // image, this ReaderException will be thrown.
                    } finally {
                        mReader.reset();
                    }
                    if (qrCode != null) {
                        return qrCode.getText();
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return null;
                }
            }
        }

        @Override
        protected void onPostExecute(String qrCode) {
            if (qrCode != null) {
                mScannerCallback.handleSuccessfulResult(qrCode);
            }
        }

        private boolean initCamera(SurfaceHolder surfaceHolder) {
            final int numberOfCameras = Camera.getNumberOfCameras();
            Camera.CameraInfo cameraInfo = new Camera.CameraInfo();
            try {
                for (int i = 0; i < numberOfCameras; ++i) {
                    Camera.getCameraInfo(i, cameraInfo);
                    if (cameraInfo.facing == CameraInfo.CAMERA_FACING_BACK) {
                        mCamera = Camera.open(i);
                        mCamera.setPreviewDisplay(surfaceHolder);
                        mCameraOrientation = cameraInfo.orientation;
                        break;
                    }
                }
                if (mCamera == null) {
                    Log.e(TAG, "Cannot find available back camera.");
                    mScannerCallback.handleCameraFailure();
                    return false;
                }
                setCameraParameter();
                if (!startPreview()) {
                    Log.e(TAG, "Error to init Camera");
                    mCamera = null;
                    mScannerCallback.handleCameraFailure();
                    return false;
                }
                return true;
            } catch (IOException e) {
                Log.e(TAG, "Error to init Camera");
                mCamera = null;
                mScannerCallback.handleCameraFailure();
                return false;
            }
        }
    }

    private QrYuvLuminanceSource getFrameImage(byte[] imageData) {
        final Rect frame = mScannerCallback.getFramePosition(mPreviewSize, mCameraOrientation);
        final Camera.Size size = mParameters.getPictureSize();
        QrYuvLuminanceSource image = new QrYuvLuminanceSource(imageData, size.width, size.height);
        return (QrYuvLuminanceSource)
                image.crop(frame.left, frame.top, frame.width(), frame.height());
    }

    @Override
    public void handleMessage(Message msg) {
        switch (msg.what) {
            case MSG_AUTO_FOCUS:
                // Calling autoFocus(null) will only trigger the camera to focus once. In order
                // to make the camera continuously auto focus during scanning, need to periodly
                // trigger it.
                mCamera.autoFocus(/* Camera.AutoFocusCallback */ null);
                sendMessageDelayed(obtainMessage(MSG_AUTO_FOCUS), AUTOFOCUS_INTERVAL_MS);
                break;
            default:
                Log.d(TAG, "Unexpected Message: " + msg.what);
        }
    }

    private Size getBestPreviewSize(Camera.Parameters parameters) {
        final Size windowSize = mScannerCallback.getViewSize();
        Size bestChoice = new Size(0, 0);
        for (Camera.Size size : parameters.getSupportedPreviewSizes()) {
            if (size.width <= windowSize.getWidth() && size.height <= windowSize.getHeight()) {
                bestChoice = new Size(size.width, size.height);
                break;
            }
        }
        return bestChoice;
    }

    @VisibleForTesting
    protected void decodeImage(BinaryBitmap image) {
        Result qrCode = null;

        try {
            qrCode = mReader.decodeWithState(image);
        } catch (ReaderException e) {
        } finally {
            mReader.reset();
        }

        if (qrCode != null) {
            mScannerCallback.handleSuccessfulResult(qrCode.getText());
        }
    }
}
+75 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2018 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.settings.wifi.qrcode;

import com.google.zxing.LuminanceSource;

/**
 * This helper class implements crop method to crop preview picture.
 */
public class QrYuvLuminanceSource extends LuminanceSource {

    private byte[] mYuvData;
    private int mWidth;
    private int mHeight;

    public QrYuvLuminanceSource(byte[] yuvData, int width, int height) {
        super(width, height);

        mWidth = width;
        mHeight = height;
        mYuvData = yuvData;
    }

    @Override
    public boolean isCropSupported() {
        return true;
    }

    @Override
    public LuminanceSource crop(int left, int top, int crop_width, int crop_height) {
        final byte[] newImage = new byte[crop_width * crop_height];
        int inputOffset = top * mWidth + left;

        if (left + crop_width > mWidth || top + crop_height > mHeight) {
            throw new IllegalArgumentException("cropped rectangle does not fit within image data.");
        }

        for (int y = 0; y < crop_height; y++) {
            System.arraycopy(mYuvData, inputOffset, newImage, y * crop_width, crop_width);
            inputOffset += mWidth;
        }
        return new QrYuvLuminanceSource(newImage, crop_width, crop_height);
    }

    @Override
    public byte[] getRow(int y, byte[] row) {
        if (y < 0 || y >= mHeight) {
            throw new IllegalArgumentException("Requested row is outside the image: " + y);
        }
        if (row == null || row.length < mWidth) {
            row = new byte[mWidth];
        }
        System.arraycopy(mYuvData, y * mWidth, row, 0, mWidth);
        return row;
    }

    @Override
    public byte[] getMatrix() {
        return mYuvData;
    }
}
+146 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2018 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.settings.wifi.qrcode;

import static com.google.common.truth.Truth.assertThat;

import static org.mockito.Mockito.mock;

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Color;
import android.graphics.Rect;
import android.util.Size;
import android.view.SurfaceHolder;

import com.android.settings.R;
import com.android.settings.testutils.SettingsRobolectricTestRunner;

import com.google.zxing.BarcodeFormat;
import com.google.zxing.BinaryBitmap;
import com.google.zxing.LuminanceSource;
import com.google.zxing.RGBLuminanceSource;
import com.google.zxing.MultiFormatWriter;
import com.google.zxing.WriterException;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.common.HybridBinarizer;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.robolectric.RuntimeEnvironment;

@RunWith(SettingsRobolectricTestRunner.class)
public class QrCameraTest {

    @Mock
    private SurfaceHolder mSurfaceHolder;

    private QrCamera mCamera;
    private Context mContext;

    private String mQrCode;
    CountDownLatch mCallbackSignal;
    private boolean mCameraCallbacked;

    private class ScannerTestCallback implements QrCamera.ScannerCallback {
        @Override
        public Size getViewSize() {
            return new Size(0, 0);
        }

        @Override
        public Rect getFramePosition(Size previewSize, int cameraOrientation) {
            return new Rect(0,0,0,0);
        }

        @Override
        public void handleSuccessfulResult(String qrCode) {
            mQrCode = qrCode;
        }

        @Override
        public void handleCameraFailure() {
            mCameraCallbacked = true;
            mCallbackSignal.countDown();
        }
    }

    private ScannerTestCallback mScannerCallback;

    @Before
    public void setUp() {
        mContext = RuntimeEnvironment.application;
        mScannerCallback = new ScannerTestCallback();
        mCamera = new QrCamera(mContext, mScannerCallback);
        mSurfaceHolder = mock(SurfaceHolder.class);
        mQrCode = "";
        mCameraCallbacked = false;
        mCallbackSignal = null;
    }

    @Test
    public void testCamera_Init_Callback() throws InterruptedException {
        mCallbackSignal = new CountDownLatch(1);
        mCamera.start(mSurfaceHolder);
        mCallbackSignal.await(5000, TimeUnit.MILLISECONDS);
        assertThat(mCameraCallbacked).isTrue();
    }

    @Test
    public void testDecode_PictureCaptured_QrCodeCorrectValue() {
        final String googleUrl = "http://www.google.com";

        try {
            Bitmap bmp = encodeQrCode(googleUrl, 320);
            int[] intArray = new int[bmp.getWidth() * bmp.getHeight()];
            bmp.getPixels(intArray, 0, bmp.getWidth(), 0, 0, bmp.getWidth(), bmp.getHeight());
            LuminanceSource source = new RGBLuminanceSource(bmp.getWidth(), bmp.getHeight(),
                    intArray);

            BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
            mCamera.decodeImage(bitmap);
        } catch (WriterException e) {
        }

        assertThat(mQrCode).isEqualTo(googleUrl);
    }

    private Bitmap encodeQrCode(String qrCode, int size) throws WriterException {
        BitMatrix qrBits = null;
        try {
            qrBits =
                    new MultiFormatWriter().encode(qrCode, BarcodeFormat.QR_CODE, size, size, null);
        } catch (IllegalArgumentException iae) {
            // Should never reach here.
        }
        assertThat(qrBits).isNotNull();

        Bitmap bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565);
        for (int x = 0; x < size; ++x) {
            for (int y = 0; y < size; ++y) {
                bitmap.setPixel(x, y, qrBits.get(x, y) ? Color.BLACK : Color.WHITE);
            }
        }
        return bitmap;
    }
}