From 881ba4e7ee2895cb7085853a35dc054a92d4cf34 Mon Sep 17 00:00:00 2001 From: Johnson Lu Date: Fri, 2 Nov 2018 15:58:47 +0800 Subject: [PATCH] Add QrCamera for QrCode scanner implementation The QrCode Scanner is for Wifi DPP feature. Bug: 118797380 Test: RunSettingsRoboTests Change-Id: I962d069a8c2bce78c00ac8ce9e8ebf176e75873c --- Android.mk | 1 + src/com/android/settings/wifi/qrcode/QrCamera.java | 328 +++++++++++++++++++++ .../settings/wifi/qrcode/QrYuvLuminanceSource.java | 75 +++++ .../android/settings/wifi/qrcode/QrCameraTest.java | 146 +++++++++ 4 files changed, 550 insertions(+) create mode 100644 src/com/android/settings/wifi/qrcode/QrCamera.java create mode 100644 src/com/android/settings/wifi/qrcode/QrYuvLuminanceSource.java create mode 100644 tests/robotests/src/com/android/settings/wifi/qrcode/QrCameraTest.java diff --git a/Android.mk b/Android.mk index ca2ad4a335..04749bfa45 100644 --- a/Android.mk +++ b/Android.mk @@ -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 index 0000000000..3230035ea3 --- /dev/null +++ b/src/com/android/settings/wifi/qrcode/QrCamera.java @@ -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> HINTS = new ArrayMap<>(); + private static List FORMATS = new ArrayList<>(); + + static { + FORMATS.add(BarcodeFormat.QR_CODE); + HINTS.put(DecodeHintType.POSSIBLE_FORMATS, FORMATS); + } + + private Camera mCamera; + private Size mPreviewSize; + private WeakReference 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); + 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 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 { + 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 index 0000000000..874a758642 --- /dev/null +++ b/src/com/android/settings/wifi/qrcode/QrYuvLuminanceSource.java @@ -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 index 0000000000..ca74c19394 --- /dev/null +++ b/tests/robotests/src/com/android/settings/wifi/qrcode/QrCameraTest.java @@ -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; + } +} -- 2.11.0