diff --git a/briar-android/res/layout/fragment_keyagreement_qr.xml b/briar-android/res/layout/fragment_keyagreement_qr.xml index 5f1b175ed567f5c794729da1aa04516f2996a9c6..aa901c67385cb8650d776456cadff4f08690cdc5 100644 --- a/briar-android/res/layout/fragment_keyagreement_qr.xml +++ b/briar-android/res/layout/fragment_keyagreement_qr.xml @@ -10,6 +10,11 @@ android:layout_width="match_parent" android:layout_height="match_parent"/> + <org.briarproject.android.util.ViewfinderView + android:id="@+id/viewfinder_view" + android:layout_width="match_parent" + android:layout_height="match_parent"/> + <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" diff --git a/briar-android/res/values/color.xml b/briar-android/res/values/color.xml index 3fb50397850e50e9d086b0b5f65a72353f1a131d..613b553d822a906d423af040e267616deb8b2d7a 100644 --- a/briar-android/res/values/color.xml +++ b/briar-android/res/values/color.xml @@ -43,4 +43,10 @@ <color name="spinner_border">#61000000</color> <!-- 38% Black --> <color name="spinner_arrow">@color/briar_blue_dark</color> + + <!-- ViewfinderView --> + <color name="possible_result_points">#c0ffbd21</color> <!-- Material Yellow 700 with alpha --> + <color name="result_view">#b0000000</color> + <color name="viewfinder_laser">#d50000</color> <!-- Red accent 700 --> + <color name="viewfinder_mask">#60000000</color> </resources> \ No newline at end of file diff --git a/briar-android/src/org/briarproject/android/keyagreement/ShowQrCodeFragment.java b/briar-android/src/org/briarproject/android/keyagreement/ShowQrCodeFragment.java index 60c7ca85bd794414fa1dd27d2b0ddce1fe455a66..e16c4973d156e54dd740720c164c5e92eaa3c515 100644 --- a/briar-android/src/org/briarproject/android/keyagreement/ShowQrCodeFragment.java +++ b/briar-android/src/org/briarproject/android/keyagreement/ShowQrCodeFragment.java @@ -19,6 +19,8 @@ import android.widget.TextView; import android.widget.Toast; import com.google.zxing.Result; +import com.google.zxing.ResultPoint; +import com.google.zxing.ResultPointCallback; import org.briarproject.R; import org.briarproject.android.AndroidComponent; @@ -27,6 +29,7 @@ import org.briarproject.android.fragment.BaseEventFragment; import org.briarproject.android.util.CameraView; import org.briarproject.android.util.QrCodeDecoder; import org.briarproject.android.util.QrCodeUtils; +import org.briarproject.android.util.ViewfinderView; import org.briarproject.api.event.Event; import org.briarproject.api.event.KeyAgreementAbortedEvent; import org.briarproject.api.event.KeyAgreementFailedEvent; @@ -55,7 +58,7 @@ import static java.util.logging.Level.WARNING; @SuppressWarnings("deprecation") public class ShowQrCodeFragment extends BaseEventFragment - implements QrCodeDecoder.ResultCallback { + implements QrCodeDecoder.ResultCallback, ResultPointCallback { public static final String TAG = "ShowQrCodeFragment"; @@ -75,6 +78,7 @@ public class ShowQrCodeFragment extends BaseEventFragment protected Executor ioExecutor; private CameraView cameraView; + private ViewfinderView viewfinderView; private View statusView; private TextView status; private ImageView qrCode; @@ -109,9 +113,13 @@ public class ShowQrCodeFragment extends BaseEventFragment super.onViewCreated(view, savedInstanceState); cameraView = (CameraView) view.findViewById(R.id.camera_view); + viewfinderView = + (ViewfinderView) view.findViewById(R.id.viewfinder_view); statusView = view.findViewById(R.id.status_container); status = (TextView) view.findViewById(R.id.connect_status); qrCode = (ImageView) view.findViewById(R.id.qr_code); + + viewfinderView.setFrameProvider(cameraView); } @Override @@ -120,7 +128,7 @@ public class ShowQrCodeFragment extends BaseEventFragment getActivity().setRequestedOrientation(SCREEN_ORIENTATION_NOSENSOR); - decoder = new QrCodeDecoder(this); + decoder = new QrCodeDecoder(this, this); } @Override @@ -219,6 +227,7 @@ public class ShowQrCodeFragment extends BaseEventFragment getActivity().finish(); } else { cameraView.start(camera, decoder, 0); + viewfinderView.drawViewfinder(); } } }; @@ -355,6 +364,11 @@ public class ShowQrCodeFragment extends BaseEventFragment }); } + @Override + public void foundPossibleResultPoint(ResultPoint point) { + viewfinderView.addPossibleResultPoint(point); + } + private class BluetoothStateReceiver extends BroadcastReceiver { @Override diff --git a/briar-android/src/org/briarproject/android/util/CameraView.java b/briar-android/src/org/briarproject/android/util/CameraView.java index d3690b1895a78b7d0993c9533b2685fb490a6e83..dfec325af3ae1a1353400f1052159c9fbd716d9b 100644 --- a/briar-android/src/org/briarproject/android/util/CameraView.java +++ b/briar-android/src/org/briarproject/android/util/CameraView.java @@ -1,6 +1,8 @@ package org.briarproject.android.util; import android.content.Context; +import android.graphics.Point; +import android.graphics.Rect; import android.hardware.Camera; import android.hardware.Camera.AutoFocusCallback; import android.hardware.Camera.CameraInfo; @@ -13,6 +15,7 @@ import android.view.SurfaceHolder; import android.view.SurfaceView; import java.io.IOException; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.logging.Logger; @@ -31,17 +34,25 @@ import static java.util.logging.Level.WARNING; @SuppressWarnings("deprecation") public class CameraView extends SurfaceView implements SurfaceHolder.Callback, - AutoFocusCallback { + AutoFocusCallback, ViewfinderView.FrameProvider { private static final int AUTO_FOCUS_RETRY_DELAY = 5000; // Milliseconds + private static final int MIN_FRAME_SIZE = 240; + private static final int MAX_FRAME_SIZE = 675; // = 5/8 * 1080 private static final Logger LOG = Logger.getLogger(CameraView.class.getName()); private Camera camera = null; + private Rect framingRect; + private Rect framingRectInPreview; + private Rect framingRectInSensor; private PreviewConsumer previewConsumer = null; private int displayOrientation = 0, surfaceWidth = 0, surfaceHeight = 0; private boolean autoFocus = false, surfaceExists = false; + private Point cameraResolution; + private final Object cameraResolutionLock = new Object(); + public CameraView(Context context) { super(context); } @@ -184,6 +195,24 @@ public class CameraView extends SurfaceView implements SurfaceHolder.Callback, LOG.info("No suitable focus mode"); } params.setZoom(0); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { + List<Camera.Area> areas = new ArrayList<>(); + areas.add(new Camera.Area(getFramingRectInSensor(), 1000)); + if (params.getMaxNumFocusAreas() > 0) { + if (LOG.isLoggable(INFO)) { + LOG.info("Focus areas supported: " + + params.getMaxNumFocusAreas()); + } + params.setFocusAreas(areas); + } + if (params.getMaxNumMeteringAreas() > 0) { + if (LOG.isLoggable(INFO)) { + LOG.info("Metering areas supported: " + + params.getMaxNumMeteringAreas()); + } + params.setMeteringAreas(areas); + } + } } private void setPreviewSize(Parameters params) { @@ -222,6 +251,13 @@ public class CameraView extends SurfaceView implements SurfaceHolder.Callback, if (LOG.isLoggable(INFO)) LOG.info("Best size " + bestSize.width + "x" + bestSize.height); params.setPreviewSize(bestSize.width, bestSize.height); + synchronized (cameraResolutionLock) { + cameraResolution = new Point(bestSize.width, bestSize.height); + } + } else { + synchronized (cameraResolutionLock) { + cameraResolution = null; + } } } @@ -276,4 +312,152 @@ public class CameraView extends SurfaceView implements SurfaceHolder.Callback, LOG.log(WARNING, "Error retrying auto focus", e); } } + + /** + * Calculates the framing rect which the UI should draw to show the user where to place the + * barcode. This target helps with alignment as well as forces the user to hold the device + * far enough away to ensure the image will be in focus. + * + * @return The rectangle to draw on screen in window coordinates. + */ + @Override + public Rect getFramingRect() { + if (framingRect == null) { + framingRect = calculateFramingRect(true); + if (LOG.isLoggable(INFO)) + LOG.info("Calculated framing rect: " + framingRect); + } + return framingRect; + } + + /** + * Calculates the framing rect which the UI should draw to show the user where to place the + * barcode. This target helps with alignment as well as forces the user to hold the device + * far enough away to ensure the image will be in focus. + * <p/> + * Adapted from the Zxing Barcode Scanner. + * + * @return The rectangle to draw on screen in window coordinates. + */ + private Rect calculateFramingRect(boolean withOrientation) { + if (camera == null) { + return null; + } + if (surfaceWidth == 0 || surfaceHeight == 0) { + // Called early, before the surface is ready + return null; + } + + boolean portrait = + withOrientation && displayOrientation % 180 == 90; + int size = findDesiredDimensionInRange( + portrait ? surfaceWidth : surfaceHeight, + portrait ? surfaceHeight / 2 : surfaceWidth / 2, + MIN_FRAME_SIZE, MAX_FRAME_SIZE); + + int leftOffset = portrait ? + (surfaceWidth - size) / 2 : + ((surfaceWidth / 2) - size) / 2; + int topOffset = portrait ? + ((surfaceHeight / 2) - size) / 2 : + (surfaceHeight - size) / 2; + return new Rect(leftOffset, topOffset, leftOffset + size, + topOffset + size); + } + + /** + * Calculates the square that fits best inside the given region. + */ + private static int findDesiredDimensionInRange(int side1, int side2, + int hardMin, int hardMax) { + if (LOG.isLoggable(INFO)) + LOG.info("Finding framing dimension, side1 = " + side1 + + ", side2 = " + side2); + int minSide = Math.min(side1, side2); + int dim = 5 * minSide / 8; // Target 5/8 of smallest side + if (dim < hardMin) { + if (hardMin > minSide) { + if (LOG.isLoggable(INFO)) + LOG.info("Returning minimum side length: " + minSide); + return minSide; + } else { + if (LOG.isLoggable(INFO)) + LOG.info("Returning hard minimum: " + hardMin); + return hardMin; + } + } + if (dim > hardMax) { + if (LOG.isLoggable(INFO)) + LOG.info("Returning hard maximum: " + hardMax); + return hardMax; + } + if (LOG.isLoggable(INFO)) + LOG.info("Returning desired dimension: " + dim); + return dim; + } + + /** + * Like {@link #getFramingRect} but coordinates are in terms of the preview + * frame, not UI / screen. + * <p/> + * Adapted from the Zxing Barcode Scanner. + * + * @return {@link Rect} expressing QR code scan area in terms of the preview size + */ + @Override + public Rect getFramingRectInPreview() { + if (framingRectInPreview == null) { + Rect framingRect = getFramingRect(); + if (framingRect == null) { + return null; + } + Rect rect = new Rect(framingRect); + Point cameraResolution = getCameraResolution(); + if (cameraResolution == null || surfaceWidth == 0 || + surfaceHeight == 0) { + // Called early, before the surface is ready + return null; + } + rect.left = rect.left * cameraResolution.x / surfaceWidth; + rect.right = rect.right * cameraResolution.x / surfaceWidth; + rect.top = rect.top * cameraResolution.y / surfaceHeight; + rect.bottom = rect.bottom * cameraResolution.y / surfaceHeight; + framingRectInPreview = rect; + } + return framingRectInPreview; + } + + private Point getCameraResolution() { + Point ret; + synchronized (cameraResolutionLock) { + ret = new Point(cameraResolution); + } + return ret; + } + + /** + * Like {@link #getFramingRect} but coordinates are in terms of the sensor, + * not UI / screen (ie. it is independent of orientation) + * + * @return {@link Rect} expressing QR code scan area in terms of the sensor + */ + private Rect getFramingRectInSensor() { + if (framingRectInSensor == null) { + Rect framingRect = calculateFramingRect(false); + if (framingRect == null) { + return null; + } + Rect rect = new Rect(framingRect); + if (surfaceWidth == 0 || surfaceHeight == 0) { + // Called early, before the surface is ready + return null; + } + rect.left = (rect.left * 2000 / surfaceWidth) - 1000; + rect.right = (rect.right * 2000 / surfaceWidth) - 1000; + rect.top = (rect.top * 2000 / surfaceHeight) - 1000; + rect.bottom = (rect.bottom * 2000 / surfaceHeight) - 1000; + framingRectInSensor = rect; + } + return framingRectInSensor; + } } \ No newline at end of file diff --git a/briar-android/src/org/briarproject/android/util/QrCodeDecoder.java b/briar-android/src/org/briarproject/android/util/QrCodeDecoder.java index 207adf368c342e1ac26fda83193d5443b3d8a219..a6d65849aca9953004a0fb19653056e86c8ca263 100644 --- a/briar-android/src/org/briarproject/android/util/QrCodeDecoder.java +++ b/briar-android/src/org/briarproject/android/util/QrCodeDecoder.java @@ -6,14 +6,18 @@ import android.hardware.Camera.Size; import android.os.AsyncTask; import com.google.zxing.BinaryBitmap; +import com.google.zxing.DecodeHintType; import com.google.zxing.LuminanceSource; import com.google.zxing.PlanarYUVLuminanceSource; import com.google.zxing.Reader; import com.google.zxing.ReaderException; import com.google.zxing.Result; +import com.google.zxing.ResultPointCallback; import com.google.zxing.common.HybridBinarizer; import com.google.zxing.qrcode.QRCodeReader; +import java.util.HashMap; +import java.util.Map; import java.util.logging.Logger; import static java.util.logging.Level.INFO; @@ -26,11 +30,14 @@ public class QrCodeDecoder implements PreviewConsumer, PreviewCallback { private final Reader reader = new QRCodeReader(); private final ResultCallback callback; + private final ResultPointCallback pointCallback; private boolean stopped = false; - public QrCodeDecoder(ResultCallback callback) { + public QrCodeDecoder(ResultCallback callback, + ResultPointCallback pointCallback) { this.callback = callback; + this.pointCallback = pointCallback; } public void start(Camera camera) { @@ -72,9 +79,11 @@ public class QrCodeDecoder implements PreviewConsumer, PreviewCallback { LuminanceSource src = new PlanarYUVLuminanceSource(data, width, height, 0, 0, width, height, false); BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(src)); + Map<DecodeHintType, Object> hints = new HashMap<>(); + hints.put(DecodeHintType.NEED_RESULT_POINT_CALLBACK, pointCallback); Result result = null; try { - result = reader.decode(bitmap); + result = reader.decode(bitmap, hints); } catch (ReaderException e) { return null; // No barcode found } catch (RuntimeException e) { diff --git a/briar-android/src/org/briarproject/android/util/ViewfinderView.java b/briar-android/src/org/briarproject/android/util/ViewfinderView.java new file mode 100644 index 0000000000000000000000000000000000000000..0e1836392e05e902a9ca47d7c183e28d019a1972 --- /dev/null +++ b/briar-android/src/org/briarproject/android/util/ViewfinderView.java @@ -0,0 +1,214 @@ +/* + * Copyright (C) 2008 ZXing authors + * Copyright (C) 2016 Sublime Software Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.briarproject.android.util; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.util.AttributeSet; +import android.view.View; + +import com.google.zxing.ResultPoint; + +import org.briarproject.R; + +import java.util.ArrayList; +import java.util.List; + +/** + * This view is overlaid on top of the camera preview. It adds the viewfinder + * rectangle and partial transparency outside it, as well as the laser scanner + * animation and result points. + * + * @author dswitkin@google.com (Daniel Switkin) + */ +public final class ViewfinderView extends View { + + private static final int[] SCANNER_ALPHA = + {0, 64, 128, 192, 255, 192, 128, 64}; + private static final long ANIMATION_DELAY = 80L; + private static final int CURRENT_POINT_OPACITY = 0xA0; + private static final int MAX_RESULT_POINTS = 20; + private static final int POINT_SIZE = 6; + + private FrameProvider frameProvider; + private final Paint paint; + private Bitmap resultBitmap; + private final int maskColor; + private final int resultColor; + private final int laserColor; + private final int resultPointColor; + private int scannerAlpha; + private List<ResultPoint> possibleResultPoints; + private List<ResultPoint> lastPossibleResultPoints; + + // This constructor is used when the class is built from an XML resource. + public ViewfinderView(Context context, AttributeSet attrs) { + super(context, attrs); + if (isInEditMode()) { + paint = null; + maskColor = 0; + resultColor = 0; + laserColor = 0; + resultPointColor = 0; + return; + } + + // Initialize these once for performance rather than calling them every + // time in onDraw(). + paint = new Paint(Paint.ANTI_ALIAS_FLAG); + Resources resources = getResources(); + maskColor = resources.getColor(R.color.viewfinder_mask); + resultColor = resources.getColor(R.color.result_view); + laserColor = resources.getColor(R.color.viewfinder_laser); + resultPointColor = resources.getColor(R.color.possible_result_points); + scannerAlpha = 0; + possibleResultPoints = new ArrayList<>(5); + lastPossibleResultPoints = null; + } + + public void setFrameProvider(FrameProvider frameProvider) { + this.frameProvider = frameProvider; + } + + @SuppressLint("DrawAllocation") + @Override + public void onDraw(Canvas canvas) { + if (frameProvider == null) { + return; // not ready yet, early draw before done configuring + } + Rect frame = this.frameProvider.getFramingRect(); + Rect previewFrame = this.frameProvider.getFramingRectInPreview(); + if (frame == null || previewFrame == null) { + return; + } + int width = canvas.getWidth(); + int height = canvas.getHeight(); + + // Draw the exterior (i.e. outside the framing rect) darkened + paint.setColor(resultBitmap != null ? resultColor : maskColor); + canvas.drawRect(0, 0, width, frame.top, paint); + canvas.drawRect(0, frame.top, frame.left, frame.bottom + 1, paint); + canvas.drawRect(frame.right + 1, frame.top, width, frame.bottom + 1, + paint); + canvas.drawRect(0, frame.bottom + 1, width, height, paint); + + if (resultBitmap != null) { + // Draw the opaque result bitmap over the scanning rectangle + paint.setAlpha(CURRENT_POINT_OPACITY); + canvas.drawBitmap(resultBitmap, null, frame, paint); + } else { + + // Draw a red "laser scanner" line through the middle to show + // decoding is active + paint.setColor(laserColor); + paint.setAlpha(SCANNER_ALPHA[scannerAlpha]); + scannerAlpha = (scannerAlpha + 1) % SCANNER_ALPHA.length; + int middle = frame.height() / 2 + frame.top; + canvas.drawRect(frame.left + 2, middle - 1, frame.right - 1, + middle + 2, paint); + + float scaleX = frame.width() / (float) previewFrame.width(); + float scaleY = frame.height() / (float) previewFrame.height(); + + List<ResultPoint> currentPossible = possibleResultPoints; + List<ResultPoint> currentLast = lastPossibleResultPoints; + int frameLeft = frame.left; + int frameTop = frame.top; + if (currentPossible.isEmpty()) { + lastPossibleResultPoints = null; + } else { + possibleResultPoints = new ArrayList<>(5); + lastPossibleResultPoints = currentPossible; + paint.setAlpha(CURRENT_POINT_OPACITY); + paint.setColor(resultPointColor); + synchronized (currentPossible) { + for (ResultPoint point : currentPossible) { + canvas.drawCircle( + frameLeft + (int) (point.getX() * scaleX), + frameTop + (int) (point.getY() * scaleY), + POINT_SIZE, paint); + } + } + } + if (currentLast != null) { + paint.setAlpha(CURRENT_POINT_OPACITY / 2); + paint.setColor(resultPointColor); + synchronized (currentLast) { + float radius = POINT_SIZE / 2.0f; + for (ResultPoint point : currentLast) { + canvas.drawCircle( + frameLeft + (int) (point.getX() * scaleX), + frameTop + (int) (point.getY() * scaleY), + radius, paint); + } + } + } + + // Request another update at the animation interval, but only + // repaint the laser line, not the entire viewfinder mask. + postInvalidateDelayed(ANIMATION_DELAY, + frame.left - POINT_SIZE, + frame.top - POINT_SIZE, + frame.right + POINT_SIZE, + frame.bottom + POINT_SIZE); + } + } + + public void drawViewfinder() { + Bitmap resultBitmap = this.resultBitmap; + this.resultBitmap = null; + if (resultBitmap != null) { + resultBitmap.recycle(); + } + invalidate(); + } + + /** + * Draw a bitmap with the result points highlighted instead of the live + * scanning display. + * + * @param barcode An image of the decoded barcode. + */ + public void drawResultBitmap(Bitmap barcode) { + resultBitmap = barcode; + invalidate(); + } + + public void addPossibleResultPoint(ResultPoint point) { + List<ResultPoint> points = possibleResultPoints; + synchronized (points) { + points.add(point); + int size = points.size(); + if (size > MAX_RESULT_POINTS) { + // trim it + points.subList(0, size - MAX_RESULT_POINTS / 2).clear(); + } + } + } + + public interface FrameProvider { + + Rect getFramingRect(); + Rect getFramingRectInPreview(); + } +}