1 /*
2  * Copyright (C) 2018 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License
15  */
16 
17 package com.android.settings.wifi.qrcode;
18 
19 import android.content.Context;
20 import android.content.res.Configuration;
21 import android.graphics.Matrix;
22 import android.graphics.Rect;
23 import android.graphics.SurfaceTexture;
24 import android.hardware.Camera;
25 import android.hardware.Camera.CameraInfo;
26 import android.hardware.Camera.Parameters;
27 import android.os.AsyncTask;
28 import android.os.Handler;
29 import android.os.Message;
30 import android.util.ArrayMap;
31 import android.util.Log;
32 import android.util.Size;
33 import android.view.Surface;
34 import android.view.WindowManager;
35 
36 import androidx.annotation.VisibleForTesting;
37 
38 import com.google.zxing.BarcodeFormat;
39 import com.google.zxing.BinaryBitmap;
40 import com.google.zxing.DecodeHintType;
41 import com.google.zxing.MultiFormatReader;
42 import com.google.zxing.ReaderException;
43 import com.google.zxing.Result;
44 import com.google.zxing.common.HybridBinarizer;
45 
46 import java.io.IOException;
47 import java.lang.ref.WeakReference;
48 import java.util.ArrayList;
49 import java.util.List;
50 import java.util.Map;
51 import java.util.concurrent.Executors;
52 import java.util.concurrent.Semaphore;
53 
54 /**
55  * Manage the camera for the QR scanner and help the decoder to get the image inside the scanning
56  * frame. Caller prepares a {@link SurfaceTexture} then call {@link #start(SurfaceTexture)} to
57  * start QR Code scanning. The scanning result will return by ScannerCallback interface. Caller
58  * can also call {@link #stop()} to halt QR Code scanning before the result returned.
59  */
60 public class QrCamera extends Handler {
61     private static final String TAG = "QrCamera";
62 
63     private static final int MSG_AUTO_FOCUS = 1;
64 
65     /**
66      * The max allowed difference between picture size ratio and preview size ratio.
67      * Uses to filter the picture sizes of similar preview size ratio, for example, if a preview
68      * size is 1920x1440, MAX_RATIO_DIFF 0.1 could allow picture size of 720x480 or 352x288 or
69      * 176x44 but not 1920x1080.
70      */
71     private static final double MAX_RATIO_DIFF = 0.1;
72 
73     private static final long AUTOFOCUS_INTERVAL_MS = 1500L;
74 
75     private static Map<DecodeHintType, List<BarcodeFormat>> HINTS = new ArrayMap<>();
76     private static List<BarcodeFormat> FORMATS = new ArrayList<>();
77 
78     static {
79         FORMATS.add(BarcodeFormat.QR_CODE);
HINTS.put(DecodeHintType.POSSIBLE_FORMATS, FORMATS)80         HINTS.put(DecodeHintType.POSSIBLE_FORMATS, FORMATS);
81     }
82 
83     @VisibleForTesting
84     Camera mCamera;
85     private Size mPreviewSize;
86     private WeakReference<Context> mContext;
87     private ScannerCallback mScannerCallback;
88     private MultiFormatReader mReader;
89     private DecodingTask mDecodeTask;
90     private int mCameraOrientation;
91     @VisibleForTesting
92     Camera.Parameters mParameters;
93 
QrCamera(Context context, ScannerCallback callback)94     public QrCamera(Context context, ScannerCallback callback) {
95         mContext =  new WeakReference<Context>(context);
96         mScannerCallback = callback;
97         mReader = new MultiFormatReader();
98         mReader.setHints(HINTS);
99     }
100 
101     /**
102      * The function start camera preview and capture pictures to decode QR code continuously in a
103      * background task.
104      *
105      * @param surface The surface to be used for live preview.
106      */
start(SurfaceTexture surface)107     public void start(SurfaceTexture surface) {
108         if (mDecodeTask == null) {
109             mDecodeTask = new DecodingTask(surface);
110             // Execute in the separate thread pool to prevent block other AsyncTask.
111             mDecodeTask.executeOnExecutor(Executors.newSingleThreadExecutor());
112         }
113     }
114 
115     /**
116      * The function stop camera preview and background decode task. Caller call this function when
117      * the surface is being destroyed.
118      */
stop()119     public void stop() {
120         removeMessages(MSG_AUTO_FOCUS);
121         if (mDecodeTask != null) {
122             mDecodeTask.cancel(true);
123             mDecodeTask = null;
124         }
125         if (mCamera != null) {
126             mCamera.stopPreview();
127         }
128     }
129 
130     /** The scanner which includes this QrCamera class should implement this */
131     public interface ScannerCallback {
132 
133         /**
134          * The function used to handle the decoding result of the QR code.
135          *
136          * @param result the result QR code after decoding.
137          */
handleSuccessfulResult(String result)138         void handleSuccessfulResult(String result);
139 
140         /** Request the QR code scanner to handle the failure happened. */
handleCameraFailure()141         void handleCameraFailure();
142 
143         /**
144          * The function used to get the background View size.
145          *
146          * @return Includes the background view size.
147          */
getViewSize()148         Size getViewSize();
149 
150         /**
151          * The function used to get the frame position inside the view
152          *
153          * @param previewSize Is the preview size set by camera
154          * @param cameraOrientation Is the orientation of current Camera
155          * @return The rectangle would like to crop from the camera preview shot.
156          */
getFramePosition(Size previewSize, int cameraOrientation)157         Rect getFramePosition(Size previewSize, int cameraOrientation);
158 
159         /**
160          * Sets the transform to associate with preview area.
161          *
162          * @param transform The transform to apply to the content of preview
163          */
setTransform(Matrix transform)164         void setTransform(Matrix transform);
165 
166         /**
167          * Verify QR code is valid or not. The camera will stop scanning if this callback returns
168          * true.
169          *
170          * @param qrCode The result QR code after decoding.
171          * @return Returns true if qrCode hold valid information.
172          */
isValid(String qrCode)173         boolean isValid(String qrCode);
174     }
175 
176     @VisibleForTesting
setCameraParameter()177     void setCameraParameter() {
178         mParameters = mCamera.getParameters();
179         mPreviewSize = getBestPreviewSize(mParameters);
180         mParameters.setPreviewSize(mPreviewSize.getWidth(), mPreviewSize.getHeight());
181         Size pictureSize = getBestPictureSize(mParameters);
182         mParameters.setPictureSize(pictureSize.getWidth(), pictureSize.getHeight());
183 
184         final List<String> supportedFlashModes = mParameters.getSupportedFlashModes();
185         if (supportedFlashModes != null &&
186                 supportedFlashModes.contains(Parameters.FLASH_MODE_OFF)) {
187             mParameters.setFlashMode(Parameters.FLASH_MODE_OFF);
188         }
189 
190         final List<String> supportedFocusModes = mParameters.getSupportedFocusModes();
191         if (supportedFocusModes.contains(Parameters.FOCUS_MODE_CONTINUOUS_PICTURE)) {
192             mParameters.setFocusMode(Parameters.FOCUS_MODE_CONTINUOUS_PICTURE);
193         } else if (supportedFocusModes.contains(Parameters.FOCUS_MODE_AUTO)) {
194             mParameters.setFocusMode(Parameters.FOCUS_MODE_AUTO);
195         }
196         mCamera.setParameters(mParameters);
197     }
198 
startPreview()199     private boolean startPreview() {
200         if (mContext.get() == null) {
201             return false;
202         }
203 
204         final WindowManager winManager =
205                 (WindowManager) mContext.get().getSystemService(Context.WINDOW_SERVICE);
206         final int rotation = winManager.getDefaultDisplay().getRotation();
207         int degrees = 0;
208         switch (rotation) {
209             case Surface.ROTATION_0:
210                 degrees = 0;
211                 break;
212             case Surface.ROTATION_90:
213                 degrees = 90;
214                 break;
215             case Surface.ROTATION_180:
216                 degrees = 180;
217                 break;
218             case Surface.ROTATION_270:
219                 degrees = 270;
220                 break;
221         }
222         final int rotateDegrees = (mCameraOrientation - degrees + 360) % 360;
223         mCamera.setDisplayOrientation(rotateDegrees);
224         mCamera.startPreview();
225         if (Parameters.FOCUS_MODE_AUTO.equals(mParameters.getFocusMode())) {
226             mCamera.autoFocus(/* Camera.AutoFocusCallback */ null);
227             sendMessageDelayed(obtainMessage(MSG_AUTO_FOCUS), AUTOFOCUS_INTERVAL_MS);
228         }
229         return true;
230     }
231 
232     private class DecodingTask extends AsyncTask<Void, Void, String> {
233         private QrYuvLuminanceSource mImage;
234         private SurfaceTexture mSurface;
235 
DecodingTask(SurfaceTexture surface)236         private DecodingTask(SurfaceTexture surface) {
237             mSurface = surface;
238         }
239 
240         @Override
doInBackground(Void... tmp)241         protected String doInBackground(Void... tmp) {
242             if (!initCamera(mSurface)) {
243                 return null;
244             }
245 
246             final Semaphore imageGot = new Semaphore(0);
247             while (true) {
248                 // This loop will try to capture preview image continuously until a valid QR Code
249                 // decoded. The caller can also call {@link #stop()} to interrupts scanning loop.
250                 mCamera.setOneShotPreviewCallback(
251                         (imageData, camera) -> {
252                             mImage = getFrameImage(imageData);
253                             imageGot.release();
254                         });
255                 try {
256                     // Semaphore.acquire() blocking until permit is available, or the thread is
257                     // interrupted.
258                     imageGot.acquire();
259                     Result qrCode = null;
260                     try {
261                         qrCode =
262                                 mReader.decodeWithState(
263                                         new BinaryBitmap(new HybridBinarizer(mImage)));
264                     } catch (ReaderException e) {
265                         // No logging since every time the reader cannot decode the
266                         // image, this ReaderException will be thrown.
267                     } finally {
268                         mReader.reset();
269                     }
270                     if (qrCode != null) {
271                         if (mScannerCallback.isValid(qrCode.getText())) {
272                             return qrCode.getText();
273                         }
274                     }
275                 } catch (InterruptedException e) {
276                     Thread.currentThread().interrupt();
277                     return null;
278                 }
279             }
280         }
281 
282         @Override
onPostExecute(String qrCode)283         protected void onPostExecute(String qrCode) {
284             if (qrCode != null) {
285                 mScannerCallback.handleSuccessfulResult(qrCode);
286             }
287         }
288 
initCamera(SurfaceTexture surface)289         private boolean initCamera(SurfaceTexture surface) {
290             final int numberOfCameras = Camera.getNumberOfCameras();
291             Camera.CameraInfo cameraInfo = new Camera.CameraInfo();
292             try {
293                 for (int i = 0; i < numberOfCameras; ++i) {
294                     Camera.getCameraInfo(i, cameraInfo);
295                     if (cameraInfo.facing == CameraInfo.CAMERA_FACING_BACK) {
296                         releaseCamera();
297                         mCamera = Camera.open(i);
298                         mCameraOrientation = cameraInfo.orientation;
299                         break;
300                     }
301                 }
302                 if (mCamera == null && numberOfCameras > 0) {
303                     Log.i(TAG, "Can't find back camera. Opening a different camera");
304                     Camera.getCameraInfo(0, cameraInfo);
305                     releaseCamera();
306                     mCamera = Camera.open(0);
307                     mCameraOrientation = cameraInfo.orientation;
308                 }
309             } catch (RuntimeException e) {
310                 Log.e(TAG, "Fail to open camera: " + e);
311                 mCamera = null;
312                 mScannerCallback.handleCameraFailure();
313                 return false;
314             }
315 
316             try {
317                 if (mCamera == null) {
318                     throw new IOException("Cannot find available camera");
319                 }
320                 mCamera.setPreviewTexture(surface);
321                 setCameraParameter();
322                 setTransformationMatrix();
323                 if (!startPreview()) {
324                     throw new IOException("Lost contex");
325                 }
326             } catch (IOException ioe) {
327                 Log.e(TAG, "Fail to startPreview camera: " + ioe);
328                 mCamera = null;
329                 mScannerCallback.handleCameraFailure();
330                 return false;
331             }
332             return true;
333         }
334     }
335 
releaseCamera()336     private void releaseCamera() {
337         if (mCamera != null) {
338             mCamera.release();
339             mCamera = null;
340         }
341     }
342 
343     /** Set transform matrix to crop and center the preview picture */
setTransformationMatrix()344     private void setTransformationMatrix() {
345         final boolean isPortrait = mContext.get().getResources().getConfiguration().orientation
346                 == Configuration.ORIENTATION_PORTRAIT;
347 
348         final int previewWidth = isPortrait ? mPreviewSize.getWidth() : mPreviewSize.getHeight();
349         final int previewHeight = isPortrait ? mPreviewSize.getHeight() : mPreviewSize.getWidth();
350         final float ratioPreview = (float) getRatio(previewWidth, previewHeight);
351 
352         // Calculate transformation matrix.
353         float scaleX = 1.0f;
354         float scaleY = 1.0f;
355         if (previewWidth > previewHeight) {
356             scaleY = scaleX / ratioPreview;
357         } else {
358             scaleX = scaleY / ratioPreview;
359         }
360 
361         // Set the transform matrix.
362         final Matrix matrix = new Matrix();
363         matrix.setScale(scaleX, scaleY);
364         mScannerCallback.setTransform(matrix);
365     }
366 
getFrameImage(byte[] imageData)367     private QrYuvLuminanceSource getFrameImage(byte[] imageData) {
368         final Rect frame = mScannerCallback.getFramePosition(mPreviewSize, mCameraOrientation);
369         final QrYuvLuminanceSource image = new QrYuvLuminanceSource(imageData,
370                 mPreviewSize.getWidth(), mPreviewSize.getHeight());
371         return (QrYuvLuminanceSource)
372                 image.crop(frame.left, frame.top, frame.width(), frame.height());
373     }
374 
375     @Override
handleMessage(Message msg)376     public void handleMessage(Message msg) {
377         switch (msg.what) {
378             case MSG_AUTO_FOCUS:
379                 // Calling autoFocus(null) will only trigger the camera to focus once. In order
380                 // to make the camera continuously auto focus during scanning, need to periodically
381                 // trigger it.
382                 mCamera.autoFocus(/* Camera.AutoFocusCallback */ null);
383                 sendMessageDelayed(obtainMessage(MSG_AUTO_FOCUS), AUTOFOCUS_INTERVAL_MS);
384                 break;
385             default:
386                 Log.d(TAG, "Unexpected Message: " + msg.what);
387         }
388     }
389 
390     /** Get best preview size from the list of camera supported preview sizes. Compares the
391      * preview size and aspect ratio to choose the best one. */
getBestPreviewSize(Camera.Parameters parameters)392     private Size getBestPreviewSize(Camera.Parameters parameters) {
393         final double minRatioDiffPercent = 0.1;
394         final Size windowSize = mScannerCallback.getViewSize();
395         final double winRatio = getRatio(windowSize.getWidth(), windowSize.getHeight());
396         double bestChoiceRatio = 0;
397         Size bestChoice = new Size(0, 0);
398         for (Camera.Size size : parameters.getSupportedPreviewSizes()) {
399             double ratio = getRatio(size.width, size.height);
400             if (size.height * size.width > bestChoice.getWidth() * bestChoice.getHeight()
401                     && (Math.abs(bestChoiceRatio - winRatio) / winRatio > minRatioDiffPercent
402                     || Math.abs(ratio - winRatio) / winRatio <= minRatioDiffPercent)) {
403                 bestChoice = new Size(size.width, size.height);
404                 bestChoiceRatio = getRatio(size.width, size.height);
405             }
406         }
407         return bestChoice;
408     }
409 
410     /** Get best picture size from the list of camera supported picture sizes. Compares the
411      *  picture size and aspect ratio to choose the best one. */
getBestPictureSize(Camera.Parameters parameters)412     private Size getBestPictureSize(Camera.Parameters parameters) {
413         final Camera.Size previewSize = parameters.getPreviewSize();
414         final double previewRatio = getRatio(previewSize.width, previewSize.height);
415         List<Size> bestChoices = new ArrayList<>();
416         final List<Size> similarChoices = new ArrayList<>();
417 
418         // Filter by ratio
419         for (Camera.Size size : parameters.getSupportedPictureSizes()) {
420             double ratio = getRatio(size.width, size.height);
421             if (ratio == previewRatio) {
422                 bestChoices.add(new Size(size.width, size.height));
423             } else if (Math.abs(ratio - previewRatio) < MAX_RATIO_DIFF) {
424                 similarChoices.add(new Size(size.width, size.height));
425             }
426         }
427 
428         if (bestChoices.size() == 0 && similarChoices.size() == 0) {
429             Log.d(TAG, "No proper picture size, return default picture size");
430             Camera.Size defaultPictureSize = parameters.getPictureSize();
431             return new Size(defaultPictureSize.width, defaultPictureSize.height);
432         }
433 
434         if (bestChoices.size() == 0) {
435             bestChoices = similarChoices;
436         }
437 
438         // Get the best by area
439         int bestAreaDifference = Integer.MAX_VALUE;
440         Size bestChoice = null;
441         final int previewArea = previewSize.width * previewSize.height;
442         for (Size size : bestChoices) {
443             int areaDifference = Math.abs(size.getWidth() * size.getHeight() - previewArea);
444             if (areaDifference < bestAreaDifference) {
445                 bestAreaDifference = areaDifference;
446                 bestChoice = size;
447             }
448         }
449         return bestChoice;
450     }
451 
getRatio(double x, double y)452     private double getRatio(double x, double y) {
453         return (x < y) ? x / y : y / x;
454     }
455 
456     @VisibleForTesting
decodeImage(BinaryBitmap image)457     protected void decodeImage(BinaryBitmap image) {
458         Result qrCode = null;
459 
460         try {
461             qrCode = mReader.decodeWithState(image);
462         } catch (ReaderException e) {
463         } finally {
464             mReader.reset();
465         }
466 
467         if (qrCode != null) {
468             mScannerCallback.handleSuccessfulResult(qrCode.getText());
469         }
470     }
471 
472     /**
473      * After {@link #start(SurfaceTexture)}, DecodingTask runs continuously to capture images and
474      * decode QR code. DecodingTask become null After {@link #stop()}.
475      *
476      * Uses this method in test case to prevent power consumption problem.
477      */
isDecodeTaskAlive()478     public boolean isDecodeTaskAlive() {
479         return mDecodeTask != null;
480     }
481 }
482