• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.annotation.NonNull;
20 import android.content.Context;
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.LuminanceSource;
40 import com.google.zxing.MultiFormatReader;
41 import com.google.zxing.ReaderException;
42 import com.google.zxing.Result;
43 import com.google.zxing.common.HybridBinarizer;
44 
45 import java.io.IOException;
46 import java.lang.ref.WeakReference;
47 import java.util.ArrayList;
48 import java.util.List;
49 import java.util.Map;
50 import java.util.concurrent.Executors;
51 import java.util.concurrent.Semaphore;
52 
53 public class QrCamera extends Handler {
54     private static final String TAG = "QrCamera";
55 
56     private static final int MSG_AUTO_FOCUS = 1;
57 
58     /**
59      * The max allowed difference between picture size ratio and preview size ratio.
60      * Uses to filter the picture sizes of similar preview size ratio, for example, if a preview
61      * size is 1920x1440, MAX_RATIO_DIFF 0.1 could allow picture size of 720x480 or 352x288 or
62      * 176x44 but not 1920x1080.
63      */
64     private static final double MAX_RATIO_DIFF = 0.1;
65 
66     private static final long AUTOFOCUS_INTERVAL_MS = 1500L;
67 
68     private static Map<DecodeHintType, List<BarcodeFormat>> HINTS = new ArrayMap<>();
69     private static List<BarcodeFormat> FORMATS = new ArrayList<>();
70 
71     static {
72         FORMATS.add(BarcodeFormat.QR_CODE);
HINTS.put(DecodeHintType.POSSIBLE_FORMATS, FORMATS)73         HINTS.put(DecodeHintType.POSSIBLE_FORMATS, FORMATS);
74     }
75 
76     @VisibleForTesting
77     Camera mCamera;
78     Camera.CameraInfo mCameraInfo;
79 
80     /**
81      * The size of the preview image as requested to camera, e.g. 1920x1080.
82      */
83     private Size mPreviewSize;
84 
85     /**
86      * Whether the preview image would be displayed in "portrait" (width less
87      * than height) orientation in current display orientation.
88      *
89      * Note that we don't distinguish between a rotation of 90 degrees or 270
90      * degrees here, since we center crop all the preview.
91      *
92      * TODO: Handle external camera / multiple display, this likely requires
93      * migrating to newer Camera2 API.
94      */
95     private boolean mPreviewInPortrait;
96 
97     private WeakReference<Context> mContext;
98     private ScannerCallback mScannerCallback;
99     private MultiFormatReader mReader;
100     private DecodingTask mDecodeTask;
101     @VisibleForTesting
102     Camera.Parameters mParameters;
103 
QrCamera(Context context, ScannerCallback callback)104     public QrCamera(Context context, ScannerCallback callback) {
105         mContext = new WeakReference<Context>(context);
106         mScannerCallback = callback;
107         mReader = new MultiFormatReader();
108         mReader.setHints(HINTS);
109     }
110 
111     /**
112      * The function start camera preview and capture pictures to decode QR code continuously in a
113      * background task.
114      *
115      * @param surface The surface to be used for live preview.
116      */
start(SurfaceTexture surface)117     public void start(SurfaceTexture surface) {
118         if (mDecodeTask == null) {
119             mDecodeTask = new DecodingTask(surface);
120             // Execute in the separate thread pool to prevent block other AsyncTask.
121             mDecodeTask.executeOnExecutor(Executors.newSingleThreadExecutor());
122         }
123     }
124 
125     /**
126      * The function stop camera preview and background decode task. Caller call this function when
127      * the surface is being destroyed.
128      */
stop()129     public void stop() {
130         removeMessages(MSG_AUTO_FOCUS);
131         if (mDecodeTask != null) {
132             mDecodeTask.cancel(true);
133             mDecodeTask = null;
134         }
135         if (mCamera != null) {
136             try {
137                 mCamera.stopPreview();
138                 releaseCamera();
139             } catch (RuntimeException e) {
140                 Log.e(TAG, "Stop previewing camera failed:" + e);
141                 mCamera = null;
142             }
143         }
144     }
145 
146     /** The scanner which includes this QrCodeCamera class should implement this */
147     public interface ScannerCallback {
148 
149         /**
150          * The function used to handle the decoding result of the QR code.
151          *
152          * @param result the result QR code after decoding.
153          */
handleSuccessfulResult(String result)154         void handleSuccessfulResult(String result);
155 
156         /** Request the QR code scanner to handle the failure happened. */
handleCameraFailure()157         void handleCameraFailure();
158 
159         /**
160          * The function used to get the background View size.
161          *
162          * @return Includes the background view size.
163          */
getViewSize()164         Size getViewSize();
165 
166         /**
167          * The function used to get the frame position inside the view
168          *
169          * @param previewSize       Is the preview size set by camera
170          * @param cameraOrientation Is the orientation of current Camera
171          * @return The rectangle would like to crop from the camera preview shot.
172          * @deprecated This is no longer used, and the frame position is
173          *     automatically calculated from the preview size and the
174          *     background View size.
175          */
176         @Deprecated
getFramePosition(@onNull Size previewSize, int cameraOrientation)177         default @NonNull Rect getFramePosition(@NonNull Size previewSize, int cameraOrientation) {
178             throw new AssertionError("getFramePosition shouldn't be used");
179         }
180 
181         /**
182          * Sets the transform to associate with preview area.
183          *
184          * @param transform The transform to apply to the content of preview
185          */
setTransform(Matrix transform)186         void setTransform(Matrix transform);
187 
188         /**
189          * Verify QR code is valid or not. The camera will stop scanning if this callback returns
190          * true.
191          *
192          * @param qrCode The result QR code after decoding.
193          * @return Returns true if qrCode hold valid information.
194          */
isValid(String qrCode)195         boolean isValid(String qrCode);
196     }
197 
setPreviewDisplayOrientation()198     private boolean setPreviewDisplayOrientation() {
199         if (mContext.get() == null) {
200             return false;
201         }
202 
203         final WindowManager winManager =
204                 (WindowManager) mContext.get().getSystemService(Context.WINDOW_SERVICE);
205         final int rotation = winManager.getDefaultDisplay().getRotation();
206         int degrees = 0;
207         switch (rotation) {
208             case Surface.ROTATION_0:
209                 degrees = 0;
210                 break;
211             case Surface.ROTATION_90:
212                 degrees = 90;
213                 break;
214             case Surface.ROTATION_180:
215                 degrees = 180;
216                 break;
217             case Surface.ROTATION_270:
218                 degrees = 270;
219                 break;
220         }
221         int rotateDegrees = 0;
222         if (mCameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
223             rotateDegrees = (mCameraInfo.orientation + degrees) % 360;
224             rotateDegrees = (360 - rotateDegrees) % 360;  // compensate the mirror
225         } else {
226             rotateDegrees = (mCameraInfo.orientation - degrees + 360) % 360;
227         }
228         mCamera.setDisplayOrientation(rotateDegrees);
229         mPreviewInPortrait = (rotateDegrees == 90 || rotateDegrees == 270);
230         return true;
231     }
232 
233     @VisibleForTesting
setCameraParameter()234     void setCameraParameter() {
235         mParameters = mCamera.getParameters();
236         mPreviewSize = getBestPreviewSize(mParameters);
237         mParameters.setPreviewSize(mPreviewSize.getWidth(), mPreviewSize.getHeight());
238         Size pictureSize = getBestPictureSize(mParameters);
239         mParameters.setPictureSize(pictureSize.getWidth(), pictureSize.getHeight());
240 
241         final List<String> supportedFlashModes = mParameters.getSupportedFlashModes();
242         if (supportedFlashModes != null &&
243                 supportedFlashModes.contains(Camera.Parameters.FLASH_MODE_OFF)) {
244             mParameters.setFlashMode(Camera.Parameters.FLASH_MODE_OFF);
245         }
246 
247         final List<String> supportedFocusModes = mParameters.getSupportedFocusModes();
248         if (supportedFocusModes.contains(Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE)) {
249             mParameters.setFocusMode(Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE);
250         } else if (supportedFocusModes.contains(Camera.Parameters.FOCUS_MODE_AUTO)) {
251             mParameters.setFocusMode(Camera.Parameters.FOCUS_MODE_AUTO);
252         }
253         mCamera.setParameters(mParameters);
254     }
255 
256     /**
257      * Set transform matrix to crop and center the preview picture.
258      */
setTransformationMatrix()259     private void setTransformationMatrix() {
260         final Size previewDisplaySize = rotateIfPortrait(mPreviewSize);
261         final Size viewSize = mScannerCallback.getViewSize();
262         final Rect cropRegion = calculateCenteredCrop(previewDisplaySize, viewSize);
263 
264         // Note that strictly speaking, since the preview is mirrored in front
265         // camera case, we should also mirror the crop region here. But since
266         // we're cropping at the center, mirroring would result in the same
267         // crop region other than small off-by-one error from floating point
268         // calculation and wouldn't be noticeable.
269 
270         // Calculate transformation matrix.
271         float scaleX = previewDisplaySize.getWidth() / (float) cropRegion.width();
272         float scaleY = previewDisplaySize.getHeight() / (float) cropRegion.height();
273         float translateX = -cropRegion.left / (float) cropRegion.width() * viewSize.getWidth();
274         float translateY = -cropRegion.top / (float) cropRegion.height() * viewSize.getHeight();
275 
276         // Set the transform matrix.
277         final Matrix matrix = new Matrix();
278         matrix.setScale(scaleX, scaleY);
279         matrix.postTranslate(translateX, translateY);
280         mScannerCallback.setTransform(matrix);
281     }
282 
startPreview()283     private void startPreview() {
284         mCamera.startPreview();
285         if (Camera.Parameters.FOCUS_MODE_AUTO.equals(mParameters.getFocusMode())) {
286             mCamera.autoFocus(/* Camera.AutoFocusCallback */ null);
287             sendMessageDelayed(obtainMessage(MSG_AUTO_FOCUS), AUTOFOCUS_INTERVAL_MS);
288         }
289     }
290 
291     private class DecodingTask extends AsyncTask<Void, Void, String> {
292         private QrYuvLuminanceSource mImage;
293         private SurfaceTexture mSurface;
294 
DecodingTask(SurfaceTexture surface)295         private DecodingTask(SurfaceTexture surface) {
296             mSurface = surface;
297         }
298 
299         @Override
doInBackground(Void... tmp)300         protected String doInBackground(Void... tmp) {
301             if (!initCamera(mSurface)) {
302                 return null;
303             }
304 
305             final Semaphore imageGot = new Semaphore(0);
306             while (true) {
307                 // This loop will try to capture preview image continuously until a valid QR Code
308                 // decoded. The caller can also call {@link #stop()} to interrupts scanning loop.
309                 mCamera.setOneShotPreviewCallback(
310                         (imageData, camera) -> {
311                             mImage = getFrameImage(imageData);
312                             imageGot.release();
313                         });
314                 try {
315                     // Semaphore.acquire() blocking until permit is available, or the thread is
316                     // interrupted.
317                     imageGot.acquire();
318                     Result qrCode = decodeQrCode(mImage);
319                     if (qrCode == null) {
320                         // Check color inversion QR code
321                         qrCode = decodeQrCode(mImage.invert());
322                     }
323                     if (qrCode != null) {
324                         if (mScannerCallback.isValid(qrCode.getText())) {
325                             return qrCode.getText();
326                         }
327                     }
328                 } catch (InterruptedException e) {
329                     Thread.currentThread().interrupt();
330                     return null;
331                 }
332             }
333         }
334 
decodeQrCode(LuminanceSource source)335         private Result decodeQrCode(LuminanceSource source) {
336             try {
337                 return mReader.decodeWithState(new BinaryBitmap(new HybridBinarizer(source)));
338             } catch (ReaderException e) {
339                 // No logging since every time the reader cannot decode the
340                 // image, this ReaderException will be thrown.
341             } finally {
342                 mReader.reset();
343             }
344             return null;
345         }
346 
347         @Override
onPostExecute(String qrCode)348         protected void onPostExecute(String qrCode) {
349             if (qrCode != null) {
350                 mScannerCallback.handleSuccessfulResult(qrCode);
351             }
352         }
353 
initCamera(SurfaceTexture surface)354         private boolean initCamera(SurfaceTexture surface) {
355             final int numberOfCameras = Camera.getNumberOfCameras();
356             Camera.CameraInfo cameraInfo = new Camera.CameraInfo();
357             try {
358                 for (int i = 0; i < numberOfCameras; ++i) {
359                     Camera.getCameraInfo(i, cameraInfo);
360                     if (cameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_BACK) {
361                         releaseCamera();
362                         mCamera = Camera.open(i);
363                         mCameraInfo = cameraInfo;
364                         break;
365                     }
366                 }
367                 if (mCamera == null && numberOfCameras > 0) {
368                     Log.i(TAG, "Can't find back camera. Opening a different camera");
369                     Camera.getCameraInfo(0, cameraInfo);
370                     releaseCamera();
371                     mCamera = Camera.open(0);
372                     mCameraInfo = cameraInfo;
373                 }
374             } catch (RuntimeException e) {
375                 Log.e(TAG, "Fail to open camera: " + e);
376                 mCamera = null;
377                 mScannerCallback.handleCameraFailure();
378                 return false;
379             }
380 
381             try {
382                 if (mCamera == null) {
383                     throw new IOException("Cannot find available camera");
384                 }
385                 mCamera.setPreviewTexture(surface);
386                 if (!setPreviewDisplayOrientation()) {
387                     throw new IOException("Lost context");
388                 }
389                 setCameraParameter();
390                 setTransformationMatrix();
391                 startPreview();
392             } catch (IOException ioe) {
393                 Log.e(TAG, "Fail to startPreview camera: " + ioe);
394                 mCamera = null;
395                 mScannerCallback.handleCameraFailure();
396                 return false;
397             }
398             return true;
399         }
400     }
401 
releaseCamera()402     private void releaseCamera() {
403         if (mCamera != null) {
404             mCamera.release();
405             mCamera = null;
406         }
407     }
408 
409     /**
410      * Calculates the crop region in `previewSize` to have the same aspect
411      * ratio as `viewSize` and center aligned.
412      */
calculateCenteredCrop(Size previewSize, Size viewSize)413     private Rect calculateCenteredCrop(Size previewSize, Size viewSize) {
414         final double previewRatio = getRatio(previewSize);
415         final double viewRatio = getRatio(viewSize);
416         int width;
417         int height;
418         if (previewRatio > viewRatio) {
419             width = previewSize.getWidth();
420             height = (int) Math.round(width * viewRatio);
421         } else {
422             height = previewSize.getHeight();
423             width = (int) Math.round(height / viewRatio);
424         }
425         final int left = (previewSize.getWidth() - width) / 2;
426         final int top = (previewSize.getHeight() - height) / 2;
427         return new Rect(left, top, left + width, top + height);
428     }
429 
getFrameImage(byte[] imageData)430     private QrYuvLuminanceSource getFrameImage(byte[] imageData) {
431         final Size viewSize = mScannerCallback.getViewSize();
432         final Rect frame = calculateCenteredCrop(mPreviewSize, rotateIfPortrait(viewSize));
433         final QrYuvLuminanceSource image = new QrYuvLuminanceSource(imageData,
434                 mPreviewSize.getWidth(), mPreviewSize.getHeight());
435         return (QrYuvLuminanceSource)
436                 image.crop(frame.left, frame.top, frame.width(), frame.height());
437     }
438 
439     @Override
handleMessage(Message msg)440     public void handleMessage(Message msg) {
441         switch (msg.what) {
442             case MSG_AUTO_FOCUS:
443                 // Calling autoFocus(null) will only trigger the camera to focus once. In order
444                 // to make the camera continuously auto focus during scanning, need to periodically
445                 // trigger it.
446                 mCamera.autoFocus(/* Camera.AutoFocusCallback */ null);
447                 sendMessageDelayed(obtainMessage(MSG_AUTO_FOCUS), AUTOFOCUS_INTERVAL_MS);
448                 break;
449             default:
450                 Log.d(TAG, "Unexpected Message: " + msg.what);
451         }
452     }
453 
454     /**
455      * Get best preview size from the list of camera supported preview sizes. Compares the
456      * preview size and aspect ratio to choose the best one.
457      */
getBestPreviewSize(Camera.Parameters parameters)458     private Size getBestPreviewSize(Camera.Parameters parameters) {
459         final double minRatioDiffPercent = 0.1;
460         final Size viewSize = rotateIfPortrait(mScannerCallback.getViewSize());
461         final double viewRatio = getRatio(viewSize);
462         double bestChoiceRatio = 0;
463         Size bestChoice = new Size(0, 0);
464         for (Camera.Size size : parameters.getSupportedPreviewSizes()) {
465             final Size newSize = toAndroidSize(size);
466             final double ratio = getRatio(newSize);
467             if (size.height * size.width > bestChoice.getWidth() * bestChoice.getHeight()
468                     && (Math.abs(bestChoiceRatio - viewRatio) / viewRatio > minRatioDiffPercent
469                     || Math.abs(ratio - viewRatio) / viewRatio <= minRatioDiffPercent)) {
470                 bestChoice = newSize;
471                 bestChoiceRatio = ratio;
472             }
473         }
474         return bestChoice;
475     }
476 
477     /**
478      * Get best picture size from the list of camera supported picture sizes. Compares the
479      * picture size and aspect ratio to choose the best one.
480      */
getBestPictureSize(Camera.Parameters parameters)481     private Size getBestPictureSize(Camera.Parameters parameters) {
482         final Size previewSize = mPreviewSize;
483         final double previewRatio = getRatio(previewSize);
484         List<Size> bestChoices = new ArrayList<>();
485         final List<Size> similarChoices = new ArrayList<>();
486 
487         // Filter by ratio
488         for (Camera.Size picSize : parameters.getSupportedPictureSizes()) {
489             final Size size = toAndroidSize(picSize);
490             final double ratio = getRatio(size);
491             if (ratio == previewRatio) {
492                 bestChoices.add(size);
493             } else if (Math.abs(ratio - previewRatio) < MAX_RATIO_DIFF) {
494                 similarChoices.add(size);
495             }
496         }
497 
498         if (bestChoices.size() == 0 && similarChoices.size() == 0) {
499             Log.d(TAG, "No proper picture size, return default picture size");
500             Camera.Size defaultPictureSize = parameters.getPictureSize();
501             return toAndroidSize(defaultPictureSize);
502         }
503 
504         if (bestChoices.size() == 0) {
505             bestChoices = similarChoices;
506         }
507 
508         // Get the best by area
509         int bestAreaDifference = Integer.MAX_VALUE;
510         Size bestChoice = null;
511         final int previewArea = previewSize.getWidth() * previewSize.getHeight();
512         for (Size size : bestChoices) {
513             int areaDifference = Math.abs(size.getWidth() * size.getHeight() - previewArea);
514             if (areaDifference < bestAreaDifference) {
515                 bestAreaDifference = areaDifference;
516                 bestChoice = size;
517             }
518         }
519         return bestChoice;
520     }
521 
rotateIfPortrait(Size size)522     private Size rotateIfPortrait(Size size) {
523         if (mPreviewInPortrait) {
524             return new Size(size.getHeight(), size.getWidth());
525         } else {
526             return size;
527         }
528     }
529 
getRatio(Size size)530     private double getRatio(Size size) {
531         return size.getHeight() / (double) size.getWidth();
532     }
533 
toAndroidSize(Camera.Size size)534     private Size toAndroidSize(Camera.Size size) {
535         return new Size(size.width, size.height);
536     }
537 
538     @VisibleForTesting
decodeImage(BinaryBitmap image)539     protected void decodeImage(BinaryBitmap image) {
540         Result qrCode = null;
541 
542         try {
543             qrCode = mReader.decodeWithState(image);
544         } catch (ReaderException e) {
545         } finally {
546             mReader.reset();
547         }
548 
549         if (qrCode != null) {
550             mScannerCallback.handleSuccessfulResult(qrCode.getText());
551         }
552     }
553 
554     /**
555      * After {@link #start(SurfaceTexture)}, DecodingTask runs continuously to capture images and
556      * decode QR code. DecodingTask become null After {@link #stop()}.
557      *
558      * Uses this method in test case to prevent power consumption problem.
559      */
isDecodeTaskAlive()560     public boolean isDecodeTaskAlive() {
561         return mDecodeTask != null;
562     }
563 }
564