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