OSDN Git Service

Add QrCamera for QrCode scanner implementation
authorJohnson Lu <johnsonlu@google.com>
Fri, 2 Nov 2018 07:58:47 +0000 (15:58 +0800)
committerJohnson Lu <johnsonlu@google.com>
Fri, 9 Nov 2018 06:38:01 +0000 (14:38 +0800)
The QrCode Scanner is for Wifi DPP feature.

Bug: 118797380
Test: RunSettingsRoboTests
Change-Id: I962d069a8c2bce78c00ac8ce9e8ebf176e75873c

Android.mk
src/com/android/settings/wifi/qrcode/QrCamera.java [new file with mode: 0644]
src/com/android/settings/wifi/qrcode/QrYuvLuminanceSource.java [new file with mode: 0644]
tests/robotests/src/com/android/settings/wifi/qrcode/QrCameraTest.java [new file with mode: 0644]

index ca2ad4a..04749bf 100644 (file)
@@ -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
 
diff --git a/src/com/android/settings/wifi/qrcode/QrCamera.java b/src/com/android/settings/wifi/qrcode/QrCamera.java
new file mode 100644 (file)
index 0000000..3230035
--- /dev/null
@@ -0,0 +1,328 @@
+/*
+ * 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());
+        }
+    }
+}
diff --git a/src/com/android/settings/wifi/qrcode/QrYuvLuminanceSource.java b/src/com/android/settings/wifi/qrcode/QrYuvLuminanceSource.java
new file mode 100644 (file)
index 0000000..874a758
--- /dev/null
@@ -0,0 +1,75 @@
+/*
+ * 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;
+    }
+}
diff --git a/tests/robotests/src/com/android/settings/wifi/qrcode/QrCameraTest.java b/tests/robotests/src/com/android/settings/wifi/qrcode/QrCameraTest.java
new file mode 100644 (file)
index 0000000..ca74c19
--- /dev/null
@@ -0,0 +1,146 @@
+/*
+ * 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;
+    }
+}