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' }