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