diff --git a/app/AndroidManifest.xml b/app/AndroidManifest.xml
index f4a537c64b..f2c6086fd7 100644
--- a/app/AndroidManifest.xml
+++ b/app/AndroidManifest.xml
@@ -139,6 +139,10 @@
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="@string/fcm_default_notification_channel" />
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/res/values/attrs.xml b/app/res/values/attrs.xml
index 0e1fc186cf..e2c3dac6cc 100755
--- a/app/res/values/attrs.xml
+++ b/app/res/values/attrs.xml
@@ -53,5 +53,10 @@
-
+
+
+
+
+
+
diff --git a/app/res/values/colors.xml b/app/res/values/colors.xml
index d9e7aa88b9..9bb2c10ff1 100644
--- a/app/res/values/colors.xml
+++ b/app/res/values/colors.xml
@@ -86,6 +86,7 @@
#685C53
#D6D6D4
+ #80D6D6D4
#373534
diff --git a/app/res/values/strings.xml b/app/res/values/strings.xml
index daca5653d9..0698718060 100644
--- a/app/res/values/strings.xml
+++ b/app/res/values/strings.xml
@@ -461,4 +461,5 @@
notification-channel-push-notifications
Required CommCare App is not installed on device
Audio Recording Notification
+ Capture Face Picture
diff --git a/app/src/org/commcare/activities/FormEntryActivity.java b/app/src/org/commcare/activities/FormEntryActivity.java
index bd5a8a9bbb..55c9760f33 100644
--- a/app/src/org/commcare/activities/FormEntryActivity.java
+++ b/app/src/org/commcare/activities/FormEntryActivity.java
@@ -358,9 +358,8 @@ public void onActivityResultSessionSafe(int requestCode, int resultCode, Intent
Localization.get("intent.callout.unable.to.process"), Toast.LENGTH_SHORT).show();
}
break;
- case FormEntryConstants.IMAGE_CAPTURE:
- ImageCaptureProcessing.processCaptureResponse(this,
- FormEntryInstanceState.getInstanceFolder(), true);
+ case FormEntryConstants.IMAGE_CAPTURE, FormEntryConstants.MICRO_IMAGE_CAPTURE:
+ ImageCaptureProcessing.processCaptureResponse(this, FormEntryInstanceState.getInstanceFolder(), true);
break;
case FormEntryConstants.SIGNATURE_CAPTURE:
Logger.log(LogTypes.SOFT_ASSERT, "Signature captured successfully");
diff --git a/app/src/org/commcare/activities/components/FormEntryConstants.java b/app/src/org/commcare/activities/components/FormEntryConstants.java
index 2cf0818a96..f00bf8f57e 100644
--- a/app/src/org/commcare/activities/components/FormEntryConstants.java
+++ b/app/src/org/commcare/activities/components/FormEntryConstants.java
@@ -21,7 +21,7 @@ public class FormEntryConstants {
public static final int INTENT_LOCATION_PERMISSION = 14;
public static final int INTENT_LOCATION_EXCEPTION = 15;
public static final int VIEW_VIDEO_FULLSCREEN = 16;
-
+ public static final int MICRO_IMAGE_CAPTURE = 17;
public static final String NAV_STATE_NEXT = "next";
public static final String NAV_STATE_DONE = "done";
public static final String NAV_STATE_QUIT = "quit";
diff --git a/app/src/org/commcare/fragments/MicroImageActivity.java b/app/src/org/commcare/fragments/MicroImageActivity.java
new file mode 100644
index 0000000000..cc77036b5c
--- /dev/null
+++ b/app/src/org/commcare/fragments/MicroImageActivity.java
@@ -0,0 +1,190 @@
+package org.commcare.fragments;
+
+import android.annotation.SuppressLint;
+import android.graphics.Bitmap;
+import android.graphics.Rect;
+import android.media.Image;
+import android.os.Bundle;
+import android.util.Size;
+import android.widget.Toast;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.mlkit.common.MlKitException;
+import com.google.mlkit.vision.common.InputImage;
+import com.google.mlkit.vision.common.internal.ImageConvertUtils;
+import com.google.mlkit.vision.face.Face;
+import com.google.mlkit.vision.face.FaceDetection;
+import com.google.mlkit.vision.face.FaceDetector;
+import com.google.mlkit.vision.face.FaceDetectorOptions;
+
+import org.commcare.dalvik.R;
+import org.commcare.util.LogTypes;
+import org.commcare.utils.MediaUtil;
+import org.commcare.views.FaceCaptureView;
+import org.commcare.views.widgets.ImageWidget;
+import org.javarosa.core.services.Logger;
+import org.javarosa.core.services.locale.Localization;
+
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.camera.core.CameraSelector;
+import androidx.camera.core.ImageAnalysis;
+import androidx.camera.core.ImageProxy;
+import androidx.camera.core.Preview;
+import androidx.camera.core.UseCase;
+import androidx.camera.lifecycle.ProcessCameraProvider;
+import androidx.camera.view.PreviewView;
+import androidx.core.content.ContextCompat;
+
+public class MicroImageActivity extends AppCompatActivity implements ImageAnalysis.Analyzer, FaceCaptureView.ImageStabilizedListener {
+ private static final String TAG = MicroImageActivity.class.toString();
+ private PreviewView cameraView;
+ private FaceCaptureView faceCaptureView;
+ private Bitmap inputImage;
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.micro_image_widget);
+
+ faceCaptureView = findViewById(R.id.face_overlay);
+ cameraView = findViewById(R.id.view_finder);
+
+ ActionBar actionBar = getSupportActionBar();
+ if (actionBar != null) {
+ actionBar.setTitle(R.string.micro_image_activity_title);
+ }
+
+ faceCaptureView.setImageStabilizedListener(this);
+
+ try {
+ startCamera();
+ } catch (ExecutionException | InterruptedException e) {
+ logErrorAndExit("Error starting camera", "microimage.camera.start.failed", e);
+ }
+ }
+
+ private void startCamera() throws ExecutionException, InterruptedException {
+ ListenableFuture cameraProviderFuture = ProcessCameraProvider.getInstance(this);
+
+ cameraProviderFuture.addListener(() -> {
+ ProcessCameraProvider cameraProvider;
+ try {
+ cameraProvider = cameraProviderFuture.get();
+ } catch (ExecutionException | InterruptedException e) {
+ logErrorAndExit("Error acquiring camera provider", "microimage.camera.start.failed", e);
+ return;
+ }
+ bindUseCases(cameraProvider);
+ }, ContextCompat.getMainExecutor(this));
+ }
+
+ private void bindUseCases(ProcessCameraProvider cameraProvider) {
+ int targetRotation = getWindowManager().getDefaultDisplay().getRotation();
+ Size targetResolution = new Size(faceCaptureView.getImageWidth(), faceCaptureView.getImageHeight());
+
+ // Preview use case
+ Preview preview = new Preview.Builder()
+ .setTargetResolution(targetResolution)
+ .setTargetRotation(targetRotation)
+ .build();
+ preview.setSurfaceProvider(cameraView.getSurfaceProvider());
+
+ UseCase imageAnalyzer = buildImageAnalysisUseCase(targetResolution, targetRotation);
+
+ CameraSelector cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA;
+
+ // Unbind any previous use cases before binding new ones
+ cameraProvider.unbindAll();
+
+ // Bind the use cases to the camera
+ cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageAnalyzer);
+ }
+
+ private UseCase buildImageAnalysisUseCase(Size targetResolution, int targetRotation) {
+ ImageAnalysis imageAnalyzer = new ImageAnalysis.Builder()
+ .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
+ .setTargetResolution(targetResolution)
+ .setTargetRotation(targetRotation)
+ .build();
+
+ imageAnalyzer.setAnalyzer(ContextCompat.getMainExecutor(getApplicationContext()), this);
+ return imageAnalyzer;
+ }
+
+ private void logErrorAndExit(String logMessage, String userMessageKey, Throwable e) {
+ if (e == null) {
+ Logger.log(LogTypes.TYPE_EXCEPTION, logMessage);
+ } else {
+ Logger.exception(logMessage, e);
+ }
+ Toast.makeText(this, Localization.get(userMessageKey), Toast.LENGTH_LONG).show();
+ setResult(AppCompatActivity.RESULT_CANCELED);
+ finish();
+ }
+
+ @Override
+ public void analyze(@NonNull ImageProxy imageProxy) {
+ @SuppressLint("UnsafeOptInUsageError") Image mediaImage = imageProxy.getImage();
+ if (mediaImage != null) {
+ InputImage image = InputImage.fromMediaImage(mediaImage, imageProxy.getImageInfo().getRotationDegrees());
+
+ FaceDetectorOptions realTimeOpts = new FaceDetectorOptions.Builder()
+ .setContourMode(FaceDetectorOptions.CONTOUR_MODE_NONE)
+ .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE)
+ .build();
+ FaceDetector faceDetector = FaceDetection.getClient(realTimeOpts);
+ // process image with the face detector
+ faceDetector.process(image)
+ .addOnSuccessListener(faces -> processFaceDetectionResult(faces, image))
+ .addOnFailureListener(e -> handleErrorDuringDetection(e))
+ .addOnCompleteListener(task -> {
+ imageProxy.close();
+ });
+ } else {
+ imageProxy.close();
+ }
+ }
+
+ private void handleErrorDuringDetection(Exception e) {
+ Logger.exception("Error during face detection ", e);
+ Toast.makeText(this, "microimage.face.detection.mode.failed", Toast.LENGTH_LONG).show();
+ // TODO: decide whether to switch to manual mode or close activity
+ }
+
+ private void processFaceDetectionResult(List faces, InputImage image) {
+ if (faces.size() > 0) {
+ // Only one face is processed, this can be increased if needed
+ Face newFace = faces.get(0);
+
+ // this will draw a bounding circle around the first detected face
+ faceCaptureView.updateFace(newFace);
+ try {
+ inputImage = ImageConvertUtils.getInstance().convertToUpRightBitmap(image);
+ } catch (MlKitException e) {
+ Logger.exception("Error during face detection ", e);
+ Toast.makeText(this, "microimage.face.detection.mode.failed", Toast.LENGTH_LONG).show();
+ // TODO: decide whether to switch to manual mode or close activity?
+ }
+ } else {
+ faceCaptureView.updateFace(null);
+ }
+ }
+
+ @Override
+ public void onImageStabilizedListener(Rect faceArea) {
+ try {
+ MediaUtil.cropAndSaveImage(inputImage, faceArea, ImageWidget.getTempFileForImageCapture());
+ setResult(AppCompatActivity.RESULT_OK);
+ finish();
+ } catch (Exception e) {
+ logErrorAndExit(e.getMessage(), "microimage.cropping.failed", e.getCause());
+ }
+
+ }
+}
diff --git a/app/src/org/commcare/utils/MediaUtil.java b/app/src/org/commcare/utils/MediaUtil.java
index ce3763c94a..561f5bc19d 100644
--- a/app/src/org/commcare/utils/MediaUtil.java
+++ b/app/src/org/commcare/utils/MediaUtil.java
@@ -6,6 +6,8 @@
import android.graphics.BitmapFactory;
import android.media.AudioManager;
import android.os.Build;
+import android.graphics.Rect;
+import android.util.Base64;
import android.util.DisplayMetrics;
import android.util.Log;
import android.util.Pair;
@@ -20,6 +22,7 @@
import org.javarosa.core.reference.ReferenceManager;
import org.javarosa.core.services.Logger;
+import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.math.BigInteger;
@@ -39,7 +42,7 @@ public class MediaUtil {
public static final String FORM_VIDEO = "video";
public static final String FORM_AUDIO = "audio";
public static final String FORM_IMAGE = "image";
-
+ private static final int IMAGE_QUALIY_REDUCTION_FACTOR = 10;
/**
* Attempts to inflate an image from a CommCare UI definition source.
@@ -513,4 +516,64 @@ public static boolean isRecordingActive(Context context){
.getActiveRecordingConfigurations().size() > 0;
}
+ /**
+ * Crops an image according to a given area and saves the resulting image
+ */
+ public static void cropAndSaveImage(Bitmap bitmap, Rect cropArea, File imageFile) {
+ if (!validateCropArea(bitmap, cropArea)) {
+ throw new RuntimeException("Cropping failed due to invalid area!");
+ }
+
+ Bitmap croppedBitmap = Bitmap.createBitmap(bitmap, cropArea.left, cropArea.top,
+ cropArea.right - cropArea.left, cropArea.bottom - cropArea.top);
+ try {
+ FileUtil.writeBitmapToDiskAndCleanupHandles(croppedBitmap,
+ ImageType.fromExtension(FileUtil.getExtension(imageFile.getPath())), imageFile);
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to save image after cropping", e);
+ } finally {
+ if (croppedBitmap != bitmap) {
+ croppedBitmap.recycle();
+ }
+ }
+ }
+
+ private static boolean validateCropArea(Bitmap bitmap, Rect cropArea) {
+ if (bitmap.getHeight() >= cropArea.top && bitmap.getHeight() >= cropArea.bottom && bitmap.getWidth() >= cropArea.left && bitmap.getWidth() >= cropArea.right){
+ return true;
+ }
+ return false;
+ }
+
+ public static byte[] compressBitmapToTargetSize(Bitmap bitmap, int targetSize) throws IOException {
+ if (bitmap == null) {
+ return null;
+ }
+
+ byte[] byteArray = null;
+ int quality = 100;
+ int numCompressionCycles = 0;
+ while (quality != 0) {
+ try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
+ bitmap.compress(Bitmap.CompressFormat.WEBP, quality, baos);
+ byteArray = baos.toByteArray();
+ if (byteArray.length <= targetSize) {
+ break;
+ }
+ quality -= IMAGE_QUALIY_REDUCTION_FACTOR;
+ }
+ numCompressionCycles++;
+ }
+ Logger.log(LogTypes.TYPE_MEDIA_EVENT, "Micro image compressed successfully. Number of cycles: " + numCompressionCycles);
+ return byteArray;
+ }
+
+ public static Bitmap decodeBase64EncodedBitmap(String base64Image){
+ try {
+ byte[] decodedString = Base64.decode(base64Image, Base64.DEFAULT);
+ return BitmapFactory.decodeByteArray(decodedString, 0, decodedString.length);
+ } catch(Exception e){
+ return null;
+ }
+ }
}
diff --git a/app/src/org/commcare/views/FaceCaptureView.java b/app/src/org/commcare/views/FaceCaptureView.java
new file mode 100644
index 0000000000..2c8ac3ed3b
--- /dev/null
+++ b/app/src/org/commcare/views/FaceCaptureView.java
@@ -0,0 +1,278 @@
+package org.commcare.views;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.content.res.TypedArray;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Point;
+import android.graphics.PorterDuff;
+import android.graphics.PorterDuffXfermode;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.util.AttributeSet;
+import android.util.TypedValue;
+
+import com.google.mlkit.vision.face.Face;
+
+import org.commcare.dalvik.R;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.widget.AppCompatImageView;
+
+public class FaceCaptureView extends AppCompatImageView {
+
+ public interface ImageStabilizedListener {
+ void onImageStabilizedListener(Rect faceArea);
+ }
+
+ private int faceCaptureAreaDelimiterColor;
+ private int backgroundColor;
+ private int faceMarkerColor;
+ private int countdownTextSizeSp;
+ private RectF faceCaptureArea = null;
+ private int imageWidth;
+ private int imageHeight;
+ public static int DEFAULT_IMAGE_WIDTH = 480;
+ public static int DEFAULT_IMAGE_HEIGHT = 640;
+ private static float VIEW_CAPTURE_AREA_RATIO = 0.8f;
+ private Object lock = new Object();
+ private FaceOvalGraphic faceOvalGraphic;
+ private float postScaleHeightOffset;
+ private float postScaleWidthOffset;
+ private float scaleFactor;
+ private ImageStabilizedListener imageStabilizedListener;
+
+ public FaceCaptureView(Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+
+ loadViewAttribs(attrs);
+ int orientation = this.getResources().getConfiguration().orientation;
+ if (orientation == Configuration.ORIENTATION_PORTRAIT) {
+ imageWidth = DEFAULT_IMAGE_WIDTH;
+ imageHeight = DEFAULT_IMAGE_HEIGHT;
+ } else {
+ imageWidth = DEFAULT_IMAGE_HEIGHT;
+ imageHeight = DEFAULT_IMAGE_WIDTH;
+ }
+ }
+
+ private void loadViewAttribs(AttributeSet attrs) {
+ TypedArray typedArr = getContext().obtainStyledAttributes(attrs, R.styleable.FaceCaptureView);
+ try {
+ faceCaptureAreaDelimiterColor = typedArr.getColor(R.styleable.FaceCaptureView_face_capture_area_delimiter_color, Color.WHITE);
+ backgroundColor = typedArr.getColor(R.styleable.FaceCaptureView_background_color, Color.LTGRAY);
+ faceMarkerColor = typedArr.getColor(R.styleable.FaceCaptureView_face_marker_color, Color.GREEN);
+ countdownTextSizeSp = typedArr.getDimensionPixelSize(R.styleable.FaceCaptureView_countdown_text_size, -1);
+ } finally {
+ typedArr.recycle();
+ }
+ }
+
+ private void initCameraView(int viewWidth, int viewHeight){
+ setFaceCaptureArea(viewWidth, viewHeight);
+ calcScaleFactors(viewWidth, viewHeight);
+
+ Bitmap previewOverlay = Bitmap.createBitmap(viewWidth, viewHeight, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(previewOverlay);
+ canvas.drawColor(backgroundColor);
+
+ // draw capture area delimiter
+ Paint faceCapturePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ faceCapturePaint.setStyle(Paint.Style.STROKE);
+ faceCapturePaint.setColor(faceCaptureAreaDelimiterColor);
+ int squareWidth = (int)((faceCaptureArea.width() + faceCaptureArea.height()) / 2);
+ faceCapturePaint.setStrokeWidth(0.01f * squareWidth);
+ canvas.drawOval(faceCaptureArea, faceCapturePaint);
+
+ // draw clear oval
+ Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ paint.setStyle(Paint.Style.FILL);
+ paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
+ canvas.drawOval(faceCaptureArea, paint);
+
+ setImageBitmap(previewOverlay);
+
+ faceOvalGraphic = new FaceOvalGraphic();
+ }
+
+ public int getImageWidth() {
+ return imageWidth;
+ }
+
+ public int getImageHeight() {
+ return imageHeight;
+ }
+
+ @Override
+ protected void onSizeChanged(int w, int h, int oldw, int oldh) {
+ super.onSizeChanged(w, h, oldw, oldh);
+
+ if (w != 0 && h !=0) {
+ initCameraView(w, h);
+ }
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+
+ synchronized (lock) {
+ if (faceOvalGraphic != null) {
+ faceOvalGraphic.drawFaceOval(canvas);
+ }
+ }
+ }
+
+ public void updateFace(Face face) {
+ if (!faceOvalGraphic.isFaceBlank() || face != null) {
+ if (face == null) {
+ faceOvalGraphic.clearFace();
+ } else {
+ faceOvalGraphic.updateFace(face);
+ }
+ postInvalidate();
+ }
+ }
+
+ public void setImageStabilizedListener(ImageStabilizedListener imageStabilizedListener) {
+ this.imageStabilizedListener = imageStabilizedListener;
+ }
+
+ private void setFaceCaptureArea(int width, int height) {
+ int captureAreaWidth = (int)(width * VIEW_CAPTURE_AREA_RATIO);
+ int captureAreaHeigth = (int)(height * VIEW_CAPTURE_AREA_RATIO);
+
+ int captureAreaLeft = (width - captureAreaWidth) / 2;
+ int captureAreaTop = (height - captureAreaHeigth) / 2;
+ int captureAreaRight = captureAreaLeft + captureAreaWidth;
+ int captureAreaBottom = captureAreaTop + captureAreaHeigth;
+
+ faceCaptureArea = new RectF(captureAreaLeft, captureAreaTop, captureAreaRight, captureAreaBottom);
+ }
+
+ private void calcScaleFactors(int viewWidth, int viewHeight) {
+ float viewAspectRatio = (float) viewWidth / viewHeight;
+ float imageAspectRatio = (float) imageWidth / imageHeight;
+ postScaleWidthOffset = 0;
+ postScaleHeightOffset = 0;
+ if (viewAspectRatio > imageAspectRatio) {
+ // The image needs to be vertically cropped to be displayed in this view.
+ scaleFactor = (float) viewWidth / imageWidth;
+ postScaleHeightOffset = ((float) viewWidth / imageAspectRatio - viewHeight) / 2;
+ } else {
+ // The image needs to be horizontally cropped to be displayed in this view.
+ scaleFactor = (float) viewHeight / imageHeight;
+ postScaleWidthOffset = ((float) viewHeight * imageAspectRatio - viewWidth) / 2;
+ }
+ }
+
+ /**
+ * Translate coordinates from the preview's system to the view system.
+ */
+ private Rect translateFaceOvalCoordinates(Rect boundingBox){
+ float x0 = scaleX(boundingBox.left);
+ float y0 = scaleY(boundingBox.top);
+ float dx = scaleX(boundingBox.right);
+ float dy = scaleY(boundingBox.bottom);
+ return new Rect((int)x0, (int)y0, (int)dx, (int)dy);
+ }
+
+ private float scaleY(float vertical) {
+ return vertical * scaleFactor - postScaleHeightOffset;
+ }
+
+ private float scaleX(float horizontal) {
+ return horizontal * scaleFactor - postScaleWidthOffset;
+ }
+
+ private class FaceOvalGraphic {
+ private Paint faceAreaPaint;
+ private Face currFace;
+ private static final int IMAGE_STABILIZATION_BUFFER = 5;
+ private static final int COUNTDOWN_START = 3;
+ private int countdown = COUNTDOWN_START;
+ private Paint faceAreaTextPaint;
+
+ public FaceOvalGraphic(){
+ faceAreaPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ faceAreaPaint.setStyle(Paint.Style.STROKE);
+ faceAreaPaint.setColor(faceMarkerColor);
+ faceAreaPaint.setStrokeWidth(10);
+
+ faceAreaTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ faceAreaTextPaint.setTextSize((int)TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, countdownTextSizeSp, getResources().getDisplayMetrics()));
+ faceAreaTextPaint.setTextAlign(Paint.Align.CENTER);
+ faceAreaTextPaint.setStyle(Paint.Style.FILL_AND_STROKE);
+ faceAreaTextPaint.setColor(faceMarkerColor);
+ }
+
+ public void updateFace(Face face){
+ if (isFaceStable(face.getBoundingBox()) && isFaceInCaptureArea(face.getBoundingBox())) {
+ currFace = face;
+ countdown--;
+ } else {
+ clearFace();
+ }
+ }
+
+ public void clearFace(){
+ currFace = null;
+ countdown = COUNTDOWN_START;
+ }
+
+ public void drawFaceOval(Canvas canvas) {
+ if (!isFaceBlank()) {
+ Rect faceOvalCoord = translateFaceOvalCoordinates(currFace.getBoundingBox());
+ canvas.drawOval(faceOvalCoord.left, faceOvalCoord.top, faceOvalCoord.right, faceOvalCoord.bottom, faceAreaPaint);
+
+ Point textCoord = calcTextPosition(faceOvalCoord);
+ canvas.drawText(countdown != COUNTDOWN_START? String.valueOf(countdown):"", textCoord.x, textCoord.y, faceAreaTextPaint);
+
+ if (countdown == 0) {
+ imageStabilizedListener.onImageStabilizedListener(currFace.getBoundingBox());
+ }
+ }
+ }
+
+ private Point calcTextPosition(Rect faceOval) {
+ int xPos = faceOval.left + (faceOval.width() / 2);
+ int yPos = faceOval.top + (int) ((faceOval.height() / 2) - ((faceAreaTextPaint.descent() + faceAreaTextPaint.ascent()) / 2));
+ return new Point(xPos, yPos);
+ }
+
+ private boolean isFaceBlank() {
+ return currFace == null;
+ }
+
+ private boolean isFaceInCaptureArea(Rect faceCoords){
+ Rect faceViewCoords = translateFaceOvalCoordinates(faceCoords);
+ if ((faceViewCoords.left < faceCaptureArea.left) ||
+ (faceViewCoords.top < faceCaptureArea.top) ||
+ (faceViewCoords.right > faceCaptureArea.right) ||
+ (faceViewCoords.bottom > faceCaptureArea.bottom)) {
+ return false;
+ }
+ return true;
+ }
+
+ private boolean isFaceStable(Rect newFaceArea) {
+ if (currFace == null || (currFace != null && areRectsEqual(newFaceArea, currFace.getBoundingBox()))) {
+ return true;
+ }
+ return false;
+ }
+
+ private boolean areRectsEqual(Rect a, Rect b) {
+ if ((Math.abs(a.left - b.left) < IMAGE_STABILIZATION_BUFFER) &&
+ (Math.abs(a.top - b.top) < IMAGE_STABILIZATION_BUFFER) &&
+ (Math.abs(a.right - b.right) < IMAGE_STABILIZATION_BUFFER) &&
+ (Math.abs(a.bottom - b.bottom) < IMAGE_STABILIZATION_BUFFER)) {
+ return true;
+ }
+ return false;
+ }
+ }
+}
diff --git a/app/src/org/commcare/views/widgets/ImageWidget.java b/app/src/org/commcare/views/widgets/ImageWidget.java
index 95d883a0c4..dccc01b8f1 100644
--- a/app/src/org/commcare/views/widgets/ImageWidget.java
+++ b/app/src/org/commcare/views/widgets/ImageWidget.java
@@ -64,18 +64,18 @@ public class ImageWidget extends QuestionWidget {
public static final Object IMAGE_VIEW_TAG = "image_view_tag";
private final Button mCaptureButton;
- private final Button mChooseButton;
+ protected final Button mChooseButton;
private final Button mDiscardButton;
private ImageView mImageView;
- private String mBinaryName;
+ protected String mBinaryName;
private final String mInstanceFolder;
private final TextView mErrorTextView;
private int mMaxDimen;
- private final PendingCalloutInterface pendingCalloutInterface;
+ protected final PendingCalloutInterface pendingCalloutInterface;
public static File getTempFileForImageCapture() {
return new File(CommCareApplication.instance().
@@ -209,6 +209,7 @@ public ImageWidget(final Context context, FormEntryPrompt prompt, PendingCallout
mErrorTextView.setVisibility(View.VISIBLE);
}
mImageView.setImageBitmap(bmp);
+ mDiscardButton.setVisibility(View.VISIBLE);
} else {
mImageView.setImageBitmap(null);
}
@@ -220,7 +221,6 @@ public ImageWidget(final Context context, FormEntryPrompt prompt, PendingCallout
MediaWidget.playMedia(getContext(), "image/*", toDisplay.getAbsolutePath()));
addView(mImageView);
- mDiscardButton.setVisibility(View.VISIBLE);
}
}
@@ -244,7 +244,7 @@ public static File getFileToDisplay(String instanceFolder, String binaryName, Se
return toDisplay;
}
- private void takePicture() {
+ protected void takePicture() {
Intent i = new Intent(android.provider.MediaStore.ACTION_IMAGE_CAPTURE);
Uri uri = FileUtil.getUriForExternalFile(getContext(), getTempFileForImageCapture());
@@ -270,7 +270,7 @@ private void takePicture() {
}
}
- private void deleteMedia() {
+ protected void deleteMedia() {
MediaWidget.deleteMediaFiles(mInstanceFolder, mBinaryName);
// clean up variables
mBinaryName = null;
diff --git a/app/src/org/commcare/views/widgets/MediaWidget.java b/app/src/org/commcare/views/widgets/MediaWidget.java
index e717d0a4d8..b765e655cb 100644
--- a/app/src/org/commcare/views/widgets/MediaWidget.java
+++ b/app/src/org/commcare/views/widgets/MediaWidget.java
@@ -356,6 +356,9 @@ private static String createTempMediaPath(String fileExtension) {
public static void playMedia(Context context, String mediaType, String filePath) {
Intent i = new Intent(Intent.ACTION_VIEW);
File mediaFile = new File(filePath);
+ if (!mediaFile.exists()) {
+ return;
+ }
Uri mediaUri = FileUtil.getUriForExternalFile(context, mediaFile);
i.setDataAndType(mediaUri, mediaType);
i.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
diff --git a/app/src/org/commcare/views/widgets/MicroImageWidget.java b/app/src/org/commcare/views/widgets/MicroImageWidget.java
new file mode 100644
index 0000000000..5a644554e1
--- /dev/null
+++ b/app/src/org/commcare/views/widgets/MicroImageWidget.java
@@ -0,0 +1,116 @@
+package org.commcare.views.widgets;
+
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.util.Base64;
+
+import org.commcare.activities.components.FormEntryConstants;
+import org.commcare.fragments.MicroImageActivity;
+import org.commcare.logic.PendingCalloutInterface;
+import org.commcare.modern.util.Pair;
+import org.commcare.util.LogTypes;
+import org.commcare.utils.MediaUtil;
+import org.javarosa.core.model.data.Base64ImageData;
+import org.javarosa.core.model.data.IAnswerData;
+import org.javarosa.core.services.Logger;
+import org.javarosa.form.api.FormEntryPrompt;
+
+import java.io.File;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+public class MicroImageWidget extends ImageWidget{
+ private static final int IMAGE_DIMEN_SCALED_MAX_PX = 72;
+ private static final int MICRO_IMAGE_MAX_SIZE_BYTES = 2 * 1024;
+
+ private String mBinary;
+
+ public MicroImageWidget(Context context, FormEntryPrompt prompt, PendingCalloutInterface pic) {
+ super(context, prompt, pic);
+
+ mChooseButton.setVisibility(GONE);
+ if (mPrompt.getAnswerValue() instanceof Base64ImageData) {
+ mBinary = ((Base64ImageData)mPrompt.getAnswerValue()).getImageData();
+ }
+ }
+
+ @Override
+ protected void takePicture() {
+ Intent i = new Intent(getContext(), MicroImageActivity.class);
+ ((AppCompatActivity)getContext()).startActivityForResult(i, FormEntryConstants.MICRO_IMAGE_CAPTURE);
+ pendingCalloutInterface.setPendingCalloutFormIndex(mPrompt.getIndex());
+ }
+
+ @Override
+ public void setBinaryData(Object binaryPath) {
+ if (mBinaryName != null) {
+ deleteMedia();
+ }
+
+ File f = new File(binaryPath.toString());
+ Bitmap originalImage = BitmapFactory.decodeFile(binaryPath.toString());
+ if (originalImage == null) {
+ showToast("microimage.decoding.no.image");
+ Logger.log(LogTypes.TYPE_EXCEPTION,"Error decoding image ");
+ return;
+ }
+
+ Bitmap scaledDownBitmap = null;
+ byte[] compressedBitmapByteArray;
+ try {
+ scaledDownBitmap = scaleImage(originalImage, IMAGE_DIMEN_SCALED_MAX_PX, IMAGE_DIMEN_SCALED_MAX_PX);
+ compressedBitmapByteArray = MediaUtil.compressBitmapToTargetSize(scaledDownBitmap, MICRO_IMAGE_MAX_SIZE_BYTES);
+ mBinary = Base64.encodeToString(compressedBitmapByteArray, Base64.DEFAULT);
+ } catch (Exception e) {
+ showToast("microimage.scalingdown.compression.error");
+ Logger.exception("Error while scaling down and compressing image: ", e);
+ return;
+ } finally {
+ originalImage.recycle();
+ if (scaledDownBitmap != null) {
+ scaledDownBitmap.recycle();
+ }
+ }
+ mBinaryName = f.getName();
+ }
+
+ @Override
+ public IAnswerData getAnswer() {
+ if (mBinaryName != null) {
+ return new Base64ImageData(new Pair<>(mBinaryName, mBinary));
+ } else {
+ return null;
+ }
+ }
+
+ @Override
+ protected void deleteMedia() {
+ super.deleteMedia();
+ mBinary = null;
+ }
+
+ // TODO: Refactor
+ private Bitmap scaleImage(Bitmap bitmap, int maxWidth, int maxHeight){
+ int width = bitmap.getWidth();
+ int height = bitmap.getHeight();
+
+ // scaling factors
+ float widthRatio = (float) maxWidth / width;
+ float heightRatio = (float) maxHeight / height;
+ float scaleFactor = Math.min(widthRatio, heightRatio);
+
+ int newWidth = Math.round(width * scaleFactor);
+ int newHeight = Math.round(height * scaleFactor);
+
+ Bitmap resizedBitmap = Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true);
+
+ // Check and set the same configuration if necessary
+ if (bitmap.getConfig() != resizedBitmap.getConfig()) {
+ resizedBitmap = resizedBitmap.copy(bitmap.getConfig(), true);
+ }
+
+ return resizedBitmap;
+ }
+}
diff --git a/app/src/org/commcare/views/widgets/WidgetFactory.java b/app/src/org/commcare/views/widgets/WidgetFactory.java
index 28752f4265..051d45fa2f 100644
--- a/app/src/org/commcare/views/widgets/WidgetFactory.java
+++ b/app/src/org/commcare/views/widgets/WidgetFactory.java
@@ -51,6 +51,9 @@ public QuestionWidget createWidgetFromPrompt(FormEntryPrompt fep, Context contex
case Constants.CONTROL_SECRET:
questionWidget = buildBasicWidget(appearance, fep, context, inCompactGroup);
break;
+ case Constants.CONTROL_MICRO_IMAGE:
+ questionWidget = new MicroImageWidget(context, fep, pendingCalloutInterface);
+ break;
case Constants.CONTROL_IMAGE_CHOOSE:
if (appearance != null && appearance.equals("signature")) {
questionWidget = new SignatureWidget(context, fep, pendingCalloutInterface);
diff --git a/app/unit-tests/src/org/commcare/android/tests/processing/FormStorageTest.java b/app/unit-tests/src/org/commcare/android/tests/processing/FormStorageTest.java
index 4a1bc4fc5a..1debac2a7b 100644
--- a/app/unit-tests/src/org/commcare/android/tests/processing/FormStorageTest.java
+++ b/app/unit-tests/src/org/commcare/android/tests/processing/FormStorageTest.java
@@ -361,6 +361,9 @@ public class FormStorageTest {
// Added in 2.55
, "org.javarosa.core.model.FormIndex"
, "org.commcare.models.database.InterruptedFormState"
+
+ // Added in 2.56
+ , "org.javarosa.core.model.data.Base64ImageData"
);
diff --git a/build.gradle b/build.gradle
index 3eb760f227..7f6561368b 100644
--- a/build.gradle
+++ b/build.gradle
@@ -30,4 +30,5 @@ allprojects {
ext {
markwon_version = '4.6.2'
lifecycle_version = '2.5.1'
+ cameraX_version = '1.2.3'
}