Sfoglia il codice sorgente

图片选择、Crop

詹子聪 5 anni fa
parent
commit
0206b6d16f
28 ha cambiato i file con 7925 aggiunte e 40 eliminazioni
  1. 6 1
      app/build.gradle
  2. 1 0
      app/proguard-rules.pro
  3. 5 1
      app/src/main/AndroidManifest.xml
  4. 21 0
      app/src/main/java/com/miekir/OCRApplication.java
  5. 44 12
      app/src/main/java/com/miekir/ocr/CameraActivity.java
  6. 34 0
      app/src/main/java/com/miekir/ocr/MainActivity.java
  7. 73 0
      app/src/main/java/com/miekir/ocr/view/GlideV4ImageEngine.java
  8. 300 0
      app/src/main/java/com/miekir/ocr/view/cropper/BitmapCroppingWorkerTask.java
  9. 150 0
      app/src/main/java/com/miekir/ocr/view/cropper/BitmapLoadingWorkerTask.java
  10. 877 0
      app/src/main/java/com/miekir/ocr/view/cropper/BitmapUtils.java
  11. 1020 0
      app/src/main/java/com/miekir/ocr/view/cropper/CropImage.java
  12. 352 0
      app/src/main/java/com/miekir/ocr/view/cropper/CropImageActivity.java
  13. 121 0
      app/src/main/java/com/miekir/ocr/view/cropper/CropImageAnimation.java
  14. 463 0
      app/src/main/java/com/miekir/ocr/view/cropper/CropImageOptions.java
  15. 2137 0
      app/src/main/java/com/miekir/ocr/view/cropper/CropImageView.java
  16. 1040 0
      app/src/main/java/com/miekir/ocr/view/cropper/CropOverlayView.java
  17. 371 0
      app/src/main/java/com/miekir/ocr/view/cropper/CropWindowHandler.java
  18. 766 0
      app/src/main/java/com/miekir/ocr/view/cropper/CropWindowMoveHandler.java
  19. BIN
      app/src/main/res/drawable-xxhdpi/crop_image_menu_flip.png
  20. BIN
      app/src/main/res/drawable-xxhdpi/crop_image_menu_rotate_left.png
  21. BIN
      app/src/main/res/drawable-xxhdpi/crop_image_menu_rotate_right.png
  22. 11 25
      app/src/main/res/layout/activity_camera.xml
  23. 4 1
      app/src/main/res/layout/activity_main.xml
  24. 6 0
      app/src/main/res/layout/crop_image_activity.xml
  25. 25 0
      app/src/main/res/layout/crop_image_view.xml
  26. 35 0
      app/src/main/res/menu/crop_image_menu.xml
  27. 50 0
      app/src/main/res/values/attrs.xml
  28. 13 0
      app/src/main/res/values/strings.xml

+ 6 - 1
app/build.gradle

@@ -42,5 +42,10 @@ dependencies {
     compile 'com.tbruyelle.rxpermissions2:rxpermissions:0.8.1@aar'
 
     // 裁剪图片
-    compile 'com.isseiaoki:simplecropview:1.1.8'
+    //compile 'com.isseiaoki:simplecropview:1.1.8'
+
+    // 图片选择
+    implementation 'com.zhihu.android:matisse:0.5.2'
+
+    implementation 'com.github.VictorAlbertos:RxActivityResult:0.5.0-2.x'
 }

+ 1 - 0
app/proguard-rules.pro

@@ -19,3 +19,4 @@
 # If you keep the line number information, uncomment this to
 # hide the original source file name.
 #-renamesourcefileattribute SourceFile
+-dontwarn com.squareup.picasso.**

+ 5 - 1
app/src/main/AndroidManifest.xml

@@ -17,7 +17,8 @@
         android:roundIcon="@mipmap/ic_launcher_round"
         android:supportsRtl="true"
         android:theme="@style/AppTheme"
-        tools:ignore="GoogleAppIndexingWarning">
+        tools:ignore="GoogleAppIndexingWarning"
+        android:name="com.miekir.OCRApplication">
         <activity android:name=".MainActivity"
             android:screenOrientation="portrait">
             <intent-filter>
@@ -28,6 +29,9 @@
 
         <activity android:name=".CameraActivity"
             android:screenOrientation="portrait"/>
+
+        <activity android:name=".view.cropper.CropImageActivity"
+            android:screenOrientation="portrait"/>
     </application>
 
 </manifest>

+ 21 - 0
app/src/main/java/com/miekir/OCRApplication.java

@@ -0,0 +1,21 @@
+package com.miekir;
+
+import android.app.Application;
+
+import rx_activity_result2.RxActivityResult;
+
+/**
+ * Copyright (C), 2019-2020, Genlot
+ *
+ * @author 詹子聪
+ * @date 2020/7/26 11:52
+ * Description:
+ */
+public class OCRApplication extends Application {
+    @Override
+    public void onCreate() {
+        super.onCreate();
+
+        RxActivityResult.register(this);
+    }
+}

+ 44 - 12
app/src/main/java/com/miekir/ocr/CameraActivity.java

@@ -2,6 +2,8 @@ package com.miekir.ocr;
 
 import android.annotation.SuppressLint;
 import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ActivityInfo;
 import android.content.res.Configuration;
 import android.graphics.ImageFormat;
 import android.graphics.Point;
@@ -35,10 +37,15 @@ import android.widget.ImageView;
 import android.widget.Toast;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 
 import com.miekir.ocr.base.BaseCameraActivity;
 import com.miekir.ocr.tool.Utils;
 import com.miekir.ocr.view.AutoFitTextureView;
+import com.miekir.ocr.view.GlideV4ImageEngine;
+import com.zhihu.matisse.Matisse;
+import com.zhihu.matisse.MimeType;
+import com.zhihu.matisse.engine.impl.GlideEngine;
 
 import java.io.File;
 import java.io.FileNotFoundException;
@@ -55,7 +62,8 @@ import java.util.concurrent.TimeUnit;
 
 import static android.os.Environment.DIRECTORY_PICTURES;
 
-public class CameraActivity extends BaseCameraActivity {
+public class CameraActivity extends BaseCameraActivity implements View.OnClickListener {
+    private static final int REQUEST_CODE_CHOOSE = 6;
     private CameraManager mCameraManager;
 
     private static SparseIntArray ORIENTATIONS = new SparseIntArray();
@@ -82,7 +90,6 @@ public class CameraActivity extends BaseCameraActivity {
     private HandlerThread mBackgroundThread;
 
     private AutoFitTextureView textureView;
-    private FrameLayout fl_take;
 
     private Semaphore mCameraOpenCloseLock = new Semaphore(1);
 
@@ -148,18 +155,10 @@ public class CameraActivity extends BaseCameraActivity {
     @Override
     public void initViews(Bundle savedInstanceState) {
         textureView = findViewById(R.id.textureView);
-        fl_take = findViewById(R.id.fl_take);
-
         textureView.setSurfaceTextureListener(textureListener);
 
-        fl_take.setOnClickListener(new View.OnClickListener() {
-            @Override
-            public void onClick(View v) {
-                takePicture();
-            }
-        });
-
-
+        findViewById(R.id.fl_take).setOnClickListener(this);
+        findViewById(R.id.iv_album).setOnClickListener(this);
     }
 
 
@@ -475,4 +474,37 @@ public class CameraActivity extends BaseCameraActivity {
             }
         }
     };
+
+    @Override
+    public void onClick(View v) {
+        switch (v.getId()) {
+            case R.id.fl_take:
+                takePicture();
+                break;
+            case R.id.iv_album:
+                Matisse.from(this)
+                        .choose(MimeType.ofImage())
+                        .countable(true)
+                        .maxSelectable(1)
+                        //.addFilter(new GifSizeFilter(320, 320, 5 * Filter.K * Filter.K))
+                        //.gridExpectedSize(getResources().getDimensionPixelSize(R.dimen.grid_expected_size))
+                        .restrictOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED)
+                        .thumbnailScale(0.85f)
+                        //.imageEngine(new GlideEngine())
+                        .imageEngine(new GlideV4ImageEngine())
+                        //.showPreview(false) // Default is `true`
+                        .forResult(REQUEST_CODE_CHOOSE);
+                break;
+            default:
+                break;
+        }
+    }
+
+    @Override
+    protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
+        super.onActivityResult(requestCode, resultCode, data);
+        if (resultCode == RESULT_OK && requestCode == REQUEST_CODE_CHOOSE) {
+
+        }
+    }
 }

+ 34 - 0
app/src/main/java/com/miekir/ocr/MainActivity.java

@@ -2,15 +2,23 @@ package com.miekir.ocr;
 
 import android.Manifest;
 import android.annotation.SuppressLint;
+import android.app.Activity;
 import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.os.Build;
 import android.os.Bundle;
 
 import androidx.annotation.Nullable;
 import androidx.appcompat.app.AlertDialog;
 import androidx.appcompat.app.AppCompatActivity;
 
+import com.miekir.ocr.view.cropper.CropImageActivity;
+import com.miekir.ocr.view.cropper.CropImageView;
 import com.tbruyelle.rxpermissions2.RxPermissions;
 
+import rx_activity_result2.RxActivityResult;
+
 /**
  * Copyright (C), 2019-2020, Genlot
  *
@@ -32,12 +40,17 @@ public class MainActivity extends AppCompatActivity {
                     if (granted) {
                         // 打开相机
                         startActivity(new Intent(MainActivity.this, CameraActivity.class));
+                        //startActivity(new Intent(MainActivity.this, CropImageActivity.class));
                         finish();
+                        //photoSelect();
                     } else {
                         // 权限被拒绝
                         showDenyDialog();
                     }
                 });
+
+
+
     }
 
     protected void showDenyDialog() {
@@ -49,4 +62,25 @@ public class MainActivity extends AppCompatActivity {
                 }).create();
         dialog.show();
     }
+
+
+    /**
+     * 打开相册
+     */
+    private void photoSelect() {
+        Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
+        intent.addCategory(Intent.CATEGORY_OPENABLE);
+        intent.setType("image/jpeg");
+        RxActivityResult.on(this)
+                .startIntent(intent)
+                .filter(result -> result.data() != null)
+                .map(result -> result.data().getData())
+                .doOnNext(this::getUri)
+                .subscribe();
+    }
+
+    private void getUri(Uri uri) {
+        CropImageView cropImageView = findViewById(R.id.cropImageView);
+        cropImageView.setImageUriAsync(uri);
+    }
 }

+ 73 - 0
app/src/main/java/com/miekir/ocr/view/GlideV4ImageEngine.java

@@ -0,0 +1,73 @@
+package com.miekir.ocr.view;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.widget.ImageView;
+
+import com.bumptech.glide.Glide;
+import com.bumptech.glide.Priority;
+import com.bumptech.glide.request.RequestOptions;
+import com.zhihu.matisse.engine.ImageEngine;
+
+public class GlideV4ImageEngine implements ImageEngine {
+
+    @Override
+    public void loadThumbnail(Context context, int resize, Drawable placeholder, ImageView imageView, Uri uri) {
+        RequestOptions requestOptions = new RequestOptions();
+        requestOptions = requestOptions.placeholder(placeholder);
+        requestOptions = requestOptions.override(resize, resize);
+        requestOptions = requestOptions.centerCrop();
+        Glide.with(context)
+                .asBitmap()
+                .load(uri)
+                .apply(requestOptions)
+                .into(imageView);
+    }
+
+    @Override
+    public void loadGifThumbnail(Context context, int resize, Drawable placeholder, ImageView imageView, Uri uri) {
+        RequestOptions requestOptions = new RequestOptions();
+        requestOptions = requestOptions.placeholder(placeholder);
+        requestOptions = requestOptions.override(resize, resize);
+        requestOptions = requestOptions.centerCrop();
+        Glide.with(context)
+                .asBitmap()
+                .load(uri)
+                .apply(requestOptions)
+                .into(imageView);
+    }
+
+
+    @Override
+    public void loadImage(Context context, int resizeX, int resizeY, ImageView imageView, Uri uri) {
+        RequestOptions requestOptions = new RequestOptions();
+        requestOptions = requestOptions.priority(Priority.HIGH);
+        requestOptions = requestOptions.override(resizeX, resizeY);
+        requestOptions = requestOptions.centerCrop();
+        Glide.with(context)
+                .asBitmap()
+                .load(uri)
+                .apply(requestOptions)
+                .into(imageView);
+    }
+
+    @Override
+    public void loadGifImage(Context context, int resizeX, int resizeY, ImageView imageView, Uri uri) {
+        RequestOptions requestOptions = new RequestOptions();
+        requestOptions = requestOptions.priority(Priority.HIGH);
+        requestOptions = requestOptions.override(resizeX, resizeY);
+        requestOptions = requestOptions.centerCrop();
+        Glide.with(context)
+                .asGif()
+                .load(uri)
+                .apply(requestOptions)
+                .into(imageView);
+    }
+
+
+    @Override
+    public boolean supportAnimatedGif() {
+        return true;
+    }
+}

+ 300 - 0
app/src/main/java/com/miekir/ocr/view/cropper/BitmapCroppingWorkerTask.java

@@ -0,0 +1,300 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+package com.miekir.ocr.view.cropper;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.os.AsyncTask;
+
+import java.lang.ref.WeakReference;
+
+/** Task to crop bitmap asynchronously from the UI thread. */
+final class BitmapCroppingWorkerTask
+    extends AsyncTask<Void, Void, BitmapCroppingWorkerTask.Result> {
+
+  // region: Fields and Consts
+
+  /** Use a WeakReference to ensure the ImageView can be garbage collected */
+  private final WeakReference<CropImageView> mCropImageViewReference;
+
+  /** the bitmap to crop */
+  private final Bitmap mBitmap;
+
+  /** The Android URI of the image to load */
+  private final Uri mUri;
+
+  /** The context of the crop image view widget used for loading of bitmap by Android URI */
+  private final Context mContext;
+
+  /** Required cropping 4 points (x0,y0,x1,y1,x2,y2,x3,y3) */
+  private final float[] mCropPoints;
+
+  /** Degrees the image was rotated after loading */
+  private final int mDegreesRotated;
+
+  /** the original width of the image to be cropped (for image loaded from URI) */
+  private final int mOrgWidth;
+
+  /** the original height of the image to be cropped (for image loaded from URI) */
+  private final int mOrgHeight;
+
+  /** is there is fixed aspect ratio for the crop rectangle */
+  private final boolean mFixAspectRatio;
+
+  /** the X aspect ration of the crop rectangle */
+  private final int mAspectRatioX;
+
+  /** the Y aspect ration of the crop rectangle */
+  private final int mAspectRatioY;
+
+  /** required width of the cropping image */
+  private final int mReqWidth;
+
+  /** required height of the cropping image */
+  private final int mReqHeight;
+
+  /** is the image flipped horizontally */
+  private final boolean mFlipHorizontally;
+
+  /** is the image flipped vertically */
+  private final boolean mFlipVertically;
+
+  /** The option to handle requested width/height */
+  private final CropImageView.RequestSizeOptions mReqSizeOptions;
+
+  /** the Android Uri to save the cropped image to */
+  private final Uri mSaveUri;
+
+  /** the compression format to use when writing the image */
+  private final Bitmap.CompressFormat mSaveCompressFormat;
+
+  /** the quality (if applicable) to use when writing the image (0 - 100) */
+  private final int mSaveCompressQuality;
+  // endregion
+
+  BitmapCroppingWorkerTask(
+      CropImageView cropImageView,
+      Bitmap bitmap,
+      float[] cropPoints,
+      int degreesRotated,
+      boolean fixAspectRatio,
+      int aspectRatioX,
+      int aspectRatioY,
+      int reqWidth,
+      int reqHeight,
+      boolean flipHorizontally,
+      boolean flipVertically,
+      CropImageView.RequestSizeOptions options,
+      Uri saveUri,
+      Bitmap.CompressFormat saveCompressFormat,
+      int saveCompressQuality) {
+
+    mCropImageViewReference = new WeakReference<>(cropImageView);
+    mContext = cropImageView.getContext();
+    mBitmap = bitmap;
+    mCropPoints = cropPoints;
+    mUri = null;
+    mDegreesRotated = degreesRotated;
+    mFixAspectRatio = fixAspectRatio;
+    mAspectRatioX = aspectRatioX;
+    mAspectRatioY = aspectRatioY;
+    mReqWidth = reqWidth;
+    mReqHeight = reqHeight;
+    mFlipHorizontally = flipHorizontally;
+    mFlipVertically = flipVertically;
+    mReqSizeOptions = options;
+    mSaveUri = saveUri;
+    mSaveCompressFormat = saveCompressFormat;
+    mSaveCompressQuality = saveCompressQuality;
+    mOrgWidth = 0;
+    mOrgHeight = 0;
+  }
+
+  BitmapCroppingWorkerTask(
+      CropImageView cropImageView,
+      Uri uri,
+      float[] cropPoints,
+      int degreesRotated,
+      int orgWidth,
+      int orgHeight,
+      boolean fixAspectRatio,
+      int aspectRatioX,
+      int aspectRatioY,
+      int reqWidth,
+      int reqHeight,
+      boolean flipHorizontally,
+      boolean flipVertically,
+      CropImageView.RequestSizeOptions options,
+      Uri saveUri,
+      Bitmap.CompressFormat saveCompressFormat,
+      int saveCompressQuality) {
+
+    mCropImageViewReference = new WeakReference<>(cropImageView);
+    mContext = cropImageView.getContext();
+    mUri = uri;
+    mCropPoints = cropPoints;
+    mDegreesRotated = degreesRotated;
+    mFixAspectRatio = fixAspectRatio;
+    mAspectRatioX = aspectRatioX;
+    mAspectRatioY = aspectRatioY;
+    mOrgWidth = orgWidth;
+    mOrgHeight = orgHeight;
+    mReqWidth = reqWidth;
+    mReqHeight = reqHeight;
+    mFlipHorizontally = flipHorizontally;
+    mFlipVertically = flipVertically;
+    mReqSizeOptions = options;
+    mSaveUri = saveUri;
+    mSaveCompressFormat = saveCompressFormat;
+    mSaveCompressQuality = saveCompressQuality;
+    mBitmap = null;
+  }
+
+  /** The Android URI that this task is currently loading. */
+  public Uri getUri() {
+    return mUri;
+  }
+
+  /**
+   * Crop image in background.
+   *
+   * @param params ignored
+   * @return the decoded bitmap data
+   */
+  @Override
+  protected Result doInBackground(Void... params) {
+    try {
+      if (!isCancelled()) {
+
+        BitmapUtils.BitmapSampled bitmapSampled;
+        if (mUri != null) {
+          bitmapSampled =
+              BitmapUtils.cropBitmap(
+                  mContext,
+                  mUri,
+                  mCropPoints,
+                  mDegreesRotated,
+                  mOrgWidth,
+                  mOrgHeight,
+                  mFixAspectRatio,
+                  mAspectRatioX,
+                  mAspectRatioY,
+                  mReqWidth,
+                  mReqHeight,
+                  mFlipHorizontally,
+                  mFlipVertically);
+        } else if (mBitmap != null) {
+          bitmapSampled =
+              BitmapUtils.cropBitmapObjectHandleOOM(
+                  mBitmap,
+                  mCropPoints,
+                  mDegreesRotated,
+                  mFixAspectRatio,
+                  mAspectRatioX,
+                  mAspectRatioY,
+                  mFlipHorizontally,
+                  mFlipVertically);
+        } else {
+          return new Result((Bitmap) null, 1);
+        }
+
+        Bitmap bitmap =
+            BitmapUtils.resizeBitmap(bitmapSampled.bitmap, mReqWidth, mReqHeight, mReqSizeOptions);
+
+        if (mSaveUri == null) {
+          return new Result(bitmap, bitmapSampled.sampleSize);
+        } else {
+          BitmapUtils.writeBitmapToUri(
+              mContext, bitmap, mSaveUri, mSaveCompressFormat, mSaveCompressQuality);
+          if (bitmap != null) {
+            bitmap.recycle();
+          }
+          return new Result(mSaveUri, bitmapSampled.sampleSize);
+        }
+      }
+      return null;
+    } catch (Exception e) {
+      return new Result(e, mSaveUri != null);
+    }
+  }
+
+  /**
+   * Once complete, see if ImageView is still around and set bitmap.
+   *
+   * @param result the result of bitmap cropping
+   */
+  @Override
+  protected void onPostExecute(Result result) {
+    if (result != null) {
+      boolean completeCalled = false;
+      if (!isCancelled()) {
+        CropImageView cropImageView = mCropImageViewReference.get();
+        if (cropImageView != null) {
+          completeCalled = true;
+          cropImageView.onImageCroppingAsyncComplete(result);
+        }
+      }
+      if (!completeCalled && result.bitmap != null) {
+        // fast release of unused bitmap
+        result.bitmap.recycle();
+      }
+    }
+  }
+
+  // region: Inner class: Result
+
+  /** The result of BitmapCroppingWorkerTask async loading. */
+  static final class Result {
+
+    /** The cropped bitmap */
+    public final Bitmap bitmap;
+
+    /** The saved cropped bitmap uri */
+    public final Uri uri;
+
+    /** The error that occurred during async bitmap cropping. */
+    final Exception error;
+
+    /** is the cropping request was to get a bitmap or to save it to uri */
+    final boolean isSave;
+
+    /** sample size used creating the crop bitmap to lower its size */
+    final int sampleSize;
+
+    Result(Bitmap bitmap, int sampleSize) {
+      this.bitmap = bitmap;
+      this.uri = null;
+      this.error = null;
+      this.isSave = false;
+      this.sampleSize = sampleSize;
+    }
+
+    Result(Uri uri, int sampleSize) {
+      this.bitmap = null;
+      this.uri = uri;
+      this.error = null;
+      this.isSave = true;
+      this.sampleSize = sampleSize;
+    }
+
+    Result(Exception error, boolean isSave) {
+      this.bitmap = null;
+      this.uri = null;
+      this.error = error;
+      this.isSave = isSave;
+      this.sampleSize = 1;
+    }
+  }
+  // endregion
+}

+ 150 - 0
app/src/main/java/com/miekir/ocr/view/cropper/BitmapLoadingWorkerTask.java

@@ -0,0 +1,150 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+package com.miekir.ocr.view.cropper;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.util.DisplayMetrics;
+
+import java.lang.ref.WeakReference;
+
+/** Task to load bitmap asynchronously from the UI thread. */
+final class BitmapLoadingWorkerTask extends AsyncTask<Void, Void, BitmapLoadingWorkerTask.Result> {
+
+  // region: Fields and Consts
+
+  /** Use a WeakReference to ensure the ImageView can be garbage collected */
+  private final WeakReference<CropImageView> mCropImageViewReference;
+
+  /** The Android URI of the image to load */
+  private final Uri mUri;
+
+  /** The context of the crop image view widget used for loading of bitmap by Android URI */
+  private final Context mContext;
+
+  /** required width of the cropping image after density adjustment */
+  private final int mWidth;
+
+  /** required height of the cropping image after density adjustment */
+  private final int mHeight;
+  // endregion
+
+  public BitmapLoadingWorkerTask(CropImageView cropImageView, Uri uri) {
+    mUri = uri;
+    mCropImageViewReference = new WeakReference<>(cropImageView);
+
+    mContext = cropImageView.getContext();
+
+    DisplayMetrics metrics = cropImageView.getResources().getDisplayMetrics();
+    double densityAdj = metrics.density > 1 ? 1 / metrics.density : 1;
+    mWidth = (int) (metrics.widthPixels * densityAdj);
+    mHeight = (int) (metrics.heightPixels * densityAdj);
+  }
+
+  /** The Android URI that this task is currently loading. */
+  public Uri getUri() {
+    return mUri;
+  }
+
+  /**
+   * Decode image in background.
+   *
+   * @param params ignored
+   * @return the decoded bitmap data
+   */
+  @Override
+  protected Result doInBackground(Void... params) {
+    try {
+      if (!isCancelled()) {
+
+        BitmapUtils.BitmapSampled decodeResult =
+            BitmapUtils.decodeSampledBitmap(mContext, mUri, mWidth, mHeight);
+
+        if (!isCancelled()) {
+
+          BitmapUtils.RotateBitmapResult rotateResult =
+              BitmapUtils.rotateBitmapByExif(decodeResult.bitmap, mContext, mUri);
+
+          return new Result(
+              mUri, rotateResult.bitmap, decodeResult.sampleSize, rotateResult.degrees);
+        }
+      }
+      return null;
+    } catch (Exception e) {
+      return new Result(mUri, e);
+    }
+  }
+
+  /**
+   * Once complete, see if ImageView is still around and set bitmap.
+   *
+   * @param result the result of bitmap loading
+   */
+  @Override
+  protected void onPostExecute(Result result) {
+    if (result != null) {
+      boolean completeCalled = false;
+      if (!isCancelled()) {
+        CropImageView cropImageView = mCropImageViewReference.get();
+        if (cropImageView != null) {
+          completeCalled = true;
+          cropImageView.onSetImageUriAsyncComplete(result);
+        }
+      }
+      if (!completeCalled && result.bitmap != null) {
+        // fast release of unused bitmap
+        result.bitmap.recycle();
+      }
+    }
+  }
+
+  // region: Inner class: Result
+
+  /** The result of BitmapLoadingWorkerTask async loading. */
+  public static final class Result {
+
+    /** The Android URI of the image to load */
+    public final Uri uri;
+
+    /** The loaded bitmap */
+    public final Bitmap bitmap;
+
+    /** The sample size used to load the given bitmap */
+    public final int loadSampleSize;
+
+    /** The degrees the image was rotated */
+    public final int degreesRotated;
+
+    /** The error that occurred during async bitmap loading. */
+    public final Exception error;
+
+    Result(Uri uri, Bitmap bitmap, int loadSampleSize, int degreesRotated) {
+      this.uri = uri;
+      this.bitmap = bitmap;
+      this.loadSampleSize = loadSampleSize;
+      this.degreesRotated = degreesRotated;
+      this.error = null;
+    }
+
+    Result(Uri uri, Exception error) {
+      this.uri = uri;
+      this.bitmap = null;
+      this.loadSampleSize = 0;
+      this.degreesRotated = 0;
+      this.error = error;
+    }
+  }
+  // endregion
+}

+ 877 - 0
app/src/main/java/com/miekir/ocr/view/cropper/BitmapUtils.java

@@ -0,0 +1,877 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+package com.miekir.ocr.view.cropper;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.BitmapRegionDecoder;
+import android.graphics.Matrix;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.net.Uri;
+import android.util.Log;
+import android.util.Pair;
+
+import java.io.Closeable;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.lang.ref.WeakReference;
+
+import javax.microedition.khronos.egl.EGL10;
+import javax.microedition.khronos.egl.EGLConfig;
+import javax.microedition.khronos.egl.EGLContext;
+import javax.microedition.khronos.egl.EGLDisplay;
+
+import androidx.exifinterface.media.ExifInterface;
+
+/** Utility class that deals with operations with an ImageView. */
+final class BitmapUtils {
+
+  static final Rect EMPTY_RECT = new Rect();
+
+  static final RectF EMPTY_RECT_F = new RectF();
+
+  /** Reusable rectangle for general internal usage */
+  static final RectF RECT = new RectF();
+
+  /** Reusable point for general internal usage */
+  static final float[] POINTS = new float[6];
+
+  /** Reusable point for general internal usage */
+  static final float[] POINTS2 = new float[6];
+
+  /** Used to know the max texture size allowed to be rendered */
+  private static int mMaxTextureSize;
+
+  /** used to save bitmaps during state save and restore so not to reload them. */
+  static Pair<String, WeakReference<Bitmap>> mStateBitmap;
+
+  /**
+   * Rotate the given image by reading the Exif value of the image (uri).<br>
+   * If no rotation is required the image will not be rotated.<br>
+   * New bitmap is created and the old one is recycled.
+   */
+  static RotateBitmapResult rotateBitmapByExif(Bitmap bitmap, Context context, Uri uri) {
+    ExifInterface ei = null;
+    try {
+      InputStream is = context.getContentResolver().openInputStream(uri);
+      if (is != null) {
+        ei = new ExifInterface(is);
+        is.close();
+      }
+    } catch (Exception ignored) {
+    }
+    return ei != null ? rotateBitmapByExif(bitmap, ei) : new RotateBitmapResult(bitmap, 0);
+  }
+
+  /**
+   * Rotate the given image by given Exif value.<br>
+   * If no rotation is required the image will not be rotated.<br>
+   * New bitmap is created and the old one is recycled.
+   */
+  static RotateBitmapResult rotateBitmapByExif(Bitmap bitmap, ExifInterface exif) {
+    int degrees;
+    int orientation =
+        exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);
+    switch (orientation) {
+      case ExifInterface.ORIENTATION_ROTATE_90:
+        degrees = 90;
+        break;
+      case ExifInterface.ORIENTATION_ROTATE_180:
+        degrees = 180;
+        break;
+      case ExifInterface.ORIENTATION_ROTATE_270:
+        degrees = 270;
+        break;
+      default:
+        degrees = 0;
+        break;
+    }
+    return new RotateBitmapResult(bitmap, degrees);
+  }
+
+  /** Decode bitmap from stream using sampling to get bitmap with the requested limit. */
+  static BitmapSampled decodeSampledBitmap(Context context, Uri uri, int reqWidth, int reqHeight) {
+
+    try {
+      ContentResolver resolver = context.getContentResolver();
+
+      // First decode with inJustDecodeBounds=true to check dimensions
+      BitmapFactory.Options options = decodeImageForOption(resolver, uri);
+
+      if(options.outWidth  == -1 && options.outHeight == -1)
+        throw new RuntimeException("File is not a picture");
+
+      // Calculate inSampleSize
+      options.inSampleSize =
+          Math.max(
+              calculateInSampleSizeByReqestedSize(
+                  options.outWidth, options.outHeight, reqWidth, reqHeight),
+              calculateInSampleSizeByMaxTextureSize(options.outWidth, options.outHeight));
+
+      // Decode bitmap with inSampleSize set
+      Bitmap bitmap = decodeImage(resolver, uri, options);
+
+      return new BitmapSampled(bitmap, options.inSampleSize);
+
+    } catch (Exception e) {
+      throw new RuntimeException(
+          "Failed to load sampled bitmap: " + uri + "\r\n" + e.getMessage(), e);
+    }
+  }
+
+  /**
+   * Crop image bitmap from given bitmap using the given points in the original bitmap and the given
+   * rotation.<br>
+   * if the rotation is not 0,90,180 or 270 degrees then we must first crop a larger area of the
+   * image that contains the requires rectangle, rotate and then crop again a sub rectangle.<br>
+   * If crop fails due to OOM we scale the cropping image by 0.5 every time it fails until it is
+   * small enough.
+   */
+  static BitmapSampled cropBitmapObjectHandleOOM(
+      Bitmap bitmap,
+      float[] points,
+      int degreesRotated,
+      boolean fixAspectRatio,
+      int aspectRatioX,
+      int aspectRatioY,
+      boolean flipHorizontally,
+      boolean flipVertically) {
+    int scale = 1;
+    while (true) {
+      try {
+        Bitmap cropBitmap =
+            cropBitmapObjectWithScale(
+                bitmap,
+                points,
+                degreesRotated,
+                fixAspectRatio,
+                aspectRatioX,
+                aspectRatioY,
+                1 / (float) scale,
+                flipHorizontally,
+                flipVertically);
+        return new BitmapSampled(cropBitmap, scale);
+      } catch (OutOfMemoryError e) {
+        scale *= 2;
+        if (scale > 8) {
+          throw e;
+        }
+      }
+    }
+  }
+
+  /**
+   * Crop image bitmap from given bitmap using the given points in the original bitmap and the given
+   * rotation.<br>
+   * if the rotation is not 0,90,180 or 270 degrees then we must first crop a larger area of the
+   * image that contains the requires rectangle, rotate and then crop again a sub rectangle.
+   *
+   * @param scale how much to scale the cropped image part, use 0.5 to lower the image by half (OOM
+   *     handling)
+   */
+  private static Bitmap cropBitmapObjectWithScale(
+      Bitmap bitmap,
+      float[] points,
+      int degreesRotated,
+      boolean fixAspectRatio,
+      int aspectRatioX,
+      int aspectRatioY,
+      float scale,
+      boolean flipHorizontally,
+      boolean flipVertically) {
+
+    // get the rectangle in original image that contains the required cropped area (larger for non
+    // rectangular crop)
+    Rect rect =
+        getRectFromPoints(
+            points,
+            bitmap.getWidth(),
+            bitmap.getHeight(),
+            fixAspectRatio,
+            aspectRatioX,
+            aspectRatioY);
+
+    // crop and rotate the cropped image in one operation
+    Matrix matrix = new Matrix();
+    matrix.setRotate(degreesRotated, bitmap.getWidth() / 2, bitmap.getHeight() / 2);
+    matrix.postScale(flipHorizontally ? -scale : scale, flipVertically ? -scale : scale);
+    Bitmap result =
+        Bitmap.createBitmap(bitmap, rect.left, rect.top, rect.width(), rect.height(), matrix, true);
+
+    if (result == bitmap) {
+      // corner case when all bitmap is selected, no worth optimizing for it
+      result = bitmap.copy(bitmap.getConfig(), false);
+    }
+
+    // rotating by 0, 90, 180 or 270 degrees doesn't require extra cropping
+    if (degreesRotated % 90 != 0) {
+
+      // extra crop because non rectangular crop cannot be done directly on the image without
+      // rotating first
+      result =
+          cropForRotatedImage(
+              result, points, rect, degreesRotated, fixAspectRatio, aspectRatioX, aspectRatioY);
+    }
+
+    return result;
+  }
+
+  /**
+   * Crop image bitmap from URI by decoding it with specific width and height to down-sample if
+   * required.<br>
+   * Additionally if OOM is thrown try to increase the sampling (2,4,8).
+   */
+  static BitmapSampled cropBitmap(
+      Context context,
+      Uri loadedImageUri,
+      float[] points,
+      int degreesRotated,
+      int orgWidth,
+      int orgHeight,
+      boolean fixAspectRatio,
+      int aspectRatioX,
+      int aspectRatioY,
+      int reqWidth,
+      int reqHeight,
+      boolean flipHorizontally,
+      boolean flipVertically) {
+    int sampleMulti = 1;
+    while (true) {
+      try {
+        // if successful, just return the resulting bitmap
+        return cropBitmap(
+            context,
+            loadedImageUri,
+            points,
+            degreesRotated,
+            orgWidth,
+            orgHeight,
+            fixAspectRatio,
+            aspectRatioX,
+            aspectRatioY,
+            reqWidth,
+            reqHeight,
+            flipHorizontally,
+            flipVertically,
+            sampleMulti);
+      } catch (OutOfMemoryError e) {
+        // if OOM try to increase the sampling to lower the memory usage
+        sampleMulti *= 2;
+        if (sampleMulti > 16) {
+          throw new RuntimeException(
+              "Failed to handle OOM by sampling ("
+                  + sampleMulti
+                  + "): "
+                  + loadedImageUri
+                  + "\r\n"
+                  + e.getMessage(),
+              e);
+        }
+      }
+    }
+  }
+
+  /** Get left value of the bounding rectangle of the given points. */
+  static float getRectLeft(float[] points) {
+    return Math.min(Math.min(Math.min(points[0], points[2]), points[4]), points[6]);
+  }
+
+  /** Get top value of the bounding rectangle of the given points. */
+  static float getRectTop(float[] points) {
+    return Math.min(Math.min(Math.min(points[1], points[3]), points[5]), points[7]);
+  }
+
+  /** Get right value of the bounding rectangle of the given points. */
+  static float getRectRight(float[] points) {
+    return Math.max(Math.max(Math.max(points[0], points[2]), points[4]), points[6]);
+  }
+
+  /** Get bottom value of the bounding rectangle of the given points. */
+  static float getRectBottom(float[] points) {
+    return Math.max(Math.max(Math.max(points[1], points[3]), points[5]), points[7]);
+  }
+
+  /** Get width of the bounding rectangle of the given points. */
+  static float getRectWidth(float[] points) {
+    return getRectRight(points) - getRectLeft(points);
+  }
+
+  /** Get height of the bounding rectangle of the given points. */
+  static float getRectHeight(float[] points) {
+    return getRectBottom(points) - getRectTop(points);
+  }
+
+  /** Get horizontal center value of the bounding rectangle of the given points. */
+  static float getRectCenterX(float[] points) {
+    return (getRectRight(points) + getRectLeft(points)) / 2f;
+  }
+
+  /** Get vertical center value of the bounding rectangle of the given points. */
+  static float getRectCenterY(float[] points) {
+    return (getRectBottom(points) + getRectTop(points)) / 2f;
+  }
+
+  /**
+   * Get a rectangle for the given 4 points (x0,y0,x1,y1,x2,y2,x3,y3) by finding the min/max 2
+   * points that contains the given 4 points and is a straight rectangle.
+   */
+  static Rect getRectFromPoints(
+      float[] points,
+      int imageWidth,
+      int imageHeight,
+      boolean fixAspectRatio,
+      int aspectRatioX,
+      int aspectRatioY) {
+    int left = Math.round(Math.max(0, getRectLeft(points)));
+    int top = Math.round(Math.max(0, getRectTop(points)));
+    int right = Math.round(Math.min(imageWidth, getRectRight(points)));
+    int bottom = Math.round(Math.min(imageHeight, getRectBottom(points)));
+
+    Rect rect = new Rect(left, top, right, bottom);
+    if (fixAspectRatio) {
+      fixRectForAspectRatio(rect, aspectRatioX, aspectRatioY);
+    }
+
+    return rect;
+  }
+
+  /**
+   * Fix the given rectangle if it doesn't confirm to aspect ration rule.<br>
+   * Make sure that width and height are equal if 1:1 fixed aspect ratio is requested.
+   */
+  private static void fixRectForAspectRatio(Rect rect, int aspectRatioX, int aspectRatioY) {
+    if (aspectRatioX == aspectRatioY && rect.width() != rect.height()) {
+      if (rect.height() > rect.width()) {
+        rect.bottom -= rect.height() - rect.width();
+      } else {
+        rect.right -= rect.width() - rect.height();
+      }
+    }
+  }
+
+  /**
+   * Write given bitmap to a temp file. If file already exists no-op as we already saved the file in
+   * this session. Uses JPEG 95% compression.
+   *
+   * @param uri the uri to write the bitmap to, if null
+   * @return the uri where the image was saved in, either the given uri or new pointing to temp
+   *     file.
+   */
+  static Uri writeTempStateStoreBitmap(Context context, Bitmap bitmap, Uri uri) {
+    try {
+      boolean needSave = true;
+      if (uri == null) {
+        uri =
+            Uri.fromFile(
+                File.createTempFile("aic_state_store_temp", ".jpg", context.getCacheDir()));
+      } else if (new File(uri.getPath()).exists()) {
+        needSave = false;
+      }
+      if (needSave) {
+        writeBitmapToUri(context, bitmap, uri, Bitmap.CompressFormat.JPEG, 95);
+      }
+      return uri;
+    } catch (Exception e) {
+      Log.w("AIC", "Failed to write bitmap to temp file for image-cropper save instance state", e);
+      return null;
+    }
+  }
+
+  /** Write the given bitmap to the given uri using the given compression. */
+  static void writeBitmapToUri(
+      Context context,
+      Bitmap bitmap,
+      Uri uri,
+      Bitmap.CompressFormat compressFormat,
+      int compressQuality)
+      throws FileNotFoundException {
+    OutputStream outputStream = null;
+    try {
+      outputStream = context.getContentResolver().openOutputStream(uri);
+      bitmap.compress(compressFormat, compressQuality, outputStream);
+    } finally {
+      closeSafe(outputStream);
+    }
+  }
+
+  /** Resize the given bitmap to the given width/height by the given option.<br> */
+  static Bitmap resizeBitmap(
+      Bitmap bitmap, int reqWidth, int reqHeight, CropImageView.RequestSizeOptions options) {
+    try {
+      if (reqWidth > 0
+          && reqHeight > 0
+          && (options == CropImageView.RequestSizeOptions.RESIZE_FIT
+              || options == CropImageView.RequestSizeOptions.RESIZE_INSIDE
+              || options == CropImageView.RequestSizeOptions.RESIZE_EXACT)) {
+
+        Bitmap resized = null;
+        if (options == CropImageView.RequestSizeOptions.RESIZE_EXACT) {
+          resized = Bitmap.createScaledBitmap(bitmap, reqWidth, reqHeight, false);
+        } else {
+          int width = bitmap.getWidth();
+          int height = bitmap.getHeight();
+          float scale = Math.max(width / (float) reqWidth, height / (float) reqHeight);
+          if (scale > 1 || options == CropImageView.RequestSizeOptions.RESIZE_FIT) {
+            resized =
+                Bitmap.createScaledBitmap(
+                    bitmap, (int) (width / scale), (int) (height / scale), false);
+          }
+        }
+        if (resized != null) {
+          if (resized != bitmap) {
+            bitmap.recycle();
+          }
+          return resized;
+        }
+      }
+    } catch (Exception e) {
+      Log.w("AIC", "Failed to resize cropped image, return bitmap before resize", e);
+    }
+    return bitmap;
+  }
+
+  // region: Private methods
+
+  /**
+   * Crop image bitmap from URI by decoding it with specific width and height to down-sample if
+   * required.
+   *
+   * @param orgWidth used to get rectangle from points (handle edge cases to limit rectangle)
+   * @param orgHeight used to get rectangle from points (handle edge cases to limit rectangle)
+   * @param sampleMulti used to increase the sampling of the image to handle memory issues.
+   */
+  private static BitmapSampled cropBitmap(
+      Context context,
+      Uri loadedImageUri,
+      float[] points,
+      int degreesRotated,
+      int orgWidth,
+      int orgHeight,
+      boolean fixAspectRatio,
+      int aspectRatioX,
+      int aspectRatioY,
+      int reqWidth,
+      int reqHeight,
+      boolean flipHorizontally,
+      boolean flipVertically,
+      int sampleMulti) {
+
+    // get the rectangle in original image that contains the required cropped area (larger for non
+    // rectangular crop)
+    Rect rect =
+        getRectFromPoints(points, orgWidth, orgHeight, fixAspectRatio, aspectRatioX, aspectRatioY);
+
+    int width = reqWidth > 0 ? reqWidth : rect.width();
+    int height = reqHeight > 0 ? reqHeight : rect.height();
+
+    Bitmap result = null;
+    int sampleSize = 1;
+    try {
+      // decode only the required image from URI, optionally sub-sampling if reqWidth/reqHeight is
+      // given.
+      BitmapSampled bitmapSampled =
+          decodeSampledBitmapRegion(context, loadedImageUri, rect, width, height, sampleMulti);
+      result = bitmapSampled.bitmap;
+      sampleSize = bitmapSampled.sampleSize;
+    } catch (Exception ignored) {
+    }
+
+    if (result != null) {
+      try {
+        // rotate the decoded region by the required amount
+        result = rotateAndFlipBitmapInt(result, degreesRotated, flipHorizontally, flipVertically);
+
+        // rotating by 0, 90, 180 or 270 degrees doesn't require extra cropping
+        if (degreesRotated % 90 != 0) {
+
+          // extra crop because non rectangular crop cannot be done directly on the image without
+          // rotating first
+          result =
+              cropForRotatedImage(
+                  result, points, rect, degreesRotated, fixAspectRatio, aspectRatioX, aspectRatioY);
+        }
+      } catch (OutOfMemoryError e) {
+        if (result != null) {
+          result.recycle();
+        }
+        throw e;
+      }
+      return new BitmapSampled(result, sampleSize);
+    } else {
+      // failed to decode region, may be skia issue, try full decode and then crop
+      return cropBitmap(
+          context,
+          loadedImageUri,
+          points,
+          degreesRotated,
+          fixAspectRatio,
+          aspectRatioX,
+          aspectRatioY,
+          sampleMulti,
+          rect,
+          width,
+          height,
+          flipHorizontally,
+          flipVertically);
+    }
+  }
+
+  /**
+   * Crop bitmap by fully loading the original and then cropping it, fallback in case cropping
+   * region failed.
+   */
+  private static BitmapSampled cropBitmap(
+      Context context,
+      Uri loadedImageUri,
+      float[] points,
+      int degreesRotated,
+      boolean fixAspectRatio,
+      int aspectRatioX,
+      int aspectRatioY,
+      int sampleMulti,
+      Rect rect,
+      int width,
+      int height,
+      boolean flipHorizontally,
+      boolean flipVertically) {
+    Bitmap result = null;
+    int sampleSize;
+    try {
+      BitmapFactory.Options options = new BitmapFactory.Options();
+      options.inSampleSize =
+          sampleSize =
+              sampleMulti
+                  * calculateInSampleSizeByReqestedSize(rect.width(), rect.height(), width, height);
+
+      Bitmap fullBitmap = decodeImage(context.getContentResolver(), loadedImageUri, options);
+      if (fullBitmap != null) {
+        try {
+          // adjust crop points by the sampling because the image is smaller
+          float[] points2 = new float[points.length];
+          System.arraycopy(points, 0, points2, 0, points.length);
+          for (int i = 0; i < points2.length; i++) {
+            points2[i] = points2[i] / options.inSampleSize;
+          }
+
+          result =
+              cropBitmapObjectWithScale(
+                  fullBitmap,
+                  points2,
+                  degreesRotated,
+                  fixAspectRatio,
+                  aspectRatioX,
+                  aspectRatioY,
+                  1,
+                  flipHorizontally,
+                  flipVertically);
+        } finally {
+          if (result != fullBitmap) {
+            fullBitmap.recycle();
+          }
+        }
+      }
+    } catch (OutOfMemoryError e) {
+      if (result != null) {
+        result.recycle();
+      }
+      throw e;
+    } catch (Exception e) {
+      throw new RuntimeException(
+          "Failed to load sampled bitmap: " + loadedImageUri + "\r\n" + e.getMessage(), e);
+    }
+    return new BitmapSampled(result, sampleSize);
+  }
+
+  /** Decode image from uri using "inJustDecodeBounds" to get the image dimensions. */
+  private static BitmapFactory.Options decodeImageForOption(ContentResolver resolver, Uri uri)
+      throws FileNotFoundException {
+    InputStream stream = null;
+    try {
+      stream = resolver.openInputStream(uri);
+      BitmapFactory.Options options = new BitmapFactory.Options();
+      options.inJustDecodeBounds = true;
+      BitmapFactory.decodeStream(stream, EMPTY_RECT, options);
+      options.inJustDecodeBounds = false;
+      return options;
+    } finally {
+      closeSafe(stream);
+    }
+  }
+
+  /**
+   * Decode image from uri using given "inSampleSize", but if failed due to out-of-memory then raise
+   * the inSampleSize until success.
+   */
+  private static Bitmap decodeImage(
+      ContentResolver resolver, Uri uri, BitmapFactory.Options options)
+      throws FileNotFoundException {
+    do {
+      InputStream stream = null;
+      try {
+        stream = resolver.openInputStream(uri);
+        return BitmapFactory.decodeStream(stream, EMPTY_RECT, options);
+      } catch (OutOfMemoryError e) {
+        options.inSampleSize *= 2;
+      } finally {
+        closeSafe(stream);
+      }
+    } while (options.inSampleSize <= 512);
+    throw new RuntimeException("Failed to decode image: " + uri);
+  }
+
+  /**
+   * Decode specific rectangle bitmap from stream using sampling to get bitmap with the requested
+   * limit.
+   *
+   * @param sampleMulti used to increase the sampling of the image to handle memory issues.
+   */
+  private static BitmapSampled decodeSampledBitmapRegion(
+      Context context, Uri uri, Rect rect, int reqWidth, int reqHeight, int sampleMulti) {
+    InputStream stream = null;
+    BitmapRegionDecoder decoder = null;
+    try {
+      BitmapFactory.Options options = new BitmapFactory.Options();
+      options.inSampleSize =
+          sampleMulti
+              * calculateInSampleSizeByReqestedSize(
+                  rect.width(), rect.height(), reqWidth, reqHeight);
+
+      stream = context.getContentResolver().openInputStream(uri);
+      decoder = BitmapRegionDecoder.newInstance(stream, false);
+      do {
+        try {
+          return new BitmapSampled(decoder.decodeRegion(rect, options), options.inSampleSize);
+        } catch (OutOfMemoryError e) {
+          options.inSampleSize *= 2;
+        }
+      } while (options.inSampleSize <= 512);
+    } catch (Exception e) {
+      throw new RuntimeException(
+          "Failed to load sampled bitmap: " + uri + "\r\n" + e.getMessage(), e);
+    } finally {
+      closeSafe(stream);
+      if (decoder != null) {
+        decoder.recycle();
+      }
+    }
+    return new BitmapSampled(null, 1);
+  }
+
+  /**
+   * Special crop of bitmap rotated by not stright angle, in this case the original crop bitmap
+   * contains parts beyond the required crop area, this method crops the already cropped and rotated
+   * bitmap to the final rectangle.<br>
+   * Note: rotating by 0, 90, 180 or 270 degrees doesn't require extra cropping.
+   */
+  private static Bitmap cropForRotatedImage(
+      Bitmap bitmap,
+      float[] points,
+      Rect rect,
+      int degreesRotated,
+      boolean fixAspectRatio,
+      int aspectRatioX,
+      int aspectRatioY) {
+    if (degreesRotated % 90 != 0) {
+
+      int adjLeft = 0, adjTop = 0, width = 0, height = 0;
+      double rads = Math.toRadians(degreesRotated);
+      int compareTo =
+          degreesRotated < 90 || (degreesRotated > 180 && degreesRotated < 270)
+              ? rect.left
+              : rect.right;
+      for (int i = 0; i < points.length; i += 2) {
+        if (points[i] >= compareTo - 1 && points[i] <= compareTo + 1) {
+          adjLeft = (int) Math.abs(Math.sin(rads) * (rect.bottom - points[i + 1]));
+          adjTop = (int) Math.abs(Math.cos(rads) * (points[i + 1] - rect.top));
+          width = (int) Math.abs((points[i + 1] - rect.top) / Math.sin(rads));
+          height = (int) Math.abs((rect.bottom - points[i + 1]) / Math.cos(rads));
+          break;
+        }
+      }
+
+      rect.set(adjLeft, adjTop, adjLeft + width, adjTop + height);
+      if (fixAspectRatio) {
+        fixRectForAspectRatio(rect, aspectRatioX, aspectRatioY);
+      }
+
+      Bitmap bitmapTmp = bitmap;
+      bitmap = Bitmap.createBitmap(bitmap, rect.left, rect.top, rect.width(), rect.height());
+      if (bitmapTmp != bitmap) {
+        bitmapTmp.recycle();
+      }
+    }
+    return bitmap;
+  }
+
+  /**
+   * Calculate the largest inSampleSize value that is a power of 2 and keeps both height and width
+   * larger than the requested height and width.
+   */
+  private static int calculateInSampleSizeByReqestedSize(
+      int width, int height, int reqWidth, int reqHeight) {
+    int inSampleSize = 1;
+    if (height > reqHeight || width > reqWidth) {
+      while ((height / 2 / inSampleSize) > reqHeight && (width / 2 / inSampleSize) > reqWidth) {
+        inSampleSize *= 2;
+      }
+    }
+    return inSampleSize;
+  }
+
+  /**
+   * Calculate the largest inSampleSize value that is a power of 2 and keeps both height and width
+   * smaller than max texture size allowed for the device.
+   */
+  private static int calculateInSampleSizeByMaxTextureSize(int width, int height) {
+    int inSampleSize = 1;
+    if (mMaxTextureSize == 0) {
+      mMaxTextureSize = getMaxTextureSize();
+    }
+    if (mMaxTextureSize > 0) {
+      while ((height / inSampleSize) > mMaxTextureSize
+          || (width / inSampleSize) > mMaxTextureSize) {
+        inSampleSize *= 2;
+      }
+    }
+    return inSampleSize;
+  }
+
+  /**
+   * Rotate the given bitmap by the given degrees.<br>
+   * New bitmap is created and the old one is recycled.
+   */
+  private static Bitmap rotateAndFlipBitmapInt(
+      Bitmap bitmap, int degrees, boolean flipHorizontally, boolean flipVertically) {
+    if (degrees > 0 || flipHorizontally || flipVertically) {
+      Matrix matrix = new Matrix();
+      matrix.setRotate(degrees);
+      matrix.postScale(flipHorizontally ? -1 : 1, flipVertically ? -1 : 1);
+      Bitmap newBitmap =
+          Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, false);
+      if (newBitmap != bitmap) {
+        bitmap.recycle();
+      }
+      return newBitmap;
+    } else {
+      return bitmap;
+    }
+  }
+
+  /**
+   * Get the max size of bitmap allowed to be rendered on the device.<br>
+   * http://stackoverflow.com/questions/7428996/hw-accelerated-activity-how-to-get-opengl-texture-size-limit.
+   */
+  private static int getMaxTextureSize() {
+    // Safe minimum default size
+    final int IMAGE_MAX_BITMAP_DIMENSION = 2048;
+
+    try {
+      // Get EGL Display
+      EGL10 egl = (EGL10) EGLContext.getEGL();
+      EGLDisplay display = egl.eglGetDisplay(EGL10.EGL_DEFAULT_DISPLAY);
+
+      // Initialise
+      int[] version = new int[2];
+      egl.eglInitialize(display, version);
+
+      // Query total number of configurations
+      int[] totalConfigurations = new int[1];
+      egl.eglGetConfigs(display, null, 0, totalConfigurations);
+
+      // Query actual list configurations
+      EGLConfig[] configurationsList = new EGLConfig[totalConfigurations[0]];
+      egl.eglGetConfigs(display, configurationsList, totalConfigurations[0], totalConfigurations);
+
+      int[] textureSize = new int[1];
+      int maximumTextureSize = 0;
+
+      // Iterate through all the configurations to located the maximum texture size
+      for (int i = 0; i < totalConfigurations[0]; i++) {
+        // Only need to check for width since opengl textures are always squared
+        egl.eglGetConfigAttrib(
+            display, configurationsList[i], EGL10.EGL_MAX_PBUFFER_WIDTH, textureSize);
+
+        // Keep track of the maximum texture size
+        if (maximumTextureSize < textureSize[0]) {
+          maximumTextureSize = textureSize[0];
+        }
+      }
+
+      // Release
+      egl.eglTerminate(display);
+
+      // Return largest texture size found, or default
+      return Math.max(maximumTextureSize, IMAGE_MAX_BITMAP_DIMENSION);
+    } catch (Exception e) {
+      return IMAGE_MAX_BITMAP_DIMENSION;
+    }
+  }
+
+  /**
+   * Close the given closeable object (Stream) in a safe way: check if it is null and catch-log
+   * exception thrown.
+   *
+   * @param closeable the closable object to close
+   */
+  private static void closeSafe(Closeable closeable) {
+    if (closeable != null) {
+      try {
+        closeable.close();
+      } catch (IOException ignored) {
+      }
+    }
+  }
+  // endregion
+
+  // region: Inner class: BitmapSampled
+
+  /** Holds bitmap instance and the sample size that the bitmap was loaded/cropped with. */
+  static final class BitmapSampled {
+
+    /** The bitmap instance */
+    public final Bitmap bitmap;
+
+    /** The sample size used to lower the size of the bitmap (1,2,4,8,...) */
+    final int sampleSize;
+
+    BitmapSampled(Bitmap bitmap, int sampleSize) {
+      this.bitmap = bitmap;
+      this.sampleSize = sampleSize;
+    }
+  }
+  // endregion
+
+  // region: Inner class: RotateBitmapResult
+
+  /** The result of {@link #rotateBitmapByExif(Bitmap, ExifInterface)}. */
+  static final class RotateBitmapResult {
+
+    /** The loaded bitmap */
+    public final Bitmap bitmap;
+
+    /** The degrees the image was rotated */
+    final int degrees;
+
+    RotateBitmapResult(Bitmap bitmap, int degrees) {
+      this.bitmap = bitmap;
+      this.degrees = degrees;
+    }
+  }
+  // endregion
+}

File diff suppressed because it is too large
+ 1020 - 0
app/src/main/java/com/miekir/ocr/view/cropper/CropImage.java


+ 352 - 0
app/src/main/java/com/miekir/ocr/view/cropper/CropImageActivity.java

@@ -0,0 +1,352 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+package com.miekir.ocr.view.cropper;
+
+import android.Manifest;
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.graphics.Bitmap;
+import android.graphics.PorterDuff;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.os.Bundle;
+import androidx.annotation.NonNull;
+import androidx.core.content.ContextCompat;
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.app.AppCompatActivity;
+import android.util.Log;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.widget.Toast;
+
+import com.miekir.ocr.R;
+
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * Built-in activity for image cropping.<br>
+ * Use {@link CropImage#activity(Uri)} to create a builder to start this activity.
+ */
+public class CropImageActivity extends AppCompatActivity
+    implements CropImageView.OnSetImageUriCompleteListener,
+        CropImageView.OnCropImageCompleteListener {
+
+  /** The crop image view library widget used in the activity */
+  private CropImageView mCropImageView;
+
+  /** Persist URI image to crop URI if specific permissions are required */
+  private Uri mCropImageUri;
+
+  /** the options that were set for the crop image */
+  private CropImageOptions mOptions;
+
+  @Override
+  @SuppressLint("NewApi")
+  public void onCreate(Bundle savedInstanceState) {
+    super.onCreate(savedInstanceState);
+    setContentView(R.layout.crop_image_activity);
+
+    mCropImageView = findViewById(R.id.cropImageView);
+
+    Bundle bundle = getIntent().getBundleExtra(CropImage.CROP_IMAGE_EXTRA_BUNDLE);
+    mCropImageUri = bundle.getParcelable(CropImage.CROP_IMAGE_EXTRA_SOURCE);
+    mOptions = bundle.getParcelable(CropImage.CROP_IMAGE_EXTRA_OPTIONS);
+
+    if (savedInstanceState == null) {
+      if (mCropImageUri == null || mCropImageUri.equals(Uri.EMPTY)) {
+        if (CropImage.isExplicitCameraPermissionRequired(this)) {
+          // request permissions and handle the result in onRequestPermissionsResult()
+          requestPermissions(
+              new String[] {Manifest.permission.CAMERA},
+              CropImage.CAMERA_CAPTURE_PERMISSIONS_REQUEST_CODE);
+        } else {
+          CropImage.startPickImageActivity(this);
+        }
+      } else if (CropImage.isReadExternalStoragePermissionsRequired(this, mCropImageUri)) {
+        // request permissions and handle the result in onRequestPermissionsResult()
+        requestPermissions(
+            new String[] {Manifest.permission.READ_EXTERNAL_STORAGE},
+            CropImage.PICK_IMAGE_PERMISSIONS_REQUEST_CODE);
+      } else {
+        // no permissions required or already grunted, can start crop image activity
+        mCropImageView.setImageUriAsync(mCropImageUri);
+      }
+    }
+
+    ActionBar actionBar = getSupportActionBar();
+    if (actionBar != null) {
+      CharSequence title = mOptions != null &&
+          mOptions.activityTitle != null && mOptions.activityTitle.length() > 0
+              ? mOptions.activityTitle
+              : getResources().getString(R.string.crop_image_activity_title);
+      actionBar.setTitle(title);
+      actionBar.setDisplayHomeAsUpEnabled(true);
+    }
+  }
+
+  @Override
+  protected void onStart() {
+    super.onStart();
+    mCropImageView.setOnSetImageUriCompleteListener(this);
+    mCropImageView.setOnCropImageCompleteListener(this);
+  }
+
+  @Override
+  protected void onStop() {
+    super.onStop();
+    mCropImageView.setOnSetImageUriCompleteListener(null);
+    mCropImageView.setOnCropImageCompleteListener(null);
+  }
+
+  @Override
+  public boolean onCreateOptionsMenu(Menu menu) {
+    getMenuInflater().inflate(R.menu.crop_image_menu, menu);
+
+    if (!mOptions.allowRotation) {
+      menu.removeItem(R.id.crop_image_menu_rotate_left);
+      menu.removeItem(R.id.crop_image_menu_rotate_right);
+    } else if (mOptions.allowCounterRotation) {
+      menu.findItem(R.id.crop_image_menu_rotate_left).setVisible(true);
+    }
+
+    if (!mOptions.allowFlipping) {
+      menu.removeItem(R.id.crop_image_menu_flip);
+    }
+
+    if (mOptions.cropMenuCropButtonTitle != null) {
+      menu.findItem(R.id.crop_image_menu_crop).setTitle(mOptions.cropMenuCropButtonTitle);
+    }
+
+    Drawable cropIcon = null;
+    try {
+      if (mOptions.cropMenuCropButtonIcon != 0) {
+        cropIcon = ContextCompat.getDrawable(this, mOptions.cropMenuCropButtonIcon);
+        menu.findItem(R.id.crop_image_menu_crop).setIcon(cropIcon);
+      }
+    } catch (Exception e) {
+      Log.w("AIC", "Failed to read menu crop drawable", e);
+    }
+
+    if (mOptions.activityMenuIconColor != 0) {
+      updateMenuItemIconColor(
+          menu, R.id.crop_image_menu_rotate_left, mOptions.activityMenuIconColor);
+      updateMenuItemIconColor(
+          menu, R.id.crop_image_menu_rotate_right, mOptions.activityMenuIconColor);
+      updateMenuItemIconColor(menu, R.id.crop_image_menu_flip, mOptions.activityMenuIconColor);
+      if (cropIcon != null) {
+        updateMenuItemIconColor(menu, R.id.crop_image_menu_crop, mOptions.activityMenuIconColor);
+      }
+    }
+    return true;
+  }
+
+  @Override
+  public boolean onOptionsItemSelected(MenuItem item) {
+    if (item.getItemId() == R.id.crop_image_menu_crop) {
+      cropImage();
+      return true;
+    }
+    if (item.getItemId() == R.id.crop_image_menu_rotate_left) {
+      rotateImage(-mOptions.rotationDegrees);
+      return true;
+    }
+    if (item.getItemId() == R.id.crop_image_menu_rotate_right) {
+      rotateImage(mOptions.rotationDegrees);
+      return true;
+    }
+    if (item.getItemId() == R.id.crop_image_menu_flip_horizontally) {
+      mCropImageView.flipImageHorizontally();
+      return true;
+    }
+    if (item.getItemId() == R.id.crop_image_menu_flip_vertically) {
+      mCropImageView.flipImageVertically();
+      return true;
+    }
+    if (item.getItemId() == android.R.id.home) {
+      setResultCancel();
+      return true;
+    }
+    return super.onOptionsItemSelected(item);
+  }
+
+  @Override
+  public void onBackPressed() {
+    super.onBackPressed();
+    setResultCancel();
+  }
+
+  @Override
+  @SuppressLint("NewApi")
+  protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+
+    // handle result of pick image chooser
+    if (requestCode == CropImage.PICK_IMAGE_CHOOSER_REQUEST_CODE) {
+      if (resultCode == Activity.RESULT_CANCELED) {
+        // User cancelled the picker. We don't have anything to crop
+        setResultCancel();
+      }
+
+      if (resultCode == Activity.RESULT_OK) {
+        mCropImageUri = CropImage.getPickImageResultUri(this, data);
+
+        // For API >= 23 we need to check specifically that we have permissions to read external
+        // storage.
+        if (CropImage.isReadExternalStoragePermissionsRequired(this, mCropImageUri)) {
+          // request permissions and handle the result in onRequestPermissionsResult()
+          requestPermissions(
+              new String[] {Manifest.permission.READ_EXTERNAL_STORAGE},
+              CropImage.PICK_IMAGE_PERMISSIONS_REQUEST_CODE);
+        } else {
+          // no permissions required or already grunted, can start crop image activity
+          mCropImageView.setImageUriAsync(mCropImageUri);
+        }
+      }
+    }
+  }
+
+  @Override
+  public void onRequestPermissionsResult(
+      int requestCode, @NonNull String permissions[], @NonNull int[] grantResults) {
+    if (requestCode == CropImage.PICK_IMAGE_PERMISSIONS_REQUEST_CODE) {
+      if (mCropImageUri != null
+          && grantResults.length > 0
+          && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+        // required permissions granted, start crop image activity
+        mCropImageView.setImageUriAsync(mCropImageUri);
+      } else {
+        Toast.makeText(this, R.string.crop_image_activity_no_permissions, Toast.LENGTH_LONG).show();
+        setResultCancel();
+      }
+    }
+
+    if (requestCode == CropImage.CAMERA_CAPTURE_PERMISSIONS_REQUEST_CODE) {
+      // Irrespective of whether camera permission was given or not, we show the picker
+      // The picker will not add the camera intent if permission is not available
+      CropImage.startPickImageActivity(this);
+    }
+  }
+
+  @Override
+  public void onSetImageUriComplete(CropImageView view, Uri uri, Exception error) {
+    if (error == null) {
+      if (mOptions.initialCropWindowRectangle != null) {
+        mCropImageView.setCropRect(mOptions.initialCropWindowRectangle);
+      }
+      if (mOptions.initialRotation > -1) {
+        mCropImageView.setRotatedDegrees(mOptions.initialRotation);
+      }
+    } else {
+      setResult(null, error, 1);
+    }
+  }
+
+  @Override
+  public void onCropImageComplete(CropImageView view, CropImageView.CropResult result) {
+    setResult(result.getUri(), result.getError(), result.getSampleSize());
+  }
+
+  // region: Private methods
+
+  /** Execute crop image and save the result tou output uri. */
+  protected void cropImage() {
+    if (mOptions.noOutputImage) {
+      setResult(null, null, 1);
+    } else {
+      Uri outputUri = getOutputUri();
+      mCropImageView.saveCroppedImageAsync(
+          outputUri,
+          mOptions.outputCompressFormat,
+          mOptions.outputCompressQuality,
+          mOptions.outputRequestWidth,
+          mOptions.outputRequestHeight,
+          mOptions.outputRequestSizeOptions);
+    }
+  }
+
+  /** Rotate the image in the crop image view. */
+  protected void rotateImage(int degrees) {
+    mCropImageView.rotateImage(degrees);
+  }
+
+  /**
+   * Get Android uri to save the cropped image into.<br>
+   * Use the given in options or create a temp file.
+   */
+  protected Uri getOutputUri() {
+    Uri outputUri = mOptions.outputUri;
+    if (outputUri == null || outputUri.equals(Uri.EMPTY)) {
+      try {
+        String ext =
+            mOptions.outputCompressFormat == Bitmap.CompressFormat.JPEG
+                ? ".jpg"
+                : mOptions.outputCompressFormat == Bitmap.CompressFormat.PNG ? ".png" : ".webp";
+        outputUri = Uri.fromFile(File.createTempFile("cropped", ext, getCacheDir()));
+      } catch (IOException e) {
+        throw new RuntimeException("Failed to create temp file for output image", e);
+      }
+    }
+    return outputUri;
+  }
+
+  /** Result with cropped image data or error if failed. */
+  protected void setResult(Uri uri, Exception error, int sampleSize) {
+    int resultCode = error == null ? RESULT_OK : CropImage.CROP_IMAGE_ACTIVITY_RESULT_ERROR_CODE;
+    setResult(resultCode, getResultIntent(uri, error, sampleSize));
+    finish();
+  }
+
+  /** Cancel of cropping activity. */
+  protected void setResultCancel() {
+    setResult(RESULT_CANCELED);
+    finish();
+  }
+
+  /** Get intent instance to be used for the result of this activity. */
+  protected Intent getResultIntent(Uri uri, Exception error, int sampleSize) {
+    CropImage.ActivityResult result =
+        new CropImage.ActivityResult(
+            mCropImageView.getImageUri(),
+            uri,
+            error,
+            mCropImageView.getCropPoints(),
+            mCropImageView.getCropRect(),
+            mCropImageView.getRotatedDegrees(),
+            mCropImageView.getWholeImageRect(),
+            sampleSize);
+    Intent intent = new Intent();
+    intent.putExtras(getIntent());
+    intent.putExtra(CropImage.CROP_IMAGE_EXTRA_RESULT, result);
+    return intent;
+  }
+
+  /** Update the color of a specific menu item to the given color. */
+  private void updateMenuItemIconColor(Menu menu, int itemId, int color) {
+    MenuItem menuItem = menu.findItem(itemId);
+    if (menuItem != null) {
+      Drawable menuItemIcon = menuItem.getIcon();
+      if (menuItemIcon != null) {
+        try {
+          menuItemIcon.mutate();
+          menuItemIcon.setColorFilter(color, PorterDuff.Mode.SRC_ATOP);
+          menuItem.setIcon(menuItemIcon);
+        } catch (Exception e) {
+          Log.w("AIC", "Failed to update menu item color", e);
+        }
+      }
+    }
+  }
+  // endregion
+}

+ 121 - 0
app/src/main/java/com/miekir/ocr/view/cropper/CropImageAnimation.java

@@ -0,0 +1,121 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+package com.miekir.ocr.view.cropper;
+
+import android.graphics.Matrix;
+import android.graphics.RectF;
+import android.view.animation.AccelerateDecelerateInterpolator;
+import android.view.animation.Animation;
+import android.view.animation.Transformation;
+import android.widget.ImageView;
+
+/**
+ * Animation to handle smooth cropping image matrix transformation change, specifically for
+ * zoom-in/out.
+ */
+final class CropImageAnimation extends Animation implements Animation.AnimationListener {
+
+  // region: Fields and Consts
+
+  private final ImageView mImageView;
+
+  private final CropOverlayView mCropOverlayView;
+
+  private final float[] mStartBoundPoints = new float[8];
+
+  private final float[] mEndBoundPoints = new float[8];
+
+  private final RectF mStartCropWindowRect = new RectF();
+
+  private final RectF mEndCropWindowRect = new RectF();
+
+  private final float[] mStartImageMatrix = new float[9];
+
+  private final float[] mEndImageMatrix = new float[9];
+
+  private final RectF mAnimRect = new RectF();
+
+  private final float[] mAnimPoints = new float[8];
+
+  private final float[] mAnimMatrix = new float[9];
+  // endregion
+
+  public CropImageAnimation(ImageView cropImageView, CropOverlayView cropOverlayView) {
+    mImageView = cropImageView;
+    mCropOverlayView = cropOverlayView;
+
+    setDuration(300);
+    setFillAfter(true);
+    setInterpolator(new AccelerateDecelerateInterpolator());
+    setAnimationListener(this);
+  }
+
+  public void setStartState(float[] boundPoints, Matrix imageMatrix) {
+    reset();
+    System.arraycopy(boundPoints, 0, mStartBoundPoints, 0, 8);
+    mStartCropWindowRect.set(mCropOverlayView.getCropWindowRect());
+    imageMatrix.getValues(mStartImageMatrix);
+  }
+
+  public void setEndState(float[] boundPoints, Matrix imageMatrix) {
+    System.arraycopy(boundPoints, 0, mEndBoundPoints, 0, 8);
+    mEndCropWindowRect.set(mCropOverlayView.getCropWindowRect());
+    imageMatrix.getValues(mEndImageMatrix);
+  }
+
+  @Override
+  protected void applyTransformation(float interpolatedTime, Transformation t) {
+
+    mAnimRect.left =
+        mStartCropWindowRect.left
+            + (mEndCropWindowRect.left - mStartCropWindowRect.left) * interpolatedTime;
+    mAnimRect.top =
+        mStartCropWindowRect.top
+            + (mEndCropWindowRect.top - mStartCropWindowRect.top) * interpolatedTime;
+    mAnimRect.right =
+        mStartCropWindowRect.right
+            + (mEndCropWindowRect.right - mStartCropWindowRect.right) * interpolatedTime;
+    mAnimRect.bottom =
+        mStartCropWindowRect.bottom
+            + (mEndCropWindowRect.bottom - mStartCropWindowRect.bottom) * interpolatedTime;
+    mCropOverlayView.setCropWindowRect(mAnimRect);
+
+    for (int i = 0; i < mAnimPoints.length; i++) {
+      mAnimPoints[i] =
+          mStartBoundPoints[i] + (mEndBoundPoints[i] - mStartBoundPoints[i]) * interpolatedTime;
+    }
+    mCropOverlayView.setBounds(mAnimPoints, mImageView.getWidth(), mImageView.getHeight());
+
+    for (int i = 0; i < mAnimMatrix.length; i++) {
+      mAnimMatrix[i] =
+          mStartImageMatrix[i] + (mEndImageMatrix[i] - mStartImageMatrix[i]) * interpolatedTime;
+    }
+    Matrix m = mImageView.getImageMatrix();
+    m.setValues(mAnimMatrix);
+    mImageView.setImageMatrix(m);
+
+    mImageView.invalidate();
+    mCropOverlayView.invalidate();
+  }
+
+  @Override
+  public void onAnimationStart(Animation animation) {}
+
+  @Override
+  public void onAnimationEnd(Animation animation) {
+    mImageView.clearAnimation();
+  }
+
+  @Override
+  public void onAnimationRepeat(Animation animation) {}
+}

+ 463 - 0
app/src/main/java/com/miekir/ocr/view/cropper/CropImageOptions.java

@@ -0,0 +1,463 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth;
+// inexhaustible as the great rivers.
+// When they come to an end;
+// they begin again;
+// like the days and months;
+// they die and are reborn;
+// like the four seasons."
+//
+// - Sun Tsu;
+// "The Art of War"
+
+package com.miekir.ocr.view.cropper;
+
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.graphics.Rect;
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.TextUtils;
+import android.util.DisplayMetrics;
+import android.util.TypedValue;
+
+/**
+ * All the possible options that can be set to customize crop image.<br>
+ * Initialized with default values.
+ */
+public class CropImageOptions implements Parcelable {
+
+  public static final Creator<CropImageOptions> CREATOR =
+      new Creator<CropImageOptions>() {
+        @Override
+        public CropImageOptions createFromParcel(Parcel in) {
+          return new CropImageOptions(in);
+        }
+
+        @Override
+        public CropImageOptions[] newArray(int size) {
+          return new CropImageOptions[size];
+        }
+      };
+
+  /** The shape of the cropping window. */
+  public CropImageView.CropShape cropShape;
+
+  /**
+   * An edge of the crop window will snap to the corresponding edge of a specified bounding box when
+   * the crop window edge is less than or equal to this distance (in pixels) away from the bounding
+   * box edge. (in pixels)
+   */
+  public float snapRadius;
+
+  /**
+   * The radius of the touchable area around the handle. (in pixels)<br>
+   * We are basing this value off of the recommended 48dp Rhythm.<br>
+   * See: http://developer.android.com/design/style/metrics-grids.html#48dp-rhythm
+   */
+  public float touchRadius;
+
+  /** whether the guidelines should be on, off, or only showing when resizing. */
+  public CropImageView.Guidelines guidelines;
+
+  /** The initial scale type of the image in the crop image view */
+  public CropImageView.ScaleType scaleType;
+
+  /**
+   * if to show crop overlay UI what contains the crop window UI surrounded by background over the
+   * cropping image.<br>
+   * default: true, may disable for animation or frame transition.
+   */
+  public boolean showCropOverlay;
+
+  /**
+   * if to show progress bar when image async loading/cropping is in progress.<br>
+   * default: true, disable to provide custom progress bar UI.
+   */
+  public boolean showProgressBar;
+
+  /**
+   * if auto-zoom functionality is enabled.<br>
+   * default: true.
+   */
+  public boolean autoZoomEnabled;
+
+  /** if multi-touch should be enabled on the crop box default: false */
+  public boolean multiTouchEnabled;
+
+  /** The max zoom allowed during cropping. */
+  public int maxZoom;
+
+  /**
+   * The initial crop window padding from image borders in percentage of the cropping image
+   * dimensions.
+   */
+  public float initialCropWindowPaddingRatio;
+
+  /** whether the width to height aspect ratio should be maintained or free to change. */
+  public boolean fixAspectRatio;
+
+  /** the X value of the aspect ratio. */
+  public int aspectRatioX;
+
+  /** the Y value of the aspect ratio. */
+  public int aspectRatioY;
+
+  /** the thickness of the guidelines lines in pixels. (in pixels) */
+  public float borderLineThickness;
+
+  /** the color of the guidelines lines */
+  public int borderLineColor;
+
+  /** thickness of the corner line. (in pixels) */
+  public float borderCornerThickness;
+
+  /** the offset of corner line from crop window border. (in pixels) */
+  public float borderCornerOffset;
+
+  /** the length of the corner line away from the corner. (in pixels) */
+  public float borderCornerLength;
+
+  /** the color of the corner line */
+  public int borderCornerColor;
+
+  /** the thickness of the guidelines lines. (in pixels) */
+  public float guidelinesThickness;
+
+  /** the color of the guidelines lines */
+  public int guidelinesColor;
+
+  /**
+   * the color of the overlay background around the crop window cover the image parts not in the
+   * crop window.
+   */
+  public int backgroundColor;
+
+  /** the min width the crop window is allowed to be. (in pixels) */
+  public int minCropWindowWidth;
+
+  /** the min height the crop window is allowed to be. (in pixels) */
+  public int minCropWindowHeight;
+
+  /**
+   * the min width the resulting cropping image is allowed to be, affects the cropping window
+   * limits. (in pixels)
+   */
+  public int minCropResultWidth;
+
+  /**
+   * the min height the resulting cropping image is allowed to be, affects the cropping window
+   * limits. (in pixels)
+   */
+  public int minCropResultHeight;
+
+  /**
+   * the max width the resulting cropping image is allowed to be, affects the cropping window
+   * limits. (in pixels)
+   */
+  public int maxCropResultWidth;
+
+  /**
+   * the max height the resulting cropping image is allowed to be, affects the cropping window
+   * limits. (in pixels)
+   */
+  public int maxCropResultHeight;
+
+  /** the title of the {@link CropImageActivity} */
+  public CharSequence activityTitle;
+
+  /** the color to use for action bar items icons */
+  public int activityMenuIconColor;
+
+  /** the Android Uri to save the cropped image to */
+  public Uri outputUri;
+
+  /** the compression format to use when writing the image */
+  public Bitmap.CompressFormat outputCompressFormat;
+
+  /** the quality (if applicable) to use when writing the image (0 - 100) */
+  public int outputCompressQuality;
+
+  /** the width to resize the cropped image to (see options) */
+  public int outputRequestWidth;
+
+  /** the height to resize the cropped image to (see options) */
+  public int outputRequestHeight;
+
+  /** the resize method to use on the cropped bitmap (see options documentation) */
+  public CropImageView.RequestSizeOptions outputRequestSizeOptions;
+
+  /** if the result of crop image activity should not save the cropped image bitmap */
+  public boolean noOutputImage;
+
+  /** the initial rectangle to set on the cropping image after loading */
+  public Rect initialCropWindowRectangle;
+
+  /** the initial rotation to set on the cropping image after loading (0-360 degrees clockwise) */
+  public int initialRotation;
+
+  /** if to allow (all) rotation during cropping (activity) */
+  public boolean allowRotation;
+
+  /** if to allow (all) flipping during cropping (activity) */
+  public boolean allowFlipping;
+
+  /** if to allow counter-clockwise rotation during cropping (activity) */
+  public boolean allowCounterRotation;
+
+  /** the amount of degrees to rotate clockwise or counter-clockwise */
+  public int rotationDegrees;
+
+  /** whether the image should be flipped horizontally */
+  public boolean flipHorizontally;
+
+  /** whether the image should be flipped vertically */
+  public boolean flipVertically;
+
+  /** optional, the text of the crop menu crop button */
+  public CharSequence cropMenuCropButtonTitle;
+
+  /** optional image resource to be used for crop menu crop icon instead of text */
+  public int cropMenuCropButtonIcon;
+
+  /** Init options with defaults. */
+  public CropImageOptions() {
+
+    DisplayMetrics dm = Resources.getSystem().getDisplayMetrics();
+
+    cropShape = CropImageView.CropShape.RECTANGLE;
+    snapRadius = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 3, dm);
+    touchRadius = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 24, dm);
+    guidelines = CropImageView.Guidelines.ON_TOUCH;
+    scaleType = CropImageView.ScaleType.FIT_CENTER;
+    showCropOverlay = true;
+    showProgressBar = true;
+    autoZoomEnabled = true;
+    multiTouchEnabled = false;
+    maxZoom = 4;
+    initialCropWindowPaddingRatio = 0.1f;
+
+    fixAspectRatio = false;
+    aspectRatioX = 1;
+    aspectRatioY = 1;
+
+    borderLineThickness = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 3, dm);
+    borderLineColor = Color.argb(170, 255, 255, 255);
+    borderCornerThickness = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2, dm);
+    borderCornerOffset = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5, dm);
+    borderCornerLength = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 14, dm);
+    borderCornerColor = Color.WHITE;
+
+    guidelinesThickness = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1, dm);
+    guidelinesColor = Color.argb(170, 255, 255, 255);
+    backgroundColor = Color.argb(119, 0, 0, 0);
+
+    minCropWindowWidth = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 42, dm);
+    minCropWindowHeight = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 42, dm);
+    minCropResultWidth = 40;
+    minCropResultHeight = 40;
+    maxCropResultWidth = 99999;
+    maxCropResultHeight = 99999;
+
+    activityTitle = "";
+    activityMenuIconColor = 0;
+
+    outputUri = Uri.EMPTY;
+    outputCompressFormat = Bitmap.CompressFormat.JPEG;
+    outputCompressQuality = 90;
+    outputRequestWidth = 0;
+    outputRequestHeight = 0;
+    outputRequestSizeOptions = CropImageView.RequestSizeOptions.NONE;
+    noOutputImage = false;
+
+    initialCropWindowRectangle = null;
+    initialRotation = -1;
+    allowRotation = true;
+    allowFlipping = true;
+    allowCounterRotation = false;
+    rotationDegrees = 90;
+    flipHorizontally = false;
+    flipVertically = false;
+    cropMenuCropButtonTitle = null;
+
+    cropMenuCropButtonIcon = 0;
+  }
+
+  /** Create object from parcel. */
+  protected CropImageOptions(Parcel in) {
+    cropShape = CropImageView.CropShape.values()[in.readInt()];
+    snapRadius = in.readFloat();
+    touchRadius = in.readFloat();
+    guidelines = CropImageView.Guidelines.values()[in.readInt()];
+    scaleType = CropImageView.ScaleType.values()[in.readInt()];
+    showCropOverlay = in.readByte() != 0;
+    showProgressBar = in.readByte() != 0;
+    autoZoomEnabled = in.readByte() != 0;
+    multiTouchEnabled = in.readByte() != 0;
+    maxZoom = in.readInt();
+    initialCropWindowPaddingRatio = in.readFloat();
+    fixAspectRatio = in.readByte() != 0;
+    aspectRatioX = in.readInt();
+    aspectRatioY = in.readInt();
+    borderLineThickness = in.readFloat();
+    borderLineColor = in.readInt();
+    borderCornerThickness = in.readFloat();
+    borderCornerOffset = in.readFloat();
+    borderCornerLength = in.readFloat();
+    borderCornerColor = in.readInt();
+    guidelinesThickness = in.readFloat();
+    guidelinesColor = in.readInt();
+    backgroundColor = in.readInt();
+    minCropWindowWidth = in.readInt();
+    minCropWindowHeight = in.readInt();
+    minCropResultWidth = in.readInt();
+    minCropResultHeight = in.readInt();
+    maxCropResultWidth = in.readInt();
+    maxCropResultHeight = in.readInt();
+    activityTitle = TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(in);
+    activityMenuIconColor = in.readInt();
+    outputUri = in.readParcelable(Uri.class.getClassLoader());
+    outputCompressFormat = Bitmap.CompressFormat.valueOf(in.readString());
+    outputCompressQuality = in.readInt();
+    outputRequestWidth = in.readInt();
+    outputRequestHeight = in.readInt();
+    outputRequestSizeOptions = CropImageView.RequestSizeOptions.values()[in.readInt()];
+    noOutputImage = in.readByte() != 0;
+    initialCropWindowRectangle = in.readParcelable(Rect.class.getClassLoader());
+    initialRotation = in.readInt();
+    allowRotation = in.readByte() != 0;
+    allowFlipping = in.readByte() != 0;
+    allowCounterRotation = in.readByte() != 0;
+    rotationDegrees = in.readInt();
+    flipHorizontally = in.readByte() != 0;
+    flipVertically = in.readByte() != 0;
+    cropMenuCropButtonTitle = TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(in);
+    cropMenuCropButtonIcon = in.readInt();
+  }
+
+  @Override
+  public void writeToParcel(Parcel dest, int flags) {
+    dest.writeInt(cropShape.ordinal());
+    dest.writeFloat(snapRadius);
+    dest.writeFloat(touchRadius);
+    dest.writeInt(guidelines.ordinal());
+    dest.writeInt(scaleType.ordinal());
+    dest.writeByte((byte) (showCropOverlay ? 1 : 0));
+    dest.writeByte((byte) (showProgressBar ? 1 : 0));
+    dest.writeByte((byte) (autoZoomEnabled ? 1 : 0));
+    dest.writeByte((byte) (multiTouchEnabled ? 1 : 0));
+    dest.writeInt(maxZoom);
+    dest.writeFloat(initialCropWindowPaddingRatio);
+    dest.writeByte((byte) (fixAspectRatio ? 1 : 0));
+    dest.writeInt(aspectRatioX);
+    dest.writeInt(aspectRatioY);
+    dest.writeFloat(borderLineThickness);
+    dest.writeInt(borderLineColor);
+    dest.writeFloat(borderCornerThickness);
+    dest.writeFloat(borderCornerOffset);
+    dest.writeFloat(borderCornerLength);
+    dest.writeInt(borderCornerColor);
+    dest.writeFloat(guidelinesThickness);
+    dest.writeInt(guidelinesColor);
+    dest.writeInt(backgroundColor);
+    dest.writeInt(minCropWindowWidth);
+    dest.writeInt(minCropWindowHeight);
+    dest.writeInt(minCropResultWidth);
+    dest.writeInt(minCropResultHeight);
+    dest.writeInt(maxCropResultWidth);
+    dest.writeInt(maxCropResultHeight);
+    TextUtils.writeToParcel(activityTitle, dest, flags);
+    dest.writeInt(activityMenuIconColor);
+    dest.writeParcelable(outputUri, flags);
+    dest.writeString(outputCompressFormat.name());
+    dest.writeInt(outputCompressQuality);
+    dest.writeInt(outputRequestWidth);
+    dest.writeInt(outputRequestHeight);
+    dest.writeInt(outputRequestSizeOptions.ordinal());
+    dest.writeInt(noOutputImage ? 1 : 0);
+    dest.writeParcelable(initialCropWindowRectangle, flags);
+    dest.writeInt(initialRotation);
+    dest.writeByte((byte) (allowRotation ? 1 : 0));
+    dest.writeByte((byte) (allowFlipping ? 1 : 0));
+    dest.writeByte((byte) (allowCounterRotation ? 1 : 0));
+    dest.writeInt(rotationDegrees);
+    dest.writeByte((byte) (flipHorizontally ? 1 : 0));
+    dest.writeByte((byte) (flipVertically ? 1 : 0));
+    TextUtils.writeToParcel(cropMenuCropButtonTitle, dest, flags);
+    dest.writeInt(cropMenuCropButtonIcon);
+  }
+
+  @Override
+  public int describeContents() {
+    return 0;
+  }
+
+  /**
+   * Validate all the options are withing valid range.
+   *
+   * @throws IllegalArgumentException if any of the options is not valid
+   */
+  public void validate() {
+    if (maxZoom < 0) {
+      throw new IllegalArgumentException("Cannot set max zoom to a number < 1");
+    }
+    if (touchRadius < 0) {
+      throw new IllegalArgumentException("Cannot set touch radius value to a number <= 0 ");
+    }
+    if (initialCropWindowPaddingRatio < 0 || initialCropWindowPaddingRatio >= 0.5) {
+      throw new IllegalArgumentException(
+          "Cannot set initial crop window padding value to a number < 0 or >= 0.5");
+    }
+    if (aspectRatioX <= 0) {
+      throw new IllegalArgumentException(
+          "Cannot set aspect ratio value to a number less than or equal to 0.");
+    }
+    if (aspectRatioY <= 0) {
+      throw new IllegalArgumentException(
+          "Cannot set aspect ratio value to a number less than or equal to 0.");
+    }
+    if (borderLineThickness < 0) {
+      throw new IllegalArgumentException(
+          "Cannot set line thickness value to a number less than 0.");
+    }
+    if (borderCornerThickness < 0) {
+      throw new IllegalArgumentException(
+          "Cannot set corner thickness value to a number less than 0.");
+    }
+    if (guidelinesThickness < 0) {
+      throw new IllegalArgumentException(
+          "Cannot set guidelines thickness value to a number less than 0.");
+    }
+    if (minCropWindowHeight < 0) {
+      throw new IllegalArgumentException(
+          "Cannot set min crop window height value to a number < 0 ");
+    }
+    if (minCropResultWidth < 0) {
+      throw new IllegalArgumentException("Cannot set min crop result width value to a number < 0 ");
+    }
+    if (minCropResultHeight < 0) {
+      throw new IllegalArgumentException(
+          "Cannot set min crop result height value to a number < 0 ");
+    }
+    if (maxCropResultWidth < minCropResultWidth) {
+      throw new IllegalArgumentException(
+          "Cannot set max crop result width to smaller value than min crop result width");
+    }
+    if (maxCropResultHeight < minCropResultHeight) {
+      throw new IllegalArgumentException(
+          "Cannot set max crop result height to smaller value than min crop result height");
+    }
+    if (outputRequestWidth < 0) {
+      throw new IllegalArgumentException("Cannot set request width value to a number < 0 ");
+    }
+    if (outputRequestHeight < 0) {
+      throw new IllegalArgumentException("Cannot set request height value to a number < 0 ");
+    }
+    if (rotationDegrees < 0 || rotationDegrees > 360) {
+      throw new IllegalArgumentException(
+          "Cannot set rotation degrees value to a number < 0 or > 360");
+    }
+  }
+}

File diff suppressed because it is too large
+ 2137 - 0
app/src/main/java/com/miekir/ocr/view/cropper/CropImageView.java


File diff suppressed because it is too large
+ 1040 - 0
app/src/main/java/com/miekir/ocr/view/cropper/CropOverlayView.java


+ 371 - 0
app/src/main/java/com/miekir/ocr/view/cropper/CropWindowHandler.java

@@ -0,0 +1,371 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+package com.miekir.ocr.view.cropper;
+
+import android.graphics.RectF;
+
+/** Handler from crop window stuff, moving and knowing possition. */
+final class CropWindowHandler {
+
+  // region: Fields and Consts
+
+  /** The 4 edges of the crop window defining its coordinates and size */
+  private final RectF mEdges = new RectF();
+
+  /**
+   * Rectangle used to return the edges rectangle without ability to change it and without creating
+   * new all the time.
+   */
+  private final RectF mGetEdges = new RectF();
+
+  /** Minimum width in pixels that the crop window can get. */
+  private float mMinCropWindowWidth;
+
+  /** Minimum height in pixels that the crop window can get. */
+  private float mMinCropWindowHeight;
+
+  /** Maximum width in pixels that the crop window can CURRENTLY get. */
+  private float mMaxCropWindowWidth;
+
+  /** Maximum height in pixels that the crop window can CURRENTLY get. */
+  private float mMaxCropWindowHeight;
+
+  /**
+   * Minimum width in pixels that the result of cropping an image can get, affects crop window width
+   * adjusted by width scale factor.
+   */
+  private float mMinCropResultWidth;
+
+  /**
+   * Minimum height in pixels that the result of cropping an image can get, affects crop window
+   * height adjusted by height scale factor.
+   */
+  private float mMinCropResultHeight;
+
+  /**
+   * Maximum width in pixels that the result of cropping an image can get, affects crop window width
+   * adjusted by width scale factor.
+   */
+  private float mMaxCropResultWidth;
+
+  /**
+   * Maximum height in pixels that the result of cropping an image can get, affects crop window
+   * height adjusted by height scale factor.
+   */
+  private float mMaxCropResultHeight;
+
+  /** The width scale factor of shown image and actual image */
+  private float mScaleFactorWidth = 1;
+
+  /** The height scale factor of shown image and actual image */
+  private float mScaleFactorHeight = 1;
+  // endregion
+
+  /** Get the left/top/right/bottom coordinates of the crop window. */
+  public RectF getRect() {
+    mGetEdges.set(mEdges);
+    return mGetEdges;
+  }
+
+  /** Minimum width in pixels that the crop window can get. */
+  public float getMinCropWidth() {
+    return Math.max(mMinCropWindowWidth, mMinCropResultWidth / mScaleFactorWidth);
+  }
+
+  /** Minimum height in pixels that the crop window can get. */
+  public float getMinCropHeight() {
+    return Math.max(mMinCropWindowHeight, mMinCropResultHeight / mScaleFactorHeight);
+  }
+
+  /** Maximum width in pixels that the crop window can get. */
+  public float getMaxCropWidth() {
+    return Math.min(mMaxCropWindowWidth, mMaxCropResultWidth / mScaleFactorWidth);
+  }
+
+  /** Maximum height in pixels that the crop window can get. */
+  public float getMaxCropHeight() {
+    return Math.min(mMaxCropWindowHeight, mMaxCropResultHeight / mScaleFactorHeight);
+  }
+
+  /** get the scale factor (on width) of the showen image to original image. */
+  public float getScaleFactorWidth() {
+    return mScaleFactorWidth;
+  }
+
+  /** get the scale factor (on height) of the showen image to original image. */
+  public float getScaleFactorHeight() {
+    return mScaleFactorHeight;
+  }
+
+  /**
+   * the min size the resulting cropping image is allowed to be, affects the cropping window limits
+   * (in pixels).<br>
+   */
+  public void setMinCropResultSize(int minCropResultWidth, int minCropResultHeight) {
+    mMinCropResultWidth = minCropResultWidth;
+    mMinCropResultHeight = minCropResultHeight;
+  }
+
+  /**
+   * the max size the resulting cropping image is allowed to be, affects the cropping window limits
+   * (in pixels).<br>
+   */
+  public void setMaxCropResultSize(int maxCropResultWidth, int maxCropResultHeight) {
+    mMaxCropResultWidth = maxCropResultWidth;
+    mMaxCropResultHeight = maxCropResultHeight;
+  }
+
+  /**
+   * set the max width/height and scale factor of the showen image to original image to scale the
+   * limits appropriately.
+   */
+  public void setCropWindowLimits(
+      float maxWidth, float maxHeight, float scaleFactorWidth, float scaleFactorHeight) {
+    mMaxCropWindowWidth = maxWidth;
+    mMaxCropWindowHeight = maxHeight;
+    mScaleFactorWidth = scaleFactorWidth;
+    mScaleFactorHeight = scaleFactorHeight;
+  }
+
+  /** Set the variables to be used during crop window handling. */
+  public void setInitialAttributeValues(CropImageOptions options) {
+    mMinCropWindowWidth = options.minCropWindowWidth;
+    mMinCropWindowHeight = options.minCropWindowHeight;
+    mMinCropResultWidth = options.minCropResultWidth;
+    mMinCropResultHeight = options.minCropResultHeight;
+    mMaxCropResultWidth = options.maxCropResultWidth;
+    mMaxCropResultHeight = options.maxCropResultHeight;
+  }
+
+  /** Set the left/top/right/bottom coordinates of the crop window. */
+  public void setRect(RectF rect) {
+    mEdges.set(rect);
+  }
+
+  /**
+   * Indicates whether the crop window is small enough that the guidelines should be shown. Public
+   * because this function is also used to determine if the center handle should be focused.
+   *
+   * @return boolean Whether the guidelines should be shown or not
+   */
+  public boolean showGuidelines() {
+    return !(mEdges.width() < 100 || mEdges.height() < 100);
+  }
+
+  /**
+   * Determines which, if any, of the handles are pressed given the touch coordinates, the bounding
+   * box, and the touch radius.
+   *
+   * @param x the x-coordinate of the touch point
+   * @param y the y-coordinate of the touch point
+   * @param targetRadius the target radius in pixels
+   * @return the Handle that was pressed; null if no Handle was pressed
+   */
+  public CropWindowMoveHandler getMoveHandler(
+      float x, float y, float targetRadius, CropImageView.CropShape cropShape) {
+    CropWindowMoveHandler.Type type =
+        cropShape == CropImageView.CropShape.OVAL
+            ? getOvalPressedMoveType(x, y)
+            : getRectanglePressedMoveType(x, y, targetRadius);
+    return type != null ? new CropWindowMoveHandler(type, this, x, y) : null;
+  }
+
+  // region: Private methods
+
+  /**
+   * Determines which, if any, of the handles are pressed given the touch coordinates, the bounding
+   * box, and the touch radius.
+   *
+   * @param x the x-coordinate of the touch point
+   * @param y the y-coordinate of the touch point
+   * @param targetRadius the target radius in pixels
+   * @return the Handle that was pressed; null if no Handle was pressed
+   */
+  private CropWindowMoveHandler.Type getRectanglePressedMoveType(
+      float x, float y, float targetRadius) {
+    CropWindowMoveHandler.Type moveType = null;
+
+    // Note: corner-handles take precedence, then side-handles, then center.
+    if (CropWindowHandler.isInCornerTargetZone(x, y, mEdges.left, mEdges.top, targetRadius)) {
+      moveType = CropWindowMoveHandler.Type.TOP_LEFT;
+    } else if (CropWindowHandler.isInCornerTargetZone(
+        x, y, mEdges.right, mEdges.top, targetRadius)) {
+      moveType = CropWindowMoveHandler.Type.TOP_RIGHT;
+    } else if (CropWindowHandler.isInCornerTargetZone(
+        x, y, mEdges.left, mEdges.bottom, targetRadius)) {
+      moveType = CropWindowMoveHandler.Type.BOTTOM_LEFT;
+    } else if (CropWindowHandler.isInCornerTargetZone(
+        x, y, mEdges.right, mEdges.bottom, targetRadius)) {
+      moveType = CropWindowMoveHandler.Type.BOTTOM_RIGHT;
+    } else if (CropWindowHandler.isInCenterTargetZone(
+            x, y, mEdges.left, mEdges.top, mEdges.right, mEdges.bottom)
+        && focusCenter()) {
+      moveType = CropWindowMoveHandler.Type.CENTER;
+    } else if (CropWindowHandler.isInHorizontalTargetZone(
+        x, y, mEdges.left, mEdges.right, mEdges.top, targetRadius)) {
+      moveType = CropWindowMoveHandler.Type.TOP;
+    } else if (CropWindowHandler.isInHorizontalTargetZone(
+        x, y, mEdges.left, mEdges.right, mEdges.bottom, targetRadius)) {
+      moveType = CropWindowMoveHandler.Type.BOTTOM;
+    } else if (CropWindowHandler.isInVerticalTargetZone(
+        x, y, mEdges.left, mEdges.top, mEdges.bottom, targetRadius)) {
+      moveType = CropWindowMoveHandler.Type.LEFT;
+    } else if (CropWindowHandler.isInVerticalTargetZone(
+        x, y, mEdges.right, mEdges.top, mEdges.bottom, targetRadius)) {
+      moveType = CropWindowMoveHandler.Type.RIGHT;
+    } else if (CropWindowHandler.isInCenterTargetZone(
+            x, y, mEdges.left, mEdges.top, mEdges.right, mEdges.bottom)
+        && !focusCenter()) {
+      moveType = CropWindowMoveHandler.Type.CENTER;
+    }
+
+    return moveType;
+  }
+
+  /**
+   * Determines which, if any, of the handles are pressed given the touch coordinates, the bounding
+   * box/oval, and the touch radius.
+   *
+   * @param x the x-coordinate of the touch point
+   * @param y the y-coordinate of the touch point
+   * @return the Handle that was pressed; null if no Handle was pressed
+   */
+  private CropWindowMoveHandler.Type getOvalPressedMoveType(float x, float y) {
+
+    /*
+       Use a 6x6 grid system divided into 9 "handles", with the center the biggest region. While
+       this is not perfect, it's a good quick-to-ship approach.
+
+       TL T T T T TR
+        L C C C C R
+        L C C C C R
+        L C C C C R
+        L C C C C R
+       BL B B B B BR
+    */
+
+    float cellLength = mEdges.width() / 6;
+    float leftCenter = mEdges.left + cellLength;
+    float rightCenter = mEdges.left + (5 * cellLength);
+
+    float cellHeight = mEdges.height() / 6;
+    float topCenter = mEdges.top + cellHeight;
+    float bottomCenter = mEdges.top + 5 * cellHeight;
+
+    CropWindowMoveHandler.Type moveType;
+    if (x < leftCenter) {
+      if (y < topCenter) {
+        moveType = CropWindowMoveHandler.Type.TOP_LEFT;
+      } else if (y < bottomCenter) {
+        moveType = CropWindowMoveHandler.Type.LEFT;
+      } else {
+        moveType = CropWindowMoveHandler.Type.BOTTOM_LEFT;
+      }
+    } else if (x < rightCenter) {
+      if (y < topCenter) {
+        moveType = CropWindowMoveHandler.Type.TOP;
+      } else if (y < bottomCenter) {
+        moveType = CropWindowMoveHandler.Type.CENTER;
+      } else {
+        moveType = CropWindowMoveHandler.Type.BOTTOM;
+      }
+    } else {
+      if (y < topCenter) {
+        moveType = CropWindowMoveHandler.Type.TOP_RIGHT;
+      } else if (y < bottomCenter) {
+        moveType = CropWindowMoveHandler.Type.RIGHT;
+      } else {
+        moveType = CropWindowMoveHandler.Type.BOTTOM_RIGHT;
+      }
+    }
+
+    return moveType;
+  }
+
+  /**
+   * Determines if the specified coordinate is in the target touch zone for a corner handle.
+   *
+   * @param x the x-coordinate of the touch point
+   * @param y the y-coordinate of the touch point
+   * @param handleX the x-coordinate of the corner handle
+   * @param handleY the y-coordinate of the corner handle
+   * @param targetRadius the target radius in pixels
+   * @return true if the touch point is in the target touch zone; false otherwise
+   */
+  private static boolean isInCornerTargetZone(
+      float x, float y, float handleX, float handleY, float targetRadius) {
+    return Math.abs(x - handleX) <= targetRadius && Math.abs(y - handleY) <= targetRadius;
+  }
+
+  /**
+   * Determines if the specified coordinate is in the target touch zone for a horizontal bar handle.
+   *
+   * @param x the x-coordinate of the touch point
+   * @param y the y-coordinate of the touch point
+   * @param handleXStart the left x-coordinate of the horizontal bar handle
+   * @param handleXEnd the right x-coordinate of the horizontal bar handle
+   * @param handleY the y-coordinate of the horizontal bar handle
+   * @param targetRadius the target radius in pixels
+   * @return true if the touch point is in the target touch zone; false otherwise
+   */
+  private static boolean isInHorizontalTargetZone(
+      float x, float y, float handleXStart, float handleXEnd, float handleY, float targetRadius) {
+    return x > handleXStart && x < handleXEnd && Math.abs(y - handleY) <= targetRadius;
+  }
+
+  /**
+   * Determines if the specified coordinate is in the target touch zone for a vertical bar handle.
+   *
+   * @param x the x-coordinate of the touch point
+   * @param y the y-coordinate of the touch point
+   * @param handleX the x-coordinate of the vertical bar handle
+   * @param handleYStart the top y-coordinate of the vertical bar handle
+   * @param handleYEnd the bottom y-coordinate of the vertical bar handle
+   * @param targetRadius the target radius in pixels
+   * @return true if the touch point is in the target touch zone; false otherwise
+   */
+  private static boolean isInVerticalTargetZone(
+      float x, float y, float handleX, float handleYStart, float handleYEnd, float targetRadius) {
+    return Math.abs(x - handleX) <= targetRadius && y > handleYStart && y < handleYEnd;
+  }
+
+  /**
+   * Determines if the specified coordinate falls anywhere inside the given bounds.
+   *
+   * @param x the x-coordinate of the touch point
+   * @param y the y-coordinate of the touch point
+   * @param left the x-coordinate of the left bound
+   * @param top the y-coordinate of the top bound
+   * @param right the x-coordinate of the right bound
+   * @param bottom the y-coordinate of the bottom bound
+   * @return true if the touch point is inside the bounding rectangle; false otherwise
+   */
+  private static boolean isInCenterTargetZone(
+      float x, float y, float left, float top, float right, float bottom) {
+    return x > left && x < right && y > top && y < bottom;
+  }
+
+  /**
+   * Determines if the cropper should focus on the center handle or the side handles. If it is a
+   * small image, focus on the center handle so the user can move it. If it is a large image, focus
+   * on the side handles so user can grab them. Corresponds to the appearance of the
+   * RuleOfThirdsGuidelines.
+   *
+   * @return true if it is small enough such that it should focus on the center; less than
+   *     show_guidelines limit
+   */
+  private boolean focusCenter() {
+    return !showGuidelines();
+  }
+  // endregion
+}

+ 766 - 0
app/src/main/java/com/miekir/ocr/view/cropper/CropWindowMoveHandler.java

@@ -0,0 +1,766 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+package com.miekir.ocr.view.cropper;
+
+import android.graphics.Matrix;
+import android.graphics.PointF;
+import android.graphics.RectF;
+
+/**
+ * Handler to update crop window edges by the move type - Horizontal, Vertical, Corner or Center.
+ * <br>
+ */
+final class CropWindowMoveHandler {
+
+  // region: Fields and Consts
+
+  /** Matrix used for rectangle rotation handling */
+  private static final Matrix MATRIX = new Matrix();
+
+  /** Minimum width in pixels that the crop window can get. */
+  private final float mMinCropWidth;
+
+  /** Minimum width in pixels that the crop window can get. */
+  private final float mMinCropHeight;
+
+  /** Maximum height in pixels that the crop window can get. */
+  private final float mMaxCropWidth;
+
+  /** Maximum height in pixels that the crop window can get. */
+  private final float mMaxCropHeight;
+
+  /** The type of crop window move that is handled. */
+  private final Type mType;
+
+  /**
+   * Holds the x and y offset between the exact touch location and the exact handle location that is
+   * activated. There may be an offset because we allow for some leeway (specified by mHandleRadius)
+   * in activating a handle. However, we want to maintain these offset values while the handle is
+   * being dragged so that the handle doesn't jump.
+   */
+  private final PointF mTouchOffset = new PointF();
+  // endregion
+
+  /**
+   * @param edgeMoveType the type of move this handler is executing
+   * @param horizontalEdge the primary edge associated with this handle; may be null
+   * @param verticalEdge the secondary edge associated with this handle; may be null
+   * @param cropWindowHandler main crop window handle to get and update the crop window edges
+   * @param touchX the location of the initial toch possition to measure move distance
+   * @param touchY the location of the initial toch possition to measure move distance
+   */
+  public CropWindowMoveHandler(
+      Type type, CropWindowHandler cropWindowHandler, float touchX, float touchY) {
+    mType = type;
+    mMinCropWidth = cropWindowHandler.getMinCropWidth();
+    mMinCropHeight = cropWindowHandler.getMinCropHeight();
+    mMaxCropWidth = cropWindowHandler.getMaxCropWidth();
+    mMaxCropHeight = cropWindowHandler.getMaxCropHeight();
+    calculateTouchOffset(cropWindowHandler.getRect(), touchX, touchY);
+  }
+
+  /**
+   * Updates the crop window by change in the toch location.<br>
+   * Move type handled by this instance, as initialized in creation, affects how the change in toch
+   * location changes the crop window position and size.<br>
+   * After the crop window position/size is changed by toch move it may result in values that
+   * vialate contraints: outside the bounds of the shown bitmap, smaller/larger than min/max size or
+   * missmatch in aspect ratio. So a series of fixes is executed on "secondary" edges to adjust it
+   * by the "primary" edge movement.<br>
+   * Primary is the edge directly affected by move type, secondary is the other edge.<br>
+   * The crop window is changed by directly setting the Edge coordinates.
+   *
+   * @param x the new x-coordinate of this handle
+   * @param y the new y-coordinate of this handle
+   * @param bounds the bounding rectangle of the image
+   * @param viewWidth The bounding image view width used to know the crop overlay is at view edges.
+   * @param viewHeight The bounding image view height used to know the crop overlay is at view
+   *     edges.
+   * @param parentView the parent View containing the image
+   * @param snapMargin the maximum distance (in pixels) at which the crop window should snap to the
+   *     image
+   * @param fixedAspectRatio is the aspect ration fixed and 'targetAspectRatio' should be used
+   * @param aspectRatio the aspect ratio to maintain
+   */
+  public void move(
+      RectF rect,
+      float x,
+      float y,
+      RectF bounds,
+      int viewWidth,
+      int viewHeight,
+      float snapMargin,
+      boolean fixedAspectRatio,
+      float aspectRatio) {
+
+    // Adjust the coordinates for the finger position's offset (i.e. the
+    // distance from the initial touch to the precise handle location).
+    // We want to maintain the initial touch's distance to the pressed
+    // handle so that the crop window size does not "jump".
+    float adjX = x + mTouchOffset.x;
+    float adjY = y + mTouchOffset.y;
+
+    if (mType == Type.CENTER) {
+      moveCenter(rect, adjX, adjY, bounds, viewWidth, viewHeight, snapMargin);
+    } else {
+      if (fixedAspectRatio) {
+        moveSizeWithFixedAspectRatio(
+            rect, adjX, adjY, bounds, viewWidth, viewHeight, snapMargin, aspectRatio);
+      } else {
+        moveSizeWithFreeAspectRatio(rect, adjX, adjY, bounds, viewWidth, viewHeight, snapMargin);
+      }
+    }
+  }
+
+  // region: Private methods
+
+  /**
+   * Calculates the offset of the touch point from the precise location of the specified handle.<br>
+   * Save these values in a member variable since we want to maintain this offset as we drag the
+   * handle.
+   */
+  private void calculateTouchOffset(RectF rect, float touchX, float touchY) {
+
+    float touchOffsetX = 0;
+    float touchOffsetY = 0;
+
+    // Calculate the offset from the appropriate handle.
+    switch (mType) {
+      case TOP_LEFT:
+        touchOffsetX = rect.left - touchX;
+        touchOffsetY = rect.top - touchY;
+        break;
+      case TOP_RIGHT:
+        touchOffsetX = rect.right - touchX;
+        touchOffsetY = rect.top - touchY;
+        break;
+      case BOTTOM_LEFT:
+        touchOffsetX = rect.left - touchX;
+        touchOffsetY = rect.bottom - touchY;
+        break;
+      case BOTTOM_RIGHT:
+        touchOffsetX = rect.right - touchX;
+        touchOffsetY = rect.bottom - touchY;
+        break;
+      case LEFT:
+        touchOffsetX = rect.left - touchX;
+        touchOffsetY = 0;
+        break;
+      case TOP:
+        touchOffsetX = 0;
+        touchOffsetY = rect.top - touchY;
+        break;
+      case RIGHT:
+        touchOffsetX = rect.right - touchX;
+        touchOffsetY = 0;
+        break;
+      case BOTTOM:
+        touchOffsetX = 0;
+        touchOffsetY = rect.bottom - touchY;
+        break;
+      case CENTER:
+        touchOffsetX = rect.centerX() - touchX;
+        touchOffsetY = rect.centerY() - touchY;
+        break;
+      default:
+        break;
+    }
+
+    mTouchOffset.x = touchOffsetX;
+    mTouchOffset.y = touchOffsetY;
+  }
+
+  /** Center move only changes the position of the crop window without changing the size. */
+  private void moveCenter(
+      RectF rect, float x, float y, RectF bounds, int viewWidth, int viewHeight, float snapRadius) {
+    float dx = x - rect.centerX();
+    float dy = y - rect.centerY();
+    if (rect.left + dx < 0
+        || rect.right + dx > viewWidth
+        || rect.left + dx < bounds.left
+        || rect.right + dx > bounds.right) {
+      dx /= 1.05f;
+      mTouchOffset.x -= dx / 2;
+    }
+    if (rect.top + dy < 0
+        || rect.bottom + dy > viewHeight
+        || rect.top + dy < bounds.top
+        || rect.bottom + dy > bounds.bottom) {
+      dy /= 1.05f;
+      mTouchOffset.y -= dy / 2;
+    }
+    rect.offset(dx, dy);
+    snapEdgesToBounds(rect, bounds, snapRadius);
+  }
+
+  /**
+   * Change the size of the crop window on the required edge (or edges for corner size move) without
+   * affecting "secondary" edges.<br>
+   * Only the primary edge(s) are fixed to stay within limits.
+   */
+  private void moveSizeWithFreeAspectRatio(
+      RectF rect, float x, float y, RectF bounds, int viewWidth, int viewHeight, float snapMargin) {
+    switch (mType) {
+      case TOP_LEFT:
+        adjustTop(rect, y, bounds, snapMargin, 0, false, false);
+        adjustLeft(rect, x, bounds, snapMargin, 0, false, false);
+        break;
+      case TOP_RIGHT:
+        adjustTop(rect, y, bounds, snapMargin, 0, false, false);
+        adjustRight(rect, x, bounds, viewWidth, snapMargin, 0, false, false);
+        break;
+      case BOTTOM_LEFT:
+        adjustBottom(rect, y, bounds, viewHeight, snapMargin, 0, false, false);
+        adjustLeft(rect, x, bounds, snapMargin, 0, false, false);
+        break;
+      case BOTTOM_RIGHT:
+        adjustBottom(rect, y, bounds, viewHeight, snapMargin, 0, false, false);
+        adjustRight(rect, x, bounds, viewWidth, snapMargin, 0, false, false);
+        break;
+      case LEFT:
+        adjustLeft(rect, x, bounds, snapMargin, 0, false, false);
+        break;
+      case TOP:
+        adjustTop(rect, y, bounds, snapMargin, 0, false, false);
+        break;
+      case RIGHT:
+        adjustRight(rect, x, bounds, viewWidth, snapMargin, 0, false, false);
+        break;
+      case BOTTOM:
+        adjustBottom(rect, y, bounds, viewHeight, snapMargin, 0, false, false);
+        break;
+      default:
+        break;
+    }
+  }
+
+  /**
+   * Change the size of the crop window on the required "primary" edge WITH affect to relevant
+   * "secondary" edge via aspect ratio.<br>
+   * Example: change in the left edge (primary) will affect top and bottom edges (secondary) to
+   * preserve the given aspect ratio.
+   */
+  private void moveSizeWithFixedAspectRatio(
+      RectF rect,
+      float x,
+      float y,
+      RectF bounds,
+      int viewWidth,
+      int viewHeight,
+      float snapMargin,
+      float aspectRatio) {
+    switch (mType) {
+      case TOP_LEFT:
+        if (calculateAspectRatio(x, y, rect.right, rect.bottom) < aspectRatio) {
+          adjustTop(rect, y, bounds, snapMargin, aspectRatio, true, false);
+          adjustLeftByAspectRatio(rect, aspectRatio);
+        } else {
+          adjustLeft(rect, x, bounds, snapMargin, aspectRatio, true, false);
+          adjustTopByAspectRatio(rect, aspectRatio);
+        }
+        break;
+      case TOP_RIGHT:
+        if (calculateAspectRatio(rect.left, y, x, rect.bottom) < aspectRatio) {
+          adjustTop(rect, y, bounds, snapMargin, aspectRatio, false, true);
+          adjustRightByAspectRatio(rect, aspectRatio);
+        } else {
+          adjustRight(rect, x, bounds, viewWidth, snapMargin, aspectRatio, true, false);
+          adjustTopByAspectRatio(rect, aspectRatio);
+        }
+        break;
+      case BOTTOM_LEFT:
+        if (calculateAspectRatio(x, rect.top, rect.right, y) < aspectRatio) {
+          adjustBottom(rect, y, bounds, viewHeight, snapMargin, aspectRatio, true, false);
+          adjustLeftByAspectRatio(rect, aspectRatio);
+        } else {
+          adjustLeft(rect, x, bounds, snapMargin, aspectRatio, false, true);
+          adjustBottomByAspectRatio(rect, aspectRatio);
+        }
+        break;
+      case BOTTOM_RIGHT:
+        if (calculateAspectRatio(rect.left, rect.top, x, y) < aspectRatio) {
+          adjustBottom(rect, y, bounds, viewHeight, snapMargin, aspectRatio, false, true);
+          adjustRightByAspectRatio(rect, aspectRatio);
+        } else {
+          adjustRight(rect, x, bounds, viewWidth, snapMargin, aspectRatio, false, true);
+          adjustBottomByAspectRatio(rect, aspectRatio);
+        }
+        break;
+      case LEFT:
+        adjustLeft(rect, x, bounds, snapMargin, aspectRatio, true, true);
+        adjustTopBottomByAspectRatio(rect, bounds, aspectRatio);
+        break;
+      case TOP:
+        adjustTop(rect, y, bounds, snapMargin, aspectRatio, true, true);
+        adjustLeftRightByAspectRatio(rect, bounds, aspectRatio);
+        break;
+      case RIGHT:
+        adjustRight(rect, x, bounds, viewWidth, snapMargin, aspectRatio, true, true);
+        adjustTopBottomByAspectRatio(rect, bounds, aspectRatio);
+        break;
+      case BOTTOM:
+        adjustBottom(rect, y, bounds, viewHeight, snapMargin, aspectRatio, true, true);
+        adjustLeftRightByAspectRatio(rect, bounds, aspectRatio);
+        break;
+      default:
+        break;
+    }
+  }
+
+  /** Check if edges have gone out of bounds (including snap margin), and fix if needed. */
+  private void snapEdgesToBounds(RectF edges, RectF bounds, float margin) {
+    if (edges.left < bounds.left + margin) {
+      edges.offset(bounds.left - edges.left, 0);
+    }
+    if (edges.top < bounds.top + margin) {
+      edges.offset(0, bounds.top - edges.top);
+    }
+    if (edges.right > bounds.right - margin) {
+      edges.offset(bounds.right - edges.right, 0);
+    }
+    if (edges.bottom > bounds.bottom - margin) {
+      edges.offset(0, bounds.bottom - edges.bottom);
+    }
+  }
+
+  /**
+   * Get the resulting x-position of the left edge of the crop window given the handle's position
+   * and the image's bounding box and snap radius.
+   *
+   * @param left the position that the left edge is dragged to
+   * @param bounds the bounding box of the image that is being cropped
+   * @param snapMargin the snap distance to the image edge (in pixels)
+   */
+  private void adjustLeft(
+      RectF rect,
+      float left,
+      RectF bounds,
+      float snapMargin,
+      float aspectRatio,
+      boolean topMoves,
+      boolean bottomMoves) {
+
+    float newLeft = left;
+
+    if (newLeft < 0) {
+      newLeft /= 1.05f;
+      mTouchOffset.x -= newLeft / 1.1f;
+    }
+
+    if (newLeft < bounds.left) {
+      mTouchOffset.x -= (newLeft - bounds.left) / 2f;
+    }
+
+    if (newLeft - bounds.left < snapMargin) {
+      newLeft = bounds.left;
+    }
+
+    // Checks if the window is too small horizontally
+    if (rect.right - newLeft < mMinCropWidth) {
+      newLeft = rect.right - mMinCropWidth;
+    }
+
+    // Checks if the window is too large horizontally
+    if (rect.right - newLeft > mMaxCropWidth) {
+      newLeft = rect.right - mMaxCropWidth;
+    }
+
+    if (newLeft - bounds.left < snapMargin) {
+      newLeft = bounds.left;
+    }
+
+    // check vertical bounds if aspect ratio is in play
+    if (aspectRatio > 0) {
+      float newHeight = (rect.right - newLeft) / aspectRatio;
+
+      // Checks if the window is too small vertically
+      if (newHeight < mMinCropHeight) {
+        newLeft = Math.max(bounds.left, rect.right - mMinCropHeight * aspectRatio);
+        newHeight = (rect.right - newLeft) / aspectRatio;
+      }
+
+      // Checks if the window is too large vertically
+      if (newHeight > mMaxCropHeight) {
+        newLeft = Math.max(bounds.left, rect.right - mMaxCropHeight * aspectRatio);
+        newHeight = (rect.right - newLeft) / aspectRatio;
+      }
+
+      // if top AND bottom edge moves by aspect ratio check that it is within full height bounds
+      if (topMoves && bottomMoves) {
+        newLeft =
+            Math.max(newLeft, Math.max(bounds.left, rect.right - bounds.height() * aspectRatio));
+      } else {
+        // if top edge moves by aspect ratio check that it is within bounds
+        if (topMoves && rect.bottom - newHeight < bounds.top) {
+          newLeft = Math.max(bounds.left, rect.right - (rect.bottom - bounds.top) * aspectRatio);
+          newHeight = (rect.right - newLeft) / aspectRatio;
+        }
+
+        // if bottom edge moves by aspect ratio check that it is within bounds
+        if (bottomMoves && rect.top + newHeight > bounds.bottom) {
+          newLeft =
+              Math.max(
+                  newLeft,
+                  Math.max(bounds.left, rect.right - (bounds.bottom - rect.top) * aspectRatio));
+        }
+      }
+    }
+
+    rect.left = newLeft;
+  }
+
+  /**
+   * Get the resulting x-position of the right edge of the crop window given the handle's position
+   * and the image's bounding box and snap radius.
+   *
+   * @param right the position that the right edge is dragged to
+   * @param bounds the bounding box of the image that is being cropped
+   * @param viewWidth
+   * @param snapMargin the snap distance to the image edge (in pixels)
+   */
+  private void adjustRight(
+      RectF rect,
+      float right,
+      RectF bounds,
+      int viewWidth,
+      float snapMargin,
+      float aspectRatio,
+      boolean topMoves,
+      boolean bottomMoves) {
+
+    float newRight = right;
+
+    if (newRight > viewWidth) {
+      newRight = viewWidth + (newRight - viewWidth) / 1.05f;
+      mTouchOffset.x -= (newRight - viewWidth) / 1.1f;
+    }
+
+    if (newRight > bounds.right) {
+      mTouchOffset.x -= (newRight - bounds.right) / 2f;
+    }
+
+    // If close to the edge
+    if (bounds.right - newRight < snapMargin) {
+      newRight = bounds.right;
+    }
+
+    // Checks if the window is too small horizontally
+    if (newRight - rect.left < mMinCropWidth) {
+      newRight = rect.left + mMinCropWidth;
+    }
+
+    // Checks if the window is too large horizontally
+    if (newRight - rect.left > mMaxCropWidth) {
+      newRight = rect.left + mMaxCropWidth;
+    }
+
+    // If close to the edge
+    if (bounds.right - newRight < snapMargin) {
+      newRight = bounds.right;
+    }
+
+    // check vertical bounds if aspect ratio is in play
+    if (aspectRatio > 0) {
+      float newHeight = (newRight - rect.left) / aspectRatio;
+
+      // Checks if the window is too small vertically
+      if (newHeight < mMinCropHeight) {
+        newRight = Math.min(bounds.right, rect.left + mMinCropHeight * aspectRatio);
+        newHeight = (newRight - rect.left) / aspectRatio;
+      }
+
+      // Checks if the window is too large vertically
+      if (newHeight > mMaxCropHeight) {
+        newRight = Math.min(bounds.right, rect.left + mMaxCropHeight * aspectRatio);
+        newHeight = (newRight - rect.left) / aspectRatio;
+      }
+
+      // if top AND bottom edge moves by aspect ratio check that it is within full height bounds
+      if (topMoves && bottomMoves) {
+        newRight =
+            Math.min(newRight, Math.min(bounds.right, rect.left + bounds.height() * aspectRatio));
+      } else {
+        // if top edge moves by aspect ratio check that it is within bounds
+        if (topMoves && rect.bottom - newHeight < bounds.top) {
+          newRight = Math.min(bounds.right, rect.left + (rect.bottom - bounds.top) * aspectRatio);
+          newHeight = (newRight - rect.left) / aspectRatio;
+        }
+
+        // if bottom edge moves by aspect ratio check that it is within bounds
+        if (bottomMoves && rect.top + newHeight > bounds.bottom) {
+          newRight =
+              Math.min(
+                  newRight,
+                  Math.min(bounds.right, rect.left + (bounds.bottom - rect.top) * aspectRatio));
+        }
+      }
+    }
+
+    rect.right = newRight;
+  }
+
+  /**
+   * Get the resulting y-position of the top edge of the crop window given the handle's position and
+   * the image's bounding box and snap radius.
+   *
+   * @param top the x-position that the top edge is dragged to
+   * @param bounds the bounding box of the image that is being cropped
+   * @param snapMargin the snap distance to the image edge (in pixels)
+   */
+  private void adjustTop(
+      RectF rect,
+      float top,
+      RectF bounds,
+      float snapMargin,
+      float aspectRatio,
+      boolean leftMoves,
+      boolean rightMoves) {
+
+    float newTop = top;
+
+    if (newTop < 0) {
+      newTop /= 1.05f;
+      mTouchOffset.y -= newTop / 1.1f;
+    }
+
+    if (newTop < bounds.top) {
+      mTouchOffset.y -= (newTop - bounds.top) / 2f;
+    }
+
+    if (newTop - bounds.top < snapMargin) {
+      newTop = bounds.top;
+    }
+
+    // Checks if the window is too small vertically
+    if (rect.bottom - newTop < mMinCropHeight) {
+      newTop = rect.bottom - mMinCropHeight;
+    }
+
+    // Checks if the window is too large vertically
+    if (rect.bottom - newTop > mMaxCropHeight) {
+      newTop = rect.bottom - mMaxCropHeight;
+    }
+
+    if (newTop - bounds.top < snapMargin) {
+      newTop = bounds.top;
+    }
+
+    // check horizontal bounds if aspect ratio is in play
+    if (aspectRatio > 0) {
+      float newWidth = (rect.bottom - newTop) * aspectRatio;
+
+      // Checks if the crop window is too small horizontally due to aspect ratio adjustment
+      if (newWidth < mMinCropWidth) {
+        newTop = Math.max(bounds.top, rect.bottom - (mMinCropWidth / aspectRatio));
+        newWidth = (rect.bottom - newTop) * aspectRatio;
+      }
+
+      // Checks if the crop window is too large horizontally due to aspect ratio adjustment
+      if (newWidth > mMaxCropWidth) {
+        newTop = Math.max(bounds.top, rect.bottom - (mMaxCropWidth / aspectRatio));
+        newWidth = (rect.bottom - newTop) * aspectRatio;
+      }
+
+      // if left AND right edge moves by aspect ratio check that it is within full width bounds
+      if (leftMoves && rightMoves) {
+        newTop = Math.max(newTop, Math.max(bounds.top, rect.bottom - bounds.width() / aspectRatio));
+      } else {
+        // if left edge moves by aspect ratio check that it is within bounds
+        if (leftMoves && rect.right - newWidth < bounds.left) {
+          newTop = Math.max(bounds.top, rect.bottom - (rect.right - bounds.left) / aspectRatio);
+          newWidth = (rect.bottom - newTop) * aspectRatio;
+        }
+
+        // if right edge moves by aspect ratio check that it is within bounds
+        if (rightMoves && rect.left + newWidth > bounds.right) {
+          newTop =
+              Math.max(
+                  newTop,
+                  Math.max(bounds.top, rect.bottom - (bounds.right - rect.left) / aspectRatio));
+        }
+      }
+    }
+
+    rect.top = newTop;
+  }
+
+  /**
+   * Get the resulting y-position of the bottom edge of the crop window given the handle's position
+   * and the image's bounding box and snap radius.
+   *
+   * @param bottom the position that the bottom edge is dragged to
+   * @param bounds the bounding box of the image that is being cropped
+   * @param viewHeight
+   * @param snapMargin the snap distance to the image edge (in pixels)
+   */
+  private void adjustBottom(
+      RectF rect,
+      float bottom,
+      RectF bounds,
+      int viewHeight,
+      float snapMargin,
+      float aspectRatio,
+      boolean leftMoves,
+      boolean rightMoves) {
+
+    float newBottom = bottom;
+
+    if (newBottom > viewHeight) {
+      newBottom = viewHeight + (newBottom - viewHeight) / 1.05f;
+      mTouchOffset.y -= (newBottom - viewHeight) / 1.1f;
+    }
+
+    if (newBottom > bounds.bottom) {
+      mTouchOffset.y -= (newBottom - bounds.bottom) / 2f;
+    }
+
+    if (bounds.bottom - newBottom < snapMargin) {
+      newBottom = bounds.bottom;
+    }
+
+    // Checks if the window is too small vertically
+    if (newBottom - rect.top < mMinCropHeight) {
+      newBottom = rect.top + mMinCropHeight;
+    }
+
+    // Checks if the window is too small vertically
+    if (newBottom - rect.top > mMaxCropHeight) {
+      newBottom = rect.top + mMaxCropHeight;
+    }
+
+    if (bounds.bottom - newBottom < snapMargin) {
+      newBottom = bounds.bottom;
+    }
+
+    // check horizontal bounds if aspect ratio is in play
+    if (aspectRatio > 0) {
+      float newWidth = (newBottom - rect.top) * aspectRatio;
+
+      // Checks if the window is too small horizontally
+      if (newWidth < mMinCropWidth) {
+        newBottom = Math.min(bounds.bottom, rect.top + mMinCropWidth / aspectRatio);
+        newWidth = (newBottom - rect.top) * aspectRatio;
+      }
+
+      // Checks if the window is too large horizontally
+      if (newWidth > mMaxCropWidth) {
+        newBottom = Math.min(bounds.bottom, rect.top + mMaxCropWidth / aspectRatio);
+        newWidth = (newBottom - rect.top) * aspectRatio;
+      }
+
+      // if left AND right edge moves by aspect ratio check that it is within full width bounds
+      if (leftMoves && rightMoves) {
+        newBottom =
+            Math.min(newBottom, Math.min(bounds.bottom, rect.top + bounds.width() / aspectRatio));
+      } else {
+        // if left edge moves by aspect ratio check that it is within bounds
+        if (leftMoves && rect.right - newWidth < bounds.left) {
+          newBottom = Math.min(bounds.bottom, rect.top + (rect.right - bounds.left) / aspectRatio);
+          newWidth = (newBottom - rect.top) * aspectRatio;
+        }
+
+        // if right edge moves by aspect ratio check that it is within bounds
+        if (rightMoves && rect.left + newWidth > bounds.right) {
+          newBottom =
+              Math.min(
+                  newBottom,
+                  Math.min(bounds.bottom, rect.top + (bounds.right - rect.left) / aspectRatio));
+        }
+      }
+    }
+
+    rect.bottom = newBottom;
+  }
+
+  /**
+   * Adjust left edge by current crop window height and the given aspect ratio, the right edge
+   * remains in possition while the left adjusts to keep aspect ratio to the height.
+   */
+  private void adjustLeftByAspectRatio(RectF rect, float aspectRatio) {
+    rect.left = rect.right - rect.height() * aspectRatio;
+  }
+
+  /**
+   * Adjust top edge by current crop window width and the given aspect ratio, the bottom edge
+   * remains in possition while the top adjusts to keep aspect ratio to the width.
+   */
+  private void adjustTopByAspectRatio(RectF rect, float aspectRatio) {
+    rect.top = rect.bottom - rect.width() / aspectRatio;
+  }
+
+  /**
+   * Adjust right edge by current crop window height and the given aspect ratio, the left edge
+   * remains in possition while the left adjusts to keep aspect ratio to the height.
+   */
+  private void adjustRightByAspectRatio(RectF rect, float aspectRatio) {
+    rect.right = rect.left + rect.height() * aspectRatio;
+  }
+
+  /**
+   * Adjust bottom edge by current crop window width and the given aspect ratio, the top edge
+   * remains in possition while the top adjusts to keep aspect ratio to the width.
+   */
+  private void adjustBottomByAspectRatio(RectF rect, float aspectRatio) {
+    rect.bottom = rect.top + rect.width() / aspectRatio;
+  }
+
+  /**
+   * Adjust left and right edges by current crop window height and the given aspect ratio, both
+   * right and left edges adjusts equally relative to center to keep aspect ratio to the height.
+   */
+  private void adjustLeftRightByAspectRatio(RectF rect, RectF bounds, float aspectRatio) {
+    rect.inset((rect.width() - rect.height() * aspectRatio) / 2, 0);
+    if (rect.left < bounds.left) {
+      rect.offset(bounds.left - rect.left, 0);
+    }
+    if (rect.right > bounds.right) {
+      rect.offset(bounds.right - rect.right, 0);
+    }
+  }
+
+  /**
+   * Adjust top and bottom edges by current crop window width and the given aspect ratio, both top
+   * and bottom edges adjusts equally relative to center to keep aspect ratio to the width.
+   */
+  private void adjustTopBottomByAspectRatio(RectF rect, RectF bounds, float aspectRatio) {
+    rect.inset(0, (rect.height() - rect.width() / aspectRatio) / 2);
+    if (rect.top < bounds.top) {
+      rect.offset(0, bounds.top - rect.top);
+    }
+    if (rect.bottom > bounds.bottom) {
+      rect.offset(0, bounds.bottom - rect.bottom);
+    }
+  }
+
+  /** Calculates the aspect ratio given a rectangle. */
+  private static float calculateAspectRatio(float left, float top, float right, float bottom) {
+    return (right - left) / (bottom - top);
+  }
+  // endregion
+
+  // region: Inner class: Type
+
+  /** The type of crop window move that is handled. */
+  public enum Type {
+    TOP_LEFT,
+    TOP_RIGHT,
+    BOTTOM_LEFT,
+    BOTTOM_RIGHT,
+    LEFT,
+    TOP,
+    RIGHT,
+    BOTTOM,
+    CENTER
+  }
+  // endregion
+}

BIN
app/src/main/res/drawable-xxhdpi/crop_image_menu_flip.png


BIN
app/src/main/res/drawable-xxhdpi/crop_image_menu_rotate_left.png


BIN
app/src/main/res/drawable-xxhdpi/crop_image_menu_rotate_right.png


+ 11 - 25
app/src/main/res/layout/activity_camera.xml

@@ -42,29 +42,12 @@
             android:layout_width="wrap_content"
             android:layout_height="match_parent" />
 
-        <com.isseiaoki.simplecropview.CropImageView
-            xmlns:custom="http://schemas.android.com/apk/res-auto"
-            android:id="@+id/cropImageView"
-            android:layout_weight="1"
-            android:paddingTop="16dp"
-            android:paddingBottom="16dp"
-            android:paddingLeft="16dp"
-            android:paddingRight="16dp"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            custom:scv_crop_mode="fit_image"
-            custom:scv_background_color="@color/black_transparent"
-            custom:scv_handle_color="@color/colorAccent"
-            custom:scv_guide_color="@color/colorAccent"
-            custom:scv_overlay_color="@color/black_transparent"
-            custom:scv_frame_color="@color/colorAccent"
-            custom:scv_handle_size="14dp"
-            custom:scv_touch_padding="8dp"
-            custom:scv_handle_show_mode="show_always"
-            custom:scv_guide_show_mode="show_always"
-            custom:scv_min_frame_size="50dp"
-            custom:scv_frame_stroke_weight="1dp"
-            custom:scv_guide_stroke_weight="1dp"/>
+
+
+
+
+
+
 
         <LinearLayout
             android:layout_width="match_parent"
@@ -225,16 +208,19 @@
                         android:layout_weight="1">
 
                         <ImageView
+                            android:id="@+id/iv_album"
                             android:layout_width="wrap_content"
                             android:layout_height="wrap_content"
                             android:layout_centerInParent="true"
-                            android:src="@mipmap/button_album" />
+                            android:src="@mipmap/button_album"
+                            android:background="?selectableItemBackgroundBorderless"/>
                     </RelativeLayout>
 
                     <FrameLayout
                         android:id="@+id/fl_take"
                         android:layout_width="wrap_content"
-                        android:layout_height="wrap_content">
+                        android:layout_height="wrap_content"
+                        android:background="?selectableItemBackgroundBorderless">
 
                         <ImageView
                             android:layout_width="wrap_content"

+ 4 - 1
app/src/main/res/layout/activity_main.xml

@@ -4,6 +4,9 @@
     android:id="@+id/activity_main"
     android:layout_width="match_parent"
     android:layout_height="match_parent">
-
+    <com.miekir.ocr.view.cropper.CropImageView
+        android:id="@+id/cropImageView"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"/>
 
 </RelativeLayout>

+ 6 - 0
app/src/main/res/layout/crop_image_activity.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<com.miekir.ocr.view.cropper.CropImageView
+    android:id="@+id/cropImageView"
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"/>

+ 25 - 0
app/src/main/res/layout/crop_image_view.xml

@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<merge xmlns:android="http://schemas.android.com/apk/res/android"
+       xmlns:tools="http://schemas.android.com/tools">
+
+    <ImageView
+        android:id="@+id/ImageView_image"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:adjustViewBounds="true"
+        android:scaleType="centerInside"
+        tools:ignore="contentDescription"/>
+
+    <com.miekir.ocr.view.cropper.CropOverlayView
+        android:id="@+id/CropOverlayView"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:visibility="invisible"/>
+
+    <ProgressBar
+        android:id="@+id/CropProgressBar"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_gravity="center"/>
+
+</merge>

+ 35 - 0
app/src/main/res/menu/crop_image_menu.xml

@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+      xmlns:app="http://schemas.android.com/apk/res-auto">
+
+    <item
+        android:id="@+id/crop_image_menu_rotate_left"
+        android:icon="@drawable/crop_image_menu_rotate_left"
+        android:title="@string/crop_image_menu_rotate_left"
+        android:visible="false"
+        app:showAsAction="ifRoom"/>
+    <item
+        android:id="@+id/crop_image_menu_rotate_right"
+        android:icon="@drawable/crop_image_menu_rotate_right"
+        android:title="@string/crop_image_menu_rotate_right"
+        app:showAsAction="ifRoom"/>
+    <item
+        android:id="@+id/crop_image_menu_flip"
+        android:icon="@drawable/crop_image_menu_flip"
+        android:title="@string/crop_image_menu_flip"
+        app:showAsAction="ifRoom">
+        <menu>
+            <item
+                android:id="@+id/crop_image_menu_flip_horizontally"
+                android:title="@string/crop_image_menu_flip_horizontally"/>
+            <item
+                android:id="@+id/crop_image_menu_flip_vertically"
+                android:title="@string/crop_image_menu_flip_vertically"/>
+        </menu>
+    </item>
+    <item
+        android:id="@+id/crop_image_menu_crop"
+        android:title="@string/crop_image_menu_crop"
+        app:showAsAction="always"/>
+
+</menu>

+ 50 - 0
app/src/main/res/values/attrs.xml

@@ -0,0 +1,50 @@
+<resources>
+
+    <declare-styleable name="CropImageView">
+        <attr name="cropGuidelines">
+            <enum name="off" value="0"/>
+            <enum name="onTouch" value="1"/>
+            <enum name="on" value="2"/>
+        </attr>
+        <attr name="cropScaleType">
+            <enum name="fitCenter" value="0"/>
+            <enum name="center" value="1"/>
+            <enum name="centerCrop" value="2"/>
+            <enum name="centerInside" value="3"/>
+        </attr>
+        <attr name="cropShape">
+            <enum name="rectangle" value="0"/>
+            <enum name="oval" value="1"/>
+        </attr>
+        <attr name="cropAutoZoomEnabled" format="boolean"/>
+        <attr name="cropMaxZoom" format="integer"/>
+        <attr name="cropMultiTouchEnabled" format="boolean"/>
+        <attr name="cropFixAspectRatio" format="boolean"/>
+        <attr name="cropAspectRatioX" format="integer"/>
+        <attr name="cropAspectRatioY" format="integer"/>
+        <attr name="cropInitialCropWindowPaddingRatio" format="float"/>
+        <attr name="cropBorderLineThickness" format="dimension"/>
+        <attr name="cropBorderLineColor" format="color"/>
+        <attr name="cropBorderCornerThickness" format="dimension"/>
+        <attr name="cropBorderCornerOffset" format="dimension"/>
+        <attr name="cropBorderCornerLength" format="dimension"/>
+        <attr name="cropBorderCornerColor" format="color"/>
+        <attr name="cropGuidelinesThickness" format="dimension"/>
+        <attr name="cropGuidelinesColor" format="color"/>
+        <attr name="cropBackgroundColor" format="color"/>
+        <attr name="cropSnapRadius" format="dimension"/>
+        <attr name="cropTouchRadius" format="dimension"/>
+        <attr name="cropSaveBitmapToInstanceState" format="boolean"/>
+        <attr name="cropShowCropOverlay" format="boolean"/>
+        <attr name="cropShowProgressBar" format="boolean"/>
+        <attr name="cropMinCropWindowWidth" format="dimension"/>
+        <attr name="cropMinCropWindowHeight" format="dimension"/>
+        <attr name="cropMinCropResultWidthPX" format="float"/>
+        <attr name="cropMinCropResultHeightPX" format="float"/>
+        <attr name="cropMaxCropResultWidthPX" format="float"/>
+        <attr name="cropMaxCropResultHeightPX" format="float"/>
+        <attr name="cropFlipHorizontally" format="boolean"/>
+        <attr name="cropFlipVertically" format="boolean"/>
+    </declare-styleable>
+
+</resources>

+ 13 - 0
app/src/main/res/values/strings.xml

@@ -1,3 +1,16 @@
 <resources>
     <string name="app_name">NRI AIOCR</string>
+
+
+    <!--裁剪类库开始-->
+    <string name="crop_image_activity_title">Crop</string>
+    <string name="crop_image_menu_rotate_left">Rotate counter clockwise</string>
+    <string name="crop_image_menu_rotate_right">Rotate</string>
+    <string name="crop_image_menu_crop">Crop</string>
+    <string name="crop_image_menu_flip">Flip</string>
+    <string name="crop_image_menu_flip_horizontally">Flip horizontally</string>
+    <string name="crop_image_menu_flip_vertically">Flip vertically</string>
+    <string name="pick_image_intent_chooser_title">Select source</string>
+    <string name="crop_image_activity_no_permissions">Cancelling, required permissions are not granted</string>
+    <!--裁剪类库结束-->
 </resources>