diff --git a/CHANGELOG.md b/CHANGELOG.md index 38f18cd4..b2c72ab6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - CropOverlayView to Kotlin [#38](https://github.com/CanHub/Android-Image-Cropper/issues/38) - CropImageView to Kotlin [#39](https://github.com/CanHub/Android-Image-Cropper/issues/39) - CropImage to Kotlin [#41](https://github.com/CanHub/Android-Image-Cropper/issues/41) +- BitmapUtils to Kotlin [#35](https://github.com/CanHub/Android-Image-Cropper/issues/35) ## [2.2.2] - 19/03/21 ### Changed diff --git a/cropper/src/main/java/com/canhub/cropper/BitmapCroppingWorkerJob.kt b/cropper/src/main/java/com/canhub/cropper/BitmapCroppingWorkerJob.kt index c168dfec..3d87c062 100644 --- a/cropper/src/main/java/com/canhub/cropper/BitmapCroppingWorkerJob.kt +++ b/cropper/src/main/java/com/canhub/cropper/BitmapCroppingWorkerJob.kt @@ -174,7 +174,7 @@ class BitmapCroppingWorkerJob internal constructor( saveCompressFormat ?: Bitmap.CompressFormat.JPEG, saveCompressQuality ) - resizedBitmap?.recycle() + resizedBitmap.recycle() onPostExecute( Result( saveUri, diff --git a/cropper/src/main/java/com/canhub/cropper/BitmapUtils.java b/cropper/src/main/java/com/canhub/cropper/BitmapUtils.java deleted file mode 100644 index 3d311404..00000000 --- a/cropper/src/main/java/com/canhub/cropper/BitmapUtils.java +++ /dev/null @@ -1,932 +0,0 @@ -package com.canhub.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.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.FileProvider; -import androidx.exifinterface.media.ExifInterface; - -import com.canhub.cropper.common.CommonValues; -import com.canhub.cropper.common.CommonVersionCheck; - -/** - * 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> mStateBitmap; - - /** - * Rotate the given image by reading the Exif value of the image (uri).
- * If no rotation is required the image will not be rotated.
- * 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.
- * If no rotation is required the image will not be rotated.
- * 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.
- * 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.
- * 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.
- * 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.0f, bitmap.getHeight() / 2.0f); - 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.
- * 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.
- * 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) { - // We have this because of a HUAWEI path bug when we use getUriForFile - if (CommonVersionCheck.INSTANCE.isAtLeastQ29()) { - uri = FileProvider.getUriForFile( - context, - context.getPackageName() + CommonValues.authority, - File.createTempFile("aic_state_store_temp", ".jpg", context.getCacheDir()) - ); - } else { - 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, - @NonNull Bitmap.CompressFormat compressFormat, - int compressQuality) - throws FileNotFoundException { - OutputStream outputStream = null; - try { - outputStream = context.getContentResolver().openOutputStream(uri); - if(compressFormat == null) { - bitmap.compress(Bitmap.CompressFormat.JPEG, compressQuality, outputStream); - } else { - bitmap.compress(compressFormat, compressQuality, outputStream); - } - } finally { - closeSafe(outputStream); - } - } - - /** - * Resize the given bitmap to the given width/height by the given option.
- */ - 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.
- * 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.
- * 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.
- * 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(android.graphics.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 -} diff --git a/cropper/src/main/java/com/canhub/cropper/BitmapUtils.kt b/cropper/src/main/java/com/canhub/cropper/BitmapUtils.kt new file mode 100644 index 00000000..4b018ef5 --- /dev/null +++ b/cropper/src/main/java/com/canhub/cropper/BitmapUtils.kt @@ -0,0 +1,964 @@ +package com.canhub.cropper + +import android.content.ContentResolver +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat +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 androidx.core.content.FileProvider +import androidx.exifinterface.media.ExifInterface +import com.canhub.cropper.CropImageView.RequestSizeOptions +import com.canhub.cropper.common.CommonValues +import com.canhub.cropper.common.CommonVersionCheck.isAtLeastQ29 +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 kotlin.math.abs +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sin + +/** + * Utility class that deals with operations with an ImageView. + */ +internal object BitmapUtils { + + val EMPTY_RECT = Rect() + val EMPTY_RECT_F = RectF() + + private const val IMAGE_MAX_BITMAP_DIMENSION = 2048 + + /** + * Reusable rectangle for general internal usage + */ + val RECT = RectF() + + /** + * Reusable point for general internal usage + */ + val POINTS = FloatArray(6) + + /** + * Reusable point for general internal usage + */ + val POINTS2 = FloatArray(6) + + /** + * Used to know the max texture size allowed to be rendered + */ + private var mMaxTextureSize = 0 + + /** + * used to save bitmaps during state save and restore so not to reload them. + */ + var mStateBitmap: Pair>? = null + + /** + * Rotate the given image by reading the Exif value of the image (uri).

+ * If no rotation is required the image will not be rotated.

+ * New bitmap is created and the old one is recycled. + */ + fun rotateBitmapByExif(bitmap: Bitmap?, context: Context, uri: Uri?): RotateBitmapResult { + var ei: ExifInterface? = null + try { + val `is` = context.contentResolver.openInputStream(uri!!) + if (`is` != null) { + ei = ExifInterface(`is`) + `is`.close() + } + } catch (ignored: Exception) { + } + return if (ei != null) rotateBitmapByExif(bitmap, ei) else RotateBitmapResult(bitmap, 0) + } + + /** + * Rotate the given image by given Exif value.

+ * If no rotation is required the image will not be rotated.

+ * New bitmap is created and the old one is recycled. + */ + fun rotateBitmapByExif(bitmap: Bitmap?, exif: ExifInterface): RotateBitmapResult { + val degrees: Int = when ( + exif.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL + ) + ) { + ExifInterface.ORIENTATION_ROTATE_90 -> 90 + ExifInterface.ORIENTATION_ROTATE_180 -> 180 + ExifInterface.ORIENTATION_ROTATE_270 -> 270 + else -> 0 + } + return RotateBitmapResult(bitmap, degrees) + } + + /** + * Decode bitmap from stream using sampling to get bitmap with the requested limit. + */ + fun decodeSampledBitmap( + context: Context, + uri: Uri, + reqWidth: Int, + reqHeight: Int + ): BitmapSampled { + return try { + val resolver = context.contentResolver + // First decode with inJustDecodeBounds=true to check dimensions + val options = decodeImageForOption(resolver, uri) + if (options.outWidth == -1 && options.outHeight == -1) throw RuntimeException("File is not a picture") + // Calculate inSampleSize + options.inSampleSize = max( + calculateInSampleSizeByReqestedSize( + options.outWidth, options.outHeight, reqWidth, reqHeight + ), + calculateInSampleSizeByMaxTextureSize(options.outWidth, options.outHeight) + ) + // Decode bitmap with inSampleSize set + val bitmap = decodeImage(resolver, uri, options) + BitmapSampled(bitmap, options.inSampleSize) + } catch (e: Exception) { + throw RuntimeException( + "Failed to load sampled bitmap: $uri\r\n${e.message}", e + ) + } + } + + /** + * Crop image bitmap from given bitmap using the given points in the original bitmap and the given + * rotation.

+ * 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.

+ * If crop fails due to OOM we scale the cropping image by 0.5 every time it fails until it is + * small enough. + */ + fun cropBitmapObjectHandleOOM( + bitmap: Bitmap?, + points: FloatArray, + degreesRotated: Int, + fixAspectRatio: Boolean, + aspectRatioX: Int, + aspectRatioY: Int, + flipHorizontally: Boolean, + flipVertically: Boolean + ): BitmapSampled { + var scale = 1 + while (true) { + try { + val cropBitmap = cropBitmapObjectWithScale( + bitmap!!, + points, + degreesRotated, + fixAspectRatio, + aspectRatioX, + aspectRatioY, + 1 / scale.toFloat(), + flipHorizontally, + flipVertically + ) + return BitmapSampled(cropBitmap, scale) + } catch (e: OutOfMemoryError) { + 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.

+ * 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 fun cropBitmapObjectWithScale( + bitmap: Bitmap, + points: FloatArray, + degreesRotated: Int, + fixAspectRatio: Boolean, + aspectRatioX: Int, + aspectRatioY: Int, + scale: Float, + flipHorizontally: Boolean, + flipVertically: Boolean + ): Bitmap? { + // get the rectangle in original image that contains the required cropped area (larger for non + // rectangular crop) + val rect = getRectFromPoints( + points, + bitmap.width, + bitmap.height, + fixAspectRatio, + aspectRatioX, + aspectRatioY + ) + // crop and rotate the cropped image in one operation + val matrix = Matrix() + matrix.setRotate(degreesRotated.toFloat(), bitmap.width / 2.0f, bitmap.height / 2.0f) + matrix.postScale( + if (flipHorizontally) -scale else scale, + if (flipVertically) -scale else scale + ) + var 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.config, 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.

+ * Additionally if OOM is thrown try to increase the sampling (2,4,8). + */ + fun cropBitmap( + context: Context, + loadedImageUri: Uri?, + points: FloatArray, + degreesRotated: Int, + orgWidth: Int, + orgHeight: Int, + fixAspectRatio: Boolean, + aspectRatioX: Int, + aspectRatioY: Int, + reqWidth: Int, + reqHeight: Int, + flipHorizontally: Boolean, + flipVertically: Boolean + ): BitmapSampled { + var 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 (e: OutOfMemoryError) { + // if OOM try to increase the sampling to lower the memory usage + sampleMulti *= 2 + if (sampleMulti > 16) { + throw RuntimeException( + "Failed to handle OOM by sampling ($sampleMulti): $loadedImageUri\r\n${e.message}", + e + ) + } + } + } + } + + /** + * Get left value of the bounding rectangle of the given points. + */ + fun getRectLeft(points: FloatArray): Float { + return min(min(min(points[0], points[2]), points[4]), points[6]) + } + + /** + * Get top value of the bounding rectangle of the given points. + */ + fun getRectTop(points: FloatArray): Float { + return min(min(min(points[1], points[3]), points[5]), points[7]) + } + + /** + * Get right value of the bounding rectangle of the given points. + */ + fun getRectRight(points: FloatArray): Float { + return max(max(max(points[0], points[2]), points[4]), points[6]) + } + + /** + * Get bottom value of the bounding rectangle of the given points. + */ + fun getRectBottom(points: FloatArray): Float { + return max(max(max(points[1], points[3]), points[5]), points[7]) + } + + /** + * Get width of the bounding rectangle of the given points. + */ + fun getRectWidth(points: FloatArray): Float { + return getRectRight(points) - getRectLeft(points) + } + + /** + * Get height of the bounding rectangle of the given points. + */ + fun getRectHeight(points: FloatArray): Float { + return getRectBottom(points) - getRectTop(points) + } + + /** + * Get horizontal center value of the bounding rectangle of the given points. + */ + fun getRectCenterX(points: FloatArray): Float { + return (getRectRight(points) + getRectLeft(points)) / 2f + } + + /** + * Get vertical center value of the bounding rectangle of the given points. + */ + fun getRectCenterY(points: FloatArray): Float { + 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. + */ + fun getRectFromPoints( + points: FloatArray, + imageWidth: Int, + imageHeight: Int, + fixAspectRatio: Boolean, + aspectRatioX: Int, + aspectRatioY: Int + ): Rect { + val left = max(0f, getRectLeft(points)).roundToInt() + val top = max(0f, getRectTop(points)).roundToInt() + val right = min(imageWidth.toFloat(), getRectRight(points)).roundToInt() + val bottom = min(imageHeight.toFloat(), getRectBottom(points)).roundToInt() + val rect = 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.

+ * Make sure that width and height are equal if 1:1 fixed aspect ratio is requested. + */ + private fun fixRectForAspectRatio(rect: Rect, aspectRatioX: Int, aspectRatioY: Int) { + 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. + */ + fun writeTempStateStoreBitmap(context: Context, bitmap: Bitmap?, uri: Uri?): Uri? { + var tempUri = uri + return try { + var needSave = true + if (tempUri == null) { + // We have this because of a HUAWEI path bug when we use getUriForFile + tempUri = if (isAtLeastQ29()) { + FileProvider.getUriForFile( + context, + context.packageName + CommonValues.authority, + File.createTempFile("aic_state_store_temp", ".jpg", context.cacheDir) + ) + } else { + Uri.fromFile( + File.createTempFile("aic_state_store_temp", ".jpg", context.cacheDir) + ) + } + } else if (tempUri.path?.let { File(it).exists() } == true) { + needSave = false + } + if (needSave) { + writeBitmapToUri(context, bitmap!!, tempUri, CompressFormat.JPEG, 95) + } + tempUri + } catch (e: Exception) { + Log.w( + "AIC", + "Failed to write bitmap to temp file for image-cropper save instance state", + e + ) + null + } + } + + /** + * Write the given bitmap to the given uri using the given compression. + */ + @Throws(FileNotFoundException::class) + fun writeBitmapToUri( + context: Context, + bitmap: Bitmap, + uri: Uri?, + compressFormat: CompressFormat?, + compressQuality: Int + ) { + var outputStream: OutputStream? = null + try { + outputStream = context.contentResolver.openOutputStream(uri!!) + + bitmap.compress(compressFormat ?: CompressFormat.JPEG, compressQuality, outputStream) + } finally { + closeSafe(outputStream) + } + } + + /** + * Resize the given bitmap to the given width/height by the given option.

+ */ + fun resizeBitmap( + bitmap: Bitmap?, + reqWidth: Int, + reqHeight: Int, + options: RequestSizeOptions + ): Bitmap { + try { + if (reqWidth > 0 && reqHeight > 0 && (options === RequestSizeOptions.RESIZE_FIT || options === RequestSizeOptions.RESIZE_INSIDE || options === RequestSizeOptions.RESIZE_EXACT)) { + var resized: Bitmap? = null + if (options === RequestSizeOptions.RESIZE_EXACT) { + resized = Bitmap.createScaledBitmap(bitmap!!, reqWidth, reqHeight, false) + } else { + val width = bitmap!!.width + val height = bitmap.height + val scale = max(width / reqWidth.toFloat(), height / reqHeight.toFloat()) + if (scale > 1 || options === RequestSizeOptions.RESIZE_FIT) { + resized = Bitmap.createScaledBitmap( + bitmap, (width / scale).toInt(), (height / scale).toInt(), false + ) + } + } + if (resized != null) { + if (resized != bitmap) { + bitmap.recycle() + } + return resized + } + } + } catch (e: Exception) { + Log.w("AIC", "Failed to resize cropped image, return bitmap before resize", e) + } + return bitmap!! + } + + /** + * 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 fun cropBitmap( + context: Context, + loadedImageUri: Uri, + points: FloatArray, + degreesRotated: Int, + orgWidth: Int, + orgHeight: Int, + fixAspectRatio: Boolean, + aspectRatioX: Int, + aspectRatioY: Int, + reqWidth: Int, + reqHeight: Int, + flipHorizontally: Boolean, + flipVertically: Boolean, + sampleMulti: Int + ): BitmapSampled { + // get the rectangle in original image that contains the required cropped area (larger for non + // rectangular crop) + val rect = getRectFromPoints( + points, + orgWidth, + orgHeight, + fixAspectRatio, + aspectRatioX, + aspectRatioY + ) + val width = if (reqWidth > 0) reqWidth else rect.width() + val height = if (reqHeight > 0) reqHeight else rect.height() + var result: Bitmap? = null + var sampleSize = 1 + try { + // decode only the required image from URI, optionally sub-sampling if reqWidth/reqHeight is + // given. + val bitmapSampled = + decodeSampledBitmapRegion(context, loadedImageUri, rect, width, height, sampleMulti) + result = bitmapSampled.bitmap + sampleSize = bitmapSampled.sampleSize + } catch (ignored: Exception) { + } + return 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 (e: OutOfMemoryError) { + result.recycle() + throw e + } + BitmapSampled(result, sampleSize) + } else { + // failed to decode region, may be skia issue, try full decode and then crop + 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 fun cropBitmap( + context: Context, + loadedImageUri: Uri, + points: FloatArray, + degreesRotated: Int, + fixAspectRatio: Boolean, + aspectRatioX: Int, + aspectRatioY: Int, + sampleMulti: Int, + rect: Rect, + width: Int, + height: Int, + flipHorizontally: Boolean, + flipVertically: Boolean + ): BitmapSampled { + var result: Bitmap? = null + val sampleSize: Int + try { + val options = BitmapFactory.Options() + sampleSize = ( + sampleMulti * + calculateInSampleSizeByReqestedSize( + rect.width(), + rect.height(), + width, + height + ) + ) + options.inSampleSize = sampleSize + val fullBitmap = decodeImage(context.contentResolver, loadedImageUri, options) + if (fullBitmap != null) { + try { + // adjust crop points by the sampling because the image is smaller + val points2 = FloatArray(points.size) + System.arraycopy(points, 0, points2, 0, points.size) + for (i in points2.indices) { + points2[i] = points2[i] / options.inSampleSize + } + result = cropBitmapObjectWithScale( + fullBitmap, + points2, + degreesRotated, + fixAspectRatio, + aspectRatioX, + aspectRatioY, 1f, + flipHorizontally, + flipVertically + ) + } finally { + if (result != fullBitmap) { + fullBitmap.recycle() + } + } + } + } catch (e: OutOfMemoryError) { + result?.recycle() + throw e + } catch (e: Exception) { + throw RuntimeException( + "Failed to load sampled bitmap: $loadedImageUri\r\n${e.message}", e + ) + } + return BitmapSampled(result, sampleSize) + } + + /** + * Decode image from uri using "inJustDecodeBounds" to get the image dimensions. + */ + @Throws(FileNotFoundException::class) + private fun decodeImageForOption(resolver: ContentResolver, uri: Uri): BitmapFactory.Options { + var stream: InputStream? = null + return try { + stream = resolver.openInputStream(uri) + val options = BitmapFactory.Options() + options.inJustDecodeBounds = true + BitmapFactory.decodeStream(stream, EMPTY_RECT, options) + options.inJustDecodeBounds = false + 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. + */ + @Throws(FileNotFoundException::class) + private fun decodeImage( + resolver: ContentResolver, + uri: Uri, + options: BitmapFactory.Options + ): Bitmap? { + do { + var stream: InputStream? = null + try { + stream = resolver.openInputStream(uri) + return BitmapFactory.decodeStream(stream, EMPTY_RECT, options) + } catch (e: OutOfMemoryError) { + options.inSampleSize *= 2 + } finally { + closeSafe(stream) + } + } while (options.inSampleSize <= 512) + throw 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 fun decodeSampledBitmapRegion( + context: Context, + uri: Uri, + rect: Rect, + reqWidth: Int, + reqHeight: Int, + sampleMulti: Int + ): BitmapSampled { + var stream: InputStream? = null + var decoder: BitmapRegionDecoder? = null + try { + val options = BitmapFactory.Options() + options.inSampleSize = ( + sampleMulti + * calculateInSampleSizeByReqestedSize( + rect.width(), rect.height(), reqWidth, reqHeight + ) + ) + stream = context.contentResolver.openInputStream(uri) + decoder = BitmapRegionDecoder.newInstance(stream, false) + do { + try { + return BitmapSampled(decoder.decodeRegion(rect, options), options.inSampleSize) + } catch (e: OutOfMemoryError) { + options.inSampleSize *= 2 + } + } while (options.inSampleSize <= 512) + } catch (e: Exception) { + throw RuntimeException( + "Failed to load sampled bitmap: $uri\r\n${e.message}", + e + ) + } finally { + closeSafe(stream) + decoder?.recycle() + } + return 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.

+ * Note: rotating by 0, 90, 180 or 270 degrees doesn't require extra cropping. + */ + private fun cropForRotatedImage( + bitmap: Bitmap?, + points: FloatArray, + rect: Rect, + degreesRotated: Int, + fixAspectRatio: Boolean, + aspectRatioX: Int, + aspectRatioY: Int + ): Bitmap? { + var tempBitmap = bitmap + if (degreesRotated % 90 != 0) { + var adjLeft = 0 + var adjTop = 0 + var width = 0 + var height = 0 + val rads = Math.toRadians(degreesRotated.toDouble()) + val compareTo = + if (degreesRotated < 90 || degreesRotated in 181..269) rect.left else rect.right + var i = 0 + while (i < points.size) { + if (points[i] >= compareTo - 1 && points[i] <= compareTo + 1) { + adjLeft = abs(sin(rads) * (rect.bottom - points[i + 1])) + .toInt() + adjTop = abs(cos(rads) * (points[i + 1] - rect.top)) + .toInt() + width = abs((points[i + 1] - rect.top) / sin(rads)) + .toInt() + height = abs((rect.bottom - points[i + 1]) / cos(rads)) + .toInt() + break + } + i += 2 + } + rect[adjLeft, adjTop, adjLeft + width] = adjTop + height + if (fixAspectRatio) { + fixRectForAspectRatio(rect, aspectRatioX, aspectRatioY) + } + val bitmapTmp = tempBitmap + tempBitmap = Bitmap.createBitmap( + bitmap!!, + rect.left, + rect.top, + rect.width(), + rect.height() + ) + if (bitmapTmp != tempBitmap) { + bitmapTmp?.recycle() + } + } + return tempBitmap + } + + /** + * 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 fun calculateInSampleSizeByReqestedSize( + width: Int, + height: Int, + reqWidth: Int, + reqHeight: Int + ): Int { + var 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 fun calculateInSampleSizeByMaxTextureSize( + width: Int, + height: Int + ): Int { + var inSampleSize = 1 + if (mMaxTextureSize == 0) { + mMaxTextureSize = maxTextureSize + } + if (mMaxTextureSize > 0) { + while ( + height / inSampleSize > mMaxTextureSize || + width / inSampleSize > mMaxTextureSize + ) { + inSampleSize *= 2 + } + } + return inSampleSize + } + + /** + * Rotate the given bitmap by the given degrees.

+ * New bitmap is created and the old one is recycled. + */ + private fun rotateAndFlipBitmapInt( + bitmap: Bitmap, + degrees: Int, + flipHorizontally: Boolean, + flipVertically: Boolean + ): Bitmap { + return if (degrees > 0 || flipHorizontally || flipVertically) { + val matrix = Matrix() + matrix.setRotate(degrees.toFloat()) + matrix.postScale( + (if (flipHorizontally) -1 else 1).toFloat(), + (if (flipVertically) -1 else 1).toFloat() + ) + val newBitmap = + Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, false) + if (newBitmap != bitmap) { + bitmap.recycle() + } + newBitmap + } else { + bitmap + } + } + // Only need to check for width since opengl textures are always squared + // Keep track of the maximum texture size + // Release + // Return largest texture size found, or default + // Get EGL Display + // Initialise + // Query total number of configurations + // Query actual list configurations + // Iterate through all the configurations to located the maximum texture size + // Safe minimum default size + /** + * Get the max size of bitmap allowed to be rendered on the device.

+ * http://stackoverflow.com/questions/7428996/hw-accelerated-activity-how-to-get-opengl-texture-size-limit. + */ + private val maxTextureSize: Int + get() { + // Safe minimum default size + + return try { + // Get EGL Display + val egl = EGLContext.getEGL() as EGL10 + val display = egl.eglGetDisplay(EGL10.EGL_DEFAULT_DISPLAY) + // Initialise + val version = IntArray(2) + egl.eglInitialize(display, version) + // Query total number of configurations + val totalConfigurations = IntArray(1) + egl.eglGetConfigs(display, null, 0, totalConfigurations) + // Query actual list configurations + val configurationsList = arrayOfNulls( + totalConfigurations[0] + ) + egl.eglGetConfigs( + display, + configurationsList, + totalConfigurations[0], + totalConfigurations + ) + val textureSize = IntArray(1) + var maximumTextureSize = 0 + // Iterate through all the configurations to located the maximum texture size + for (i in 0 until totalConfigurations[0]) { + // 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 + max(maximumTextureSize, IMAGE_MAX_BITMAP_DIMENSION) + } catch (e: Exception) { + 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 fun closeSafe(closeable: Closeable?) { + try { + closeable?.close() + } catch (ignored: IOException) { + } + } + + /** + * Holds bitmap instance and the sample size that the bitmap was loaded/cropped with. + */ + internal class BitmapSampled( + /** + * The bitmap instance + */ + val bitmap: Bitmap?, + /** + * The sample size used to lower the size of the bitmap (1,2,4,8,...) + */ + val sampleSize: Int + ) + + /** + * The result of [.rotateBitmapByExif]. + */ + internal class RotateBitmapResult( + /** + * The loaded bitmap + */ + val bitmap: Bitmap?, + /** + * The degrees the image was rotated + */ + val degrees: Int + ) +} diff --git a/cropper/src/main/java/com/canhub/cropper/CropImageView.kt b/cropper/src/main/java/com/canhub/cropper/CropImageView.kt index fa768e00..daa8e599 100644 --- a/cropper/src/main/java/com/canhub/cropper/CropImageView.kt +++ b/cropper/src/main/java/com/canhub/cropper/CropImageView.kt @@ -181,7 +181,7 @@ class CropImageView @JvmOverloads constructor(context: Context, attrs: Attribute mZoom = 1f mZoomOffsetY = 0f mZoomOffsetX = mZoomOffsetY - mCropOverlayView!!.resetCropOverlayView() + mCropOverlayView?.resetCropOverlayView() requestLayout() } } @@ -1088,15 +1088,16 @@ class CropImageView @JvmOverloads constructor(context: Context, attrs: Attribute var uri = state.getParcelable("LOADED_IMAGE_URI") if (uri != null) { val key = state.getString("LOADED_IMAGE_STATE_BITMAP_KEY") - if (key != null) { - val stateBitmap = - if (BitmapUtils.mStateBitmap != null && BitmapUtils.mStateBitmap.first == key) BitmapUtils.mStateBitmap.second.get() else null + key?.run { + val stateBitmap = BitmapUtils.mStateBitmap?.let { + if (it.first == key) it.second.get() else null + } BitmapUtils.mStateBitmap = null if (stateBitmap != null && !stateBitmap.isRecycled) { setBitmap(stateBitmap, 0, uri, state.getInt("LOADED_SAMPLE_SIZE"), 0) } } - if (imageUri == null) setImageUriAsync(uri) + imageUri ?: setImageUriAsync(uri) } else { val resId = state.getInt("LOADED_IMAGE_RESOURCE") @@ -1140,19 +1141,20 @@ class CropImageView @JvmOverloads constructor(context: Context, attrs: Attribute val widthSize = MeasureSpec.getSize(widthMeasureSpec) val heightMode = MeasureSpec.getMode(heightMeasureSpec) var heightSize = MeasureSpec.getSize(heightMeasureSpec) - if (mBitmap != null) { + val bitmap = mBitmap + if (bitmap != null) { // Bypasses a baffling bug when used within a ScrollView, where heightSize is set to 0. - if (heightSize == 0) heightSize = mBitmap!!.height + if (heightSize == 0) heightSize = bitmap.height val desiredWidth: Int val desiredHeight: Int var viewToBitmapWidthRatio = Double.POSITIVE_INFINITY var viewToBitmapHeightRatio = Double.POSITIVE_INFINITY // Checks if either width or height needs to be fixed - if (widthSize < mBitmap!!.width) { - viewToBitmapWidthRatio = widthSize.toDouble() / mBitmap!!.width.toDouble() + if (widthSize < bitmap.width) { + viewToBitmapWidthRatio = widthSize.toDouble() / bitmap.width.toDouble() } - if (heightSize < mBitmap!!.height) { - viewToBitmapHeightRatio = heightSize.toDouble() / mBitmap!!.height.toDouble() + if (heightSize < bitmap.height) { + viewToBitmapHeightRatio = heightSize.toDouble() / bitmap.height.toDouble() } // If either needs to be fixed, choose smallest ratio and calculate from there if (viewToBitmapWidthRatio != Double.POSITIVE_INFINITY || @@ -1160,16 +1162,16 @@ class CropImageView @JvmOverloads constructor(context: Context, attrs: Attribute ) { if (viewToBitmapWidthRatio <= viewToBitmapHeightRatio) { desiredWidth = widthSize - desiredHeight = (mBitmap!!.height * viewToBitmapWidthRatio).toInt() + desiredHeight = (bitmap.height * viewToBitmapWidthRatio).toInt() } else { desiredHeight = heightSize - desiredWidth = (mBitmap!!.width * viewToBitmapHeightRatio).toInt() + desiredWidth = (bitmap.width * viewToBitmapHeightRatio).toInt() } } else { // Otherwise, the picture is within frame layout bounds. Desired width is simply picture // size - desiredWidth = mBitmap!!.width - desiredHeight = mBitmap!!.height + desiredWidth = bitmap.width + desiredHeight = bitmap.height } val width = getOnMeasureSpec(widthMode, widthSize, desiredWidth) val height = getOnMeasureSpec(heightMode, heightSize, desiredHeight) @@ -1302,14 +1304,15 @@ class CropImageView @JvmOverloads constructor(context: Context, attrs: Attribute * @param height the height of the image view */ private fun applyImageMatrix(width: Float, height: Float, center: Boolean, animate: Boolean) { - if (mBitmap != null && width > 0 && height > 0) { + val bitmap = mBitmap + if (bitmap != null && width > 0 && height > 0) { mImageMatrix.invert(mImageInverseMatrix) val cropRect = mCropOverlayView!!.cropWindowRect mImageInverseMatrix.mapRect(cropRect) mImageMatrix.reset() // move the image to the center of the image view first so we can manipulate it from there mImageMatrix.postTranslate( - (width - mBitmap!!.width) / 2, (height - mBitmap!!.height) / 2 + (width - bitmap.width) / 2, (height - bitmap.height) / 2 ) mapImagePointsByImageMatrix() // rotate the image the required degrees from center of image