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