| /* |
| * 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.content.res.Configuration; |
| import android.graphics.Matrix; |
| import android.graphics.Rect; |
| import android.graphics.SurfaceTexture; |
| import android.hardware.Camera; |
| import android.hardware.Camera.CameraInfo; |
| import android.hardware.Camera.Parameters; |
| 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.WindowManager; |
| |
| import androidx.annotation.VisibleForTesting; |
| |
| 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.Executors; |
| import java.util.concurrent.Semaphore; |
| |
| /** |
| * Manage the camera for the QR scanner and help the decoder to get the image inside the scanning |
| * frame. Caller prepares a {@link SurfaceTexture} then call {@link #start(SurfaceTexture)} 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; |
| |
| /** |
| * The max allowed difference between picture size ratio and preview size ratio. |
| * Uses to filter the picture sizes of similar preview size ratio, for example, if a preview |
| * size is 1920x1440, MAX_RATIO_DIFF 0.1 could allow picture size of 720x480 or 352x288 or |
| * 176x44 but not 1920x1080. |
| */ |
| private static final double MAX_RATIO_DIFF = 0.1; |
| |
| private static final 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); |
| } |
| |
| @VisibleForTesting |
| Camera mCamera; |
| private Size mPreviewSize; |
| private WeakReference<Context> mContext; |
| private ScannerCallback mScannerCallback; |
| private MultiFormatReader mReader; |
| private DecodingTask mDecodeTask; |
| private int mCameraOrientation; |
| @VisibleForTesting |
| Camera.Parameters mParameters; |
| |
| public QrCamera(Context context, ScannerCallback callback) { |
| mContext = new WeakReference<Context>(context); |
| mScannerCallback = callback; |
| mReader = new MultiFormatReader(); |
| mReader.setHints(HINTS); |
| } |
| |
| /** |
| * The function start camera preview and capture pictures to decode QR code continuously in a |
| * background task. |
| * |
| * @param surface The surface to be used for live preview. |
| */ |
| public void start(SurfaceTexture surface) { |
| if (mDecodeTask == null) { |
| mDecodeTask = new DecodingTask(surface); |
| // Execute in the separate thread pool to prevent block other AsyncTask. |
| mDecodeTask.executeOnExecutor(Executors.newSingleThreadExecutor()); |
| } |
| } |
| |
| /** |
| * The function stop camera preview and background decode task. Caller call this function when |
| * the surface is being destroyed. |
| */ |
| public 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 */ |
| public 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); |
| |
| /** |
| * Sets the transform to associate with preview area. |
| * |
| * @param transform The transform to apply to the content of preview |
| */ |
| void setTransform(Matrix transform); |
| |
| /** |
| * Verify QR code is valid or not. The camera will stop scanning if this callback returns |
| * true. |
| * |
| * @param qrCode The result QR code after decoding. |
| * @return Returns true if qrCode hold valid information. |
| */ |
| boolean isValid(String qrCode); |
| } |
| |
| @VisibleForTesting |
| void setCameraParameter() { |
| mParameters = mCamera.getParameters(); |
| mPreviewSize = getBestPreviewSize(mParameters); |
| mParameters.setPreviewSize(mPreviewSize.getWidth(), mPreviewSize.getHeight()); |
| Size pictureSize = getBestPictureSize(mParameters); |
| mParameters.setPictureSize(pictureSize.getWidth(), pictureSize.getHeight()); |
| |
| final List<String> supportedFlashModes = mParameters.getSupportedFlashModes(); |
| if (supportedFlashModes != null && |
| supportedFlashModes.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 (Parameters.FOCUS_MODE_AUTO.equals(mParameters.getFocusMode())) { |
| 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 SurfaceTexture mSurface; |
| |
| private DecodingTask(SurfaceTexture surface) { |
| mSurface = surface; |
| } |
| |
| @Override |
| protected String doInBackground(Void... tmp) { |
| if (!initCamera(mSurface)) { |
| 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 interrupts 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) { |
| if (mScannerCallback.isValid(qrCode.getText())) { |
| 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(SurfaceTexture surface) { |
| 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) { |
| releaseCamera(); |
| mCamera = Camera.open(i); |
| mCameraOrientation = cameraInfo.orientation; |
| break; |
| } |
| } |
| if (mCamera == null && numberOfCameras > 0) { |
| Log.i(TAG, "Can't find back camera. Opening a different camera"); |
| Camera.getCameraInfo(0, cameraInfo); |
| releaseCamera(); |
| mCamera = Camera.open(0); |
| mCameraOrientation = cameraInfo.orientation; |
| } |
| } catch (RuntimeException e) { |
| Log.e(TAG, "Fail to open camera: " + e); |
| mCamera = null; |
| mScannerCallback.handleCameraFailure(); |
| return false; |
| } |
| |
| try { |
| if (mCamera == null) { |
| throw new IOException("Cannot find available camera"); |
| } |
| mCamera.setPreviewTexture(surface); |
| setCameraParameter(); |
| setTransformationMatrix(); |
| if (!startPreview()) { |
| throw new IOException("Lost contex"); |
| } |
| } catch (IOException ioe) { |
| Log.e(TAG, "Fail to startPreview camera: " + ioe); |
| mCamera = null; |
| mScannerCallback.handleCameraFailure(); |
| return false; |
| } |
| return true; |
| } |
| } |
| |
| private void releaseCamera() { |
| if (mCamera != null) { |
| mCamera.release(); |
| mCamera = null; |
| } |
| } |
| |
| /** Set transform matrix to crop and center the preview picture */ |
| private void setTransformationMatrix() { |
| final boolean isPortrait = mContext.get().getResources().getConfiguration().orientation |
| == Configuration.ORIENTATION_PORTRAIT; |
| |
| final int previewWidth = isPortrait ? mPreviewSize.getWidth() : mPreviewSize.getHeight(); |
| final int previewHeight = isPortrait ? mPreviewSize.getHeight() : mPreviewSize.getWidth(); |
| final float ratioPreview = (float) getRatio(previewWidth, previewHeight); |
| |
| // Calculate transformation matrix. |
| float scaleX = 1.0f; |
| float scaleY = 1.0f; |
| if (previewWidth > previewHeight) { |
| scaleY = scaleX / ratioPreview; |
| } else { |
| scaleX = scaleY / ratioPreview; |
| } |
| |
| // Set the transform matrix. |
| final Matrix matrix = new Matrix(); |
| matrix.setScale(scaleX, scaleY); |
| mScannerCallback.setTransform(matrix); |
| } |
| |
| private QrYuvLuminanceSource getFrameImage(byte[] imageData) { |
| final Rect frame = mScannerCallback.getFramePosition(mPreviewSize, mCameraOrientation); |
| final QrYuvLuminanceSource image = new QrYuvLuminanceSource(imageData, |
| mPreviewSize.getWidth(), mPreviewSize.getHeight()); |
| 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 periodically |
| // trigger it. |
| mCamera.autoFocus(/* Camera.AutoFocusCallback */ null); |
| sendMessageDelayed(obtainMessage(MSG_AUTO_FOCUS), AUTOFOCUS_INTERVAL_MS); |
| break; |
| default: |
| Log.d(TAG, "Unexpected Message: " + msg.what); |
| } |
| } |
| |
| /** Get best preview size from the list of camera supported preview sizes. Compares the |
| * preview size and aspect ratio to choose the best one. */ |
| private Size getBestPreviewSize(Camera.Parameters parameters) { |
| final double minRatioDiffPercent = 0.1; |
| final Size windowSize = mScannerCallback.getViewSize(); |
| final double winRatio = getRatio(windowSize.getWidth(), windowSize.getHeight()); |
| double bestChoiceRatio = 0; |
| Size bestChoice = new Size(0, 0); |
| for (Camera.Size size : parameters.getSupportedPreviewSizes()) { |
| double ratio = getRatio(size.width, size.height); |
| if (size.height * size.width > bestChoice.getWidth() * bestChoice.getHeight() |
| && (Math.abs(bestChoiceRatio - winRatio) / winRatio > minRatioDiffPercent |
| || Math.abs(ratio - winRatio) / winRatio <= minRatioDiffPercent)) { |
| bestChoice = new Size(size.width, size.height); |
| bestChoiceRatio = getRatio(size.width, size.height); |
| } |
| } |
| return bestChoice; |
| } |
| |
| /** Get best picture size from the list of camera supported picture sizes. Compares the |
| * picture size and aspect ratio to choose the best one. */ |
| private Size getBestPictureSize(Camera.Parameters parameters) { |
| final Camera.Size previewSize = parameters.getPreviewSize(); |
| final double previewRatio = getRatio(previewSize.width, previewSize.height); |
| List<Size> bestChoices = new ArrayList<>(); |
| final List<Size> similarChoices = new ArrayList<>(); |
| |
| // Filter by ratio |
| for (Camera.Size size : parameters.getSupportedPictureSizes()) { |
| double ratio = getRatio(size.width, size.height); |
| if (ratio == previewRatio) { |
| bestChoices.add(new Size(size.width, size.height)); |
| } else if (Math.abs(ratio - previewRatio) < MAX_RATIO_DIFF) { |
| similarChoices.add(new Size(size.width, size.height)); |
| } |
| } |
| |
| if (bestChoices.size() == 0 && similarChoices.size() == 0) { |
| Log.d(TAG, "No proper picture size, return default picture size"); |
| Camera.Size defaultPictureSize = parameters.getPictureSize(); |
| return new Size(defaultPictureSize.width, defaultPictureSize.height); |
| } |
| |
| if (bestChoices.size() == 0) { |
| bestChoices = similarChoices; |
| } |
| |
| // Get the best by area |
| int bestAreaDifference = Integer.MAX_VALUE; |
| Size bestChoice = null; |
| final int previewArea = previewSize.width * previewSize.height; |
| for (Size size : bestChoices) { |
| int areaDifference = Math.abs(size.getWidth() * size.getHeight() - previewArea); |
| if (areaDifference < bestAreaDifference) { |
| bestAreaDifference = areaDifference; |
| bestChoice = size; |
| } |
| } |
| return bestChoice; |
| } |
| |
| private double getRatio(double x, double y) { |
| return (x < y) ? x / y : y / x; |
| } |
| |
| @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()); |
| } |
| } |
| |
| /** |
| * After {@link #start(SurfaceTexture)}, DecodingTask runs continuously to capture images and |
| * decode QR code. DecodingTask become null After {@link #stop()}. |
| * |
| * Uses this method in test case to prevent power consumption problem. |
| */ |
| public boolean isDecodeTaskAlive() { |
| return mDecodeTask != null; |
| } |
| } |